Struts2全系漏洞分析(二)

文章发布时间:

最后更新时间:

文章总字数:
5.4k

预计阅读时间:
21 分钟

Struts2全系漏洞分析(二)

参考链接:

https://blog.csdn.net/Fly_hps/article/details/85001659

https://su18.org/post/struts2-1/#s2-009

前言

在继续Struts2全系漏洞的分析之前,我犹豫了一会,究竟是要继续分析Struts2剩下的其他漏洞,还是好好分析一下ysoserial的反序列化链呢,本着尽量旧坑未填不开新坑的原则,我还是继续来调试代码,分析Struts2的历史漏洞吧。

另外,在网上寻找怎么搭建S2-007漏洞时,发现了一个惊人的漏洞源码库,只需要将对应的文件夹用 idea 打开配置自己的 tomcat 即可运行,终于不用艰难地搭环境了,好耶!

仓库在这里:https://github.com/xhycccc/Struts2-Vuln-Demo

前一篇文章由于是第一次分析Struts2的漏洞,故分析的比较详细,这一篇就会简略一点,主要理解漏洞链以及漏洞触发的流程。

S2-007

影响版本:Struts 2.0.0 - Struts 2.2.3

漏洞成因:设置了age字段的验证规则,类型转换错误时,拦截器将其取出插入到了当前值栈中(字符串拼接),之后对其二次解析,造成表达式注入。

漏洞流程分析

首先漏洞触发的关键点在于转换出错之后的拦截器处理逻辑,即xwork中的ConversionErrorInterceptor,往age里面传入字符串www,打个断点分析一下处理的逻辑:

image-20230912194922738

既然说了要分析得简单一点,那就简单一点说,就是获取了OGNL的ValueStack,然后把发生了转换错误的参数的名和值放在这个新建的map,fakie中,最后把fakie放进了ValueStack:

但是这里需要注意一点,把参数值放进去fakie之前,调用了getOverrideExpr来处理:

image-20230912195221910

可以看到,就是用引号把我们的参数值包裹了起来,这时候大概对这个 payload 有点眉目了,既然我们可以控制参数值放到 ValueStack 中,那是否可以像sql注入一样先把引号闭合,然后使得恶意语句被当成普通的OGNL表达式被解析呢?别急,先往后面看。

后面一路跟进可以发现和 S2-001 是完全一样的,都是在 doEndTag 处调用 End 方法,然后对用户输入进行 OGNL 解析然后回填,S2-001是因为重复解析出现的问题,而这里是因为字符串逃逸产生的问题。这两的取值方式也有不同,S2-001 是从 ValueStack 中的 root 对象直接取值,而 S2-007 由于类型验证失败,用户输入值没法放到 Action 对象中,我们跟进看看 S2-007 是怎么解析类型转换出错参数的值的:

先正常findValue,然后tryFindValue,可以看到这里是去Overrides里面寻找:

image-20230912200534621

就是上面提到的 overrides,程序将用户输入前后添加单引号处理成字符串,然后放在 context 和 stack 对象中,在 doEndTag() 解析对应的参数 %{age} 时,会调用 lookupForOverrides() 方法在 stack 中取回用户输入。

image-20230912200514802

然后就成功把我们的值取回了,接下来调用getValue处理就进行了 OGNL 解析。

综上,我们只需要将单引号闭合,使得我们的输入逃逸出来被当成普通的 OGNL 表达式解析即可完成注入。

除了 ConversionErrorInterceptor,还有一个类能触发类型转换错误,那就是 RepopulateConversionErrorFieldValidatorSupport,原理相同,也许以后可以用来绕过waf。

payload :

' + (#_memberAccess["allowStaticMethodAccess"]=true ,#context["xwork.MethodAccessor.denyMethodExecution"]=new java.lang.Boolean("false"),@org.apache.commons.io.IOUtils@toString(@java.lang.Runtime@getRuntime().exec('whoami').getInputStream())) + '

这个 payload 有几点需要注意:

  • 自从 S2-003 和 005 之后,Struts 启用了安全措施,以后我们 OGNL 注入需要先设置一下 _memberAccess["allowStaticMethodAccess"]context["xwork.MethodAccessor.denyMethodExecution"],不然没法执行命令。
  • S2-007的依赖有org.apache.commons.io.IOUtils,所以可以直接用 toString 输出命令执行的结果。

漏洞修复

漏洞修复超级简单,只要把用户传进的参数值特殊符号转义就行(类似 addslashes ),直接给你单引号前面加个斜杠看你怎么闭合字符串。

S2-008

影响版本:Struts 2.0.0 - Struts 2.3.17(DebugModeRCE)

漏洞流程分析

通过 S2-003/S2-005 ,Struts 2 为了阻止攻击者在参数中植入恶意 OGNL,设置了 xwork.MethodAccessor.denyMethodExecution 以及 SecurityMemberAccess.allowStaticMethodAccess,并使用白名单正则 [a-zA-Z0-9\.][()_']+ 来匹配参数中的恶意调用,但是在一些特殊情况下,这些防御还是可以被绕过。

官方文档提出了 4 种绕过防御的手段,其中关注比较多的是 Debug 模式导致的绕过。

第一种就是刚刚分析过的 S2-007

第二种是由于进行参数名检测的安全函数 acceptedParamNames 并没有应用到 Cookie 参数名上,而 Cookie 参数名同样会被解析导致的表达式注入,看下 Cookie 拦截器的具体逻辑。

image-20230912205259201

简单来说就是获取 cookie 的名和值,然后调用 populateCookieValueIntoStack 方法,将 cookie 放入值栈中。

image-20230912205455279

可以看到这里面是调用了 setValue ,就像 S2-003 那样产生了漏洞。

但是通过 Cookie 进行表达式注入有两个严重的问题

  • 很多 WEB 容器对 Cookie 名称都有严格的字符限制,基本没得利用。
  • Cookie 拦截器不在默认的拦截器中,需要手动开启。

第三种是 ParameterInterceptor 任意文件覆盖:这是一种思路的拓展,由于 acceptedParamNames 正则允许了括号,因此可以调用一些构造方法可以执行操作的类,比如使用 FileWriter 的构造方法传入文件名可以直接创建这个文件或者清空其内容(看得出来其实没什么用,除了单纯想搞破坏的)

另外,如果 FileWriter 的参数直接写文件名的话,无法跳出执行目录,因为正则不允许使用 “” 或者 “/“,所以无法使用相对路径或者绝对路径,但是我们可以使用当前请求 action 的参数(这是为 S2-009 埋下了伏笔),因为这些参数会被放入 ValueStack 的 root 中,无需 # 即可调用。

name=/tmp/1.txt&su18[new+java.io.FileWriter(name)]=1

最后一种就是我们今天要重点分析的,debug 模式下的命令执行。怎么说呢,其实这个严格意义上也不算是漏洞,和默认密码差不多,开发的时候不谨慎导致的问题。

首先需要开启 debug 模式,很简单,在 Struts.xml 里面配置多一行即可。

image-20230912215237232

开启了 debug 模式之后,从 request 传入名为 debug 的参数,即可成功进入关键拦截器 DebuggingInterceptor :

image-20230912215451460

可以看到这里104到107行定义了四个字符串,这也是我们 debug 下可以选择的四种功能

  • debug=xml :从 ServletActionContext 中获取 response 对象,把一些 context 中的内容以 xml 的格式打印出来。

  • debug=command&expression=:非常清晰的漏洞调用点,如果参数 debug 是 command ,取参数 expression 的值,并调用 stack.findValue() 进行解析。

    image-20230912220123920

  • debug=console:弹出一个黑色的交互界面用于解析 Ognl 表达式。

  • debug=browser&object=:如果参数 debug 是 browser,取参数 object 的值,如果没有默认为 #context,并调用 stack.findValue() 进行解析

使用第二个来进行表达式注入,记得对特殊字符进行编码:

payload:debug=command&expression=(%23_memberAccess%5B%22allowStaticMethodAccess%22%5D%3Dtrue%2C%23foo%3Dnew%20java.lang.Boolean%28%22false%22%29%20%2C%23context%5B%22xwork.MethodAccessor.denyMethodExecution%22%5D%3D%23foo%2C@org.apache.commons.io.IOUtils@toString%28@java.lang.Runtime@getRuntime%28%29.exec%28%27ipconfig%27%29.getInputStream%28%29%29)

漏洞修复

加强了对于参数的正则匹配ParametersInterceptor.class,以及在CookieInterceptor.class中也做了限制

可以看到其实限制的是第二种和第三种利用,最后一种也限制不了,总不能把 debug 直接关了吧,说到底应用部署之后开着 debug 的开发确实是被攻击少了(笑)

S2-009

影响版本:Struts 2.0.0-Struts 2.3.1.1

S2-009 是对 S2-005 的绕过,但是不同的是,S2-009 是参数值注入,对于 S2-003/S2-005 都是参数名的 OGNL 注入,这次的漏洞出在参数值上。

漏洞流程分析

正如之前说的,利用 action 参数值可以规避正则校验,而且 action 参数值会直接放进 ValueStac k中,不使用 # 就可以在 OGNL 表达式中直接调用,也就是说我们可以传两个参数,第一个参数值用来写恶意表达式,第二个的参数名通过直接调用第一个的参数名就可以使用恶意表达式,再加上之前分析过的特定 AST 树格式即可完成表达式注入。(有点像 php 命令执行过滤的绕过:参数转移)

简单来说,当传入(ONGL)(1)时,会将前者视为 ONGL 表达式来执行,从而绕过了正则的匹配保护。而且由于其在HTTP参数值中,也可以进一步绕过字符串限制的保护

另外由于 tomcat6 以上不允许在 URL 里面输入某些字符,导致我在测试 payload 的时候一直 400 ,那我肯定不会为了测试一下换个 tomcat 。

提供两种办法解决测试 payload 问题:

  • 使用 vulhub 一键搭建漏洞环境。
  • 在 TestAction 里的 excute 方法处定义参数,访问看是否能弹计算器即可。

以下三种 payload 中变化的只有第二个参数的参数名(因为需要二次解析), foo 传入的恶意表达式是不变的,一直都是(#context["xwork.MethodAccessor.denyMethodExecution"]=new java.lang.Boolean(false), #_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec("calc"))

第一种payload

(foo)(pazuris)=true

image-20230913100058779

另一种payload

one[(two)(three)],在 OGNL 解析这个表达式时,他本身是 ASTChain,首先会解析成为两个 ASTProperty :one[(two)(three)],对于后者来说,和我们上文讨论的并无区别。简单来说使用 one[(two)(three)] 表达式,会对 two 进行二次解析。

paz[(foo)(uris)]=true

image-20230913101915632

还有一种payload

事实上,我们刚刚使用的两种都是基于之前了解过的 ASTEVAL 的 payload ,而官方的通报中有一种新的表达式执行方式 top['foo'](0)(这里注意一点,这个 top 是固定的名称!)

在上下文中,可以使用 top 来访问 Action 中的成员变量,这种方式会对 foo 进行二次解析。

image-20230913102001977

简单来说,top[‘foo’] 这个 ASTChain 会被解析成 top 和 foo 两个ASTProperty ,第一个 top 会使得 getProperty 方法返回 root 中定义的一个对象,而第一个对象就是 action 获取的参数值。再通过 foo 获取参数值中 foo 对应的恶意表达式,即可完成表达式注入

漏洞修复

主要是在参数名拦截器上又加了升级,把我们上述的三种payload都拦下了。

Struts2.3.1.1private String acceptedParamNames = "[a-zA-Z0-9\\.\\]\\[\\(\\)_']+";

Struts2.3.1.2private String acceptedParamNames = "\\w+((\\.\\w+)|(\\[\\d+\\])|(\\(\\d+\\))|(\\['\\w+'\\])|(\\('\\w+'\\)))*";

S2-012

影响版本:2.0.0 - 2.3.14.2

漏洞触发原理与 S2-001 类似,对 %{} 表达式进行了循环解析.

简单来说如果配置 Action 中的 Result 时使用了重定向类型,并且还使用${param_name}作为重定向结果,当触发 redirect 类型返回时,Struts2 使用${param_name}获取其值,在这个过程中会对 name 参数的值执行 OGNL 表达式解析,从而可以插入任意 OGNL 表达式导致任意代码执行

在 Struts.xml 中这样配置(其实用的还是上篇文章提到过的源码库,都是别人配置好的):

1
2
3
<action name="index" class="org.test.IndexAction">
<result name="redirect" type="redirect">/redirect.action?user=${name}</result>
</action>

可以看到这里的配置符合我们刚才说的条件,Result 使用了重定向类型并使用${name}作为了重定向的结果。

漏洞流程分析

断点打在 xwork 的 DefaultActionInvocation 的221行,传入%{1+1}从 Action 构造开始分析:

image-20230913143624469

可以看到在 invoke 方法中,调用了 executeResult 方法,跟进:

image-20230913143652349

这里首先调用了 createResult 方法,简单来说就是根据 action 的返回值获取对应的 result 标签配置,createResult 里面又调用了 buildResult 来构建一个对应的 Result 实现类,对我们这里来说就是org.apache.struts2.dispatcher.ServletRedirectResult 然后将我们传入 url 路径 redirect.action?name=%{1+1}赋值给 location :

image-20230913144121140

将结果返回,回到了 executeResult 中,看到下一个关键方法,调用了 result 的 execute 方法(里面又调用了父类的 execute 方法):

image-20230913144358142

这里使用了 conditionalParse 来处理 location,最终得到 lastFinalLocation。直接步过,可以发现 lastFinalLocation 的值变为了 redirect.action?name=2 ,说明我们输入的 OGNL 表达式已经得到了解析,所以问题出现在这个 conditionalParse 中,看到了很类似 S2-001 的 translateVariables 方法:

image-20230913144907103

跟进可以发现嵌套调用,这里多给了一个参数,包含两个字母的字母数组,这就是后面二次解析问题产生的关键。

image-20230913114602827

这里就是最终的表达式解析方法,可以发现,因为 S2-001 的出现,限制了 maxloop ,但是限制的是 while ,这里还有个 for ,刚好循环两次,导致了对 user 参数值的二次解析,完成表达式注入。

payload:由于没有想到这里能注入,所以没有防御,不需要开 denyMethodExecution

1
%{#_memberAccess["allowStaticMethodAccess"]=true,@java.lang.Runtime@getRuntime().exec("calc")}

另外还有一种不用调用静态方法的 payload (利用 ProcessBuilder )

%{new java.lang.ProcessBuilder('calc').start()}

其实之前的 payload 中也可以采用下面那种,就可以不用开静态方法允许。

漏洞修复

image-20230913114602827

仔细观察这个方法的源码,可以看出问题其实是出在这个 pos 每次循环都会置0,按道理应该 start 一直往后,而不是重复回到一开始再寻找%$

所以2.3.14.3中将int pos = 0删除,pos计算方式就只有原来下面的那个:

pos = (left != null && left.length() > 0 ? left.length() - 1: 0) + (middle != null && middle.length() > 0 ? middle.length() - 1: 0) + 1;

从而导致把原来的 url 走完一次之后不会再进入循环解析,而是直接退出。

S2-013

影响版本:Struts 2.0.0 - Struts 2.3.14.1

前置知识:

在Struts2中,<s:a><s:url> 是两种常用的链接标签,它们都可以用来创建和渲染超链接。这两个标签主要的区别在于它们如何处理URL和链接的具体显示。

<s:a> 标签用于创建一个超链接(HTML的 “a” 标签)。它允许你指定链接的URL、链接的显示文本以及任何其他你想添加的HTML属性。

1
<s:a href="http://www.example.com">Visit Example.com</s:a>

在这个例子中,<s:a> 标签创建了一个指向www.example.com的链接,链接的显示文本是 “Visit Example.com”。

<s:url> 标签用于创建一个URL。它允许你指定URL的各个部分,包括协议(例如http或https)、服务器名称、端口号、路径和查询参数。然后,它会把这些部分组合成一个完整的URL。

1
<s:url action="myAction" />

在这个例子中,<s:url> 标签创建了一个指向”myAction”动作的URL。

这两个标签的主要区别在于 <s:a> 是用来创建一个可视化的链接,而 <s:url> 是用来创建一个URL,这个URL可以在其他地方使用,不一定要在一个可视化的链接中使用。

在这两个标签中,存在一个属性 includeParams,有三个属性值:

  • none:URL 中不包含参数。
  • get:包含 URL 中的 GET 参数。
  • all:包含 URL 中的 GET 和 POST 参数。

这个属性的作用是将请求当前页面的参数转发到标签中的链接中。

image-20230913155744957

也就是说我们只要在请求 index.jsp 的时候带上参数就可以把参数转发到HelloWorld.action 中且会对参数进行解析,这就是漏洞产生点。

漏洞流程分析

ComponentTagSupport#doStartTag() 方法开始解析标签

image-20230913201800883

然后调用了 start 方法:

image-20230913201656689

接着调用了 UrlRenderer 的 beforeRenderUrl() 来渲染链接标签中的 URL。

image-20230913201448291

这里是获取了 urlComponent ,里面有各种数据包括我们传入的参数,直接跳到下一个关键点,doEndTag 调用了 end 方法,而 end 方法里面调用了 renderUrl 来处理我们的 urlProvider,后调用了 determineActionURL :

image-20230913202706813

determineActionURL 中调用了 buildUrl 方法,并将参数等传入:

image-20230913202837958

buildUrl 中的关键是调用了 buildParametersString 方法,这个方法一看就是处理我们的参数的,而里面又调用了 buildParameterSubstring 方法来具体处理:

image-20230913203125710

关键方法是上图所示的 translateAndEncode ,可以看到这里传入了我们构造的参数值:

image-20230913203221012

这里看到了老熟人, translateVariable ,后面的流程和 S2-012 完全一样,故 payload 也和 S2-012 一样,只不过要作为参数值传入:

/?a=%24%7B%23_memberAccess%5B"allowStaticMethodAccess"%5D%3Dtrue%2C%23a%3D%40java.lang.Runtime%40getRuntime().exec(%27whoami%27).getInputStream()%2C%23b%3Dnew%20java.io.InputStreamReader(%23a)%2C%23c%3Dnew%20java.io.BufferedReader(%23b)%2C%23d%3Dnew%20char%5B50000%5D%2C%23c.read(%23d)%2C%23out%3D%40org.apache.struts2.ServletActionContext%40getResponse().getWriter()%2C%23out.println(%27dbapp%3D%27%2Bnew%20java.lang.String(%23d))%2C%23out.close()%7D

注意适用版本也和 S2-012 一样。

漏洞修复

同 S2-012 的修复。

S2-015

影响版本:Struts 2.0.0 - Struts 2.3.14.2

官方公布了两种漏洞利用方式,一种是通配符匹配 action ,一种是在 struts.xml 中使用 ${} 引用 Action 变量导致的二次解析。

前置知识

在 Struts2 中,* 通配符可以在 struts.xml 配置文件中的 <action> 标签中使用,用于匹配 action 名称。

1
2
3
<action name="user_*" class="com.example.UserAction" method="{1}">
<result name="success">/pages/{1}.jsp</result>
</action>

同时可以使用{1},{2}(有顺序)等获取用户输入的*的值。

漏洞流程分析

通配符匹配 action

image-20230913230412287

没有对传入 Action 名称进行转义和过滤造成 OGNL 解析,这个漏洞原理与 S2-012 类似,012利用的是重定向,而这个利用的是 Action 的名称。

在我们的配置中,使用 * 匹配了全部的 action 地址,并返回 {1}.jsp ,这些信息放在了 ResultConfig 对象中的 location 中,最后处理结果时将会进行解析和渲染。

后面的就和 S2-012 完全一样,都是 DefaultActionInvocation 的 executeResult 方法调用 StrutsResultSupport 的 execute() 方法 调用 conditionalParse() 最后调用 TextParseUtil.translateVariables() 方法解析这个地址。

需要注意的是,在 Struts 2.3.14.2 中,官方将 SecurityMemberAccess 类中成员变量 allowStaticMethodAccess 添加了 final 修饰符,并且将其 set 方法进行了删除。这就导致了我们不能通过 #_memberAccess["allowStaticMethodAccess"]=true 来改变其值,因为没有 set 方法了。

但是依然可以有两个方法来绕过:

  • 使用反射修改值#f=#_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),#f.setAccessible(true),#f.set(#_memberAccess,true),

  • 直接用非静态方法 ProcessBuilder.start()

    new java.lang.ProcessBuilder(new java.lang.String[]{"calc"}).start()

最终 payload 和 S2-012 也完全一样,只不过传入后面要加个 .action

1
/${#context['xwork.MethodAccessor.denyMethodExecution']=false,#f=#_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),#f.setAccessible(true),#f.set(#_memberAccess,true),@java.lang.Runtime@getRuntime().exec('calc')}.action

当然此处用$和%都是一样的。

引用 Action 变量

image-20230913230834290

我们传入的名为 message 的参数会被解析然后填入 headers.foobar,这里存在二次解析漏洞。

在处理返回结果时,处理响应包头部信息使用 HttpHeaderResult 类的 execute() 方法,取得${message} 的内容,然后调用 TextParseUtil.translateVariables() 进行解析。

当用户输入参数被用来配置返回结果时,会遭到二次解析,这与上一个点的漏洞原理是相通的。

所以 payload 也基本是一样的:

1
/helloworld.action?message=${#context['xwork.MethodAccessor.denyMethodExecution']=false,#f=#_memberAccess.getClass().getDeclaredField('allowStaticMethodAccess'),#f.setAccessible(true),#f.set(#_memberAccess,true),@java.lang.Runtime@getRuntime().exec('calc')}

记得 url 编码再发。

漏洞修复

第一种问题的修复

对 action 参数名进行了过滤。protected String allowedActionNames = "[a-z]*[A-Z]*[0-9]*[.\\-_!/]*";

第二种问题的修复

取消 int pos = 0,和之前说的一样。

后记

感觉 Struts2 的漏洞分析可以暂时停一停了(),先去看看反序列化的链子,这个 Struts2 来来回回都是通过差不多的函数和二次解析漏洞来解析毫无过滤的 OGNL 表达式,从中只锻炼了看代码的能力,并没有学到什么实用的绕过技巧。

不过现在看 java 代码能力有了提高,再重看 ysoserial 肯定会比一开始轻松不少,说实话反序列化才是 java 最重点的漏洞类型,Struts2 肯定每次都是一把检测一把梭()。