Tomcat CVE-2024-21733漏洞简单复现、分析

Tomcat CVE-2024-21733漏洞简单复现、分析

原创 黄连冠 华为安全应急响应中心 2024-09-06 18:40

1

前言

一句话概括这个漏洞,就是Tomcat在处理请求时不会清理缓冲区,由于某些原因,导致异常出现后标志位没有重置,进而导致异常堆栈抛出了没有被清理掉的缓冲区的数据

本文主要介绍了
1. 异常是怎么产生的

  1. 怎么构造exp是最佳实践

  2. 异常抛出的信息是哪里来的

2

漏洞简介

影响范围:

Apache Tomcat 9.0.0-M11 to 9.0.43

Apache Tomcat 8.5.7 to 8.5.63

漏洞描述:
1. 在受影响的版本中, 
Coyote.Http11InputBuffer.fill 在抛出 
CloseNowException 异常后没有重置缓冲区的 
position 和 
limit ,导致服务端可能可以获取另一个用户的请求数据。可以通过构造特定请求,在异常页面中输出其他请求的 body 数据。修复版本中通过增加finally代码块,保证默认会重设缓冲区position和limit到一致的状态。

  1. Client-side de-sync (CSD) vulnerabilities occur when a web server fails to correctly process the Content-Length of POST requests. By exploiting this behavior, an attacker can force a victim’s browser to de-synchronize its connection with the website, causing sensitive data to be smuggled from the server and/or client connections.

3

环境搭建

Tomcat搭建

首先我们需要在
https://archive.apache.org/dist/tomcat
找个受影响版本下载。然后在
bin/
目录下启动文件
start.bat

start.sh
改一下

set JAVA_TOOL_OPTIONS="-Duser.language=en"
set CATALINA_HOME=D:\Dev\Tomcat\apache-tomcat-9.0.43

这两步一个是设置英文环境,另一个是设置环境变量

再写一个jsp直接丢到
webapps/ROOT/
下面(本人是将war包放
webapps/
下面)

<%
    String id = request.getParameter("id");

    if (id != null) {
        out.println("The ID is: " + id);
    } else {
        out.println("No ID parameter provided.");
    }
%>

开启tomcat即可

Springboot搭建

之前分析CVE-2023-42795 时候用的环境还可以用,再用maven加springboot的依赖,再把内置的tomcat换成9.0.43(漏洞影响)版本

<properties>
    <tomcat.version>9.0.43</tomcat.version>
    <maven.compiler.source>8</maven.compiler.source>
    <maven.compiler.target>8</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.tomcat</groupId>
        <artifactId>tomcat-servlet-api</artifactId>
        <version>9.0.43</version>
        <scope>compile</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

由于springboot对4xx的响应,默认不会把异常堆栈打印到前端,所以我们需要修改下springboot的
resources/application .properties配置项;

server.tomcat.connection-timeout=5000
server.error.include-message=always
server.error.include-binding-errors=always
server.error.include-stacktrace=always

其中第一行的配置是修改tomcat的默认超时时间,这里为什么要改后面会详细说明,
然后再写一个controller

@RequestMapping(value = "/CVE-2024-21733", method = RequestMethod.POST)
public String cve(HttpServletRequest request) {
    String age = request.getParameter("age");
    return "received: " + age;
}

这里用什么注解区别不大,
@PostMapping(“/CVE-2024-21733”)也行,不过记得传参不能用
@RequestParam String age指定参数类型的方式,不然可能会抛别的异常,最后跑起来即可

4

漏洞复现

复现很简单,基本是稳定触发;这里我以springboot为例

首先发第一个请求,记得把数据包稍微写长一些

然后再发第二个请求

这里需要把
Content-Length自动更新取消勾选,然后把长度设置为大于实际请求体的任意长度即可请求包超时异常后,返回的异常堆栈中即可看到我们第一个请求发出的数据包;也即漏洞描述中的信息泄露到这里,即完成了简单的复现

5

漏洞分析

根据漏洞描述,漏洞产生的代码块在
org.apache.coyote.http11.Http11InputBuffer#fill,看下这是怎么写的

private boolean fill(boolean block) throws IOException {

    ...

    byteBuffer.mark();
    if (byteBuffer.position() < byteBuffer.limit()) {
        byteBuffer.position(byteBuffer.limit());
    }
    byteBuffer.limit(byteBuffer.capacity());
    SocketWrapperBase<?> socketWrapper = this.wrapper;
    int nRead = -1;
    if (socketWrapper != null) {
        nRead = socketWrapper.read(block, byteBuffer);
    } else {
        throw new CloseNowException(sm.getString("iib.eof.error"));
    }
    byteBuffer.limit(byteBuffer.position()).reset();

    ...

}
  • 一开始根据某些安全厂商的描述,意思是在抛出CloseNowException异常后,不会执行byteBuffer.limit (byteBuffer.position()).reset();去重置position的值,这个描述不准确,实际上和这个异常没关系

可以看到修改的地方在于:commit

把reset放到了finally中,以保证每次都会重置position的值

虽然公开的漏洞描述没有太多参考价值,但起码我们知道是Content-Length的问题就行,在白盒看代码前,我们先黑盒玩点花样,看看细节

黑盒玩点花样

细节一:泄露的前一个请求的数据,好像并不全,缺了一些

细节二:
请求的响应时间是5秒

在这里我们先回答细节二:

漏洞产生时是因为超时异常,tomcat默认的连接超时时间是20秒,每次测试都要等这么久太烦了,所以我们在前面提到了,修改配置项
server.tomcat.connection-timeout = 5000,将超时时间改为5秒,就在这里体现了

看到数据缺了一些,合理怀疑是Content-Length的问题,修改一下看看有什么变化

Content-Length的玩法

我们将
Content-Length设置为比实际长度大1,触发漏洞

在返回包可以看到:本次请求第1位后的数据包 以及 前一个请求的完整数据包的值

再试点别的,我们将
Content-Length设置为60

发现只剩下一个字符了,
再试一下修改当前请求包的长度

发现此次请求的数据包好像覆盖了上一个请求的数据包一部分,
最最后再试一次:

perfect!

于是在没看代码前,我们可以大胆推出公式(在不使用真实key-value的情况下):

len(leakage) = len(previous body) + len(actually length) + 1 -len(Content-Length)

但是
leakage会被本次请求的数据包覆盖一部分,所以最佳的实践应该是:

POST /CVE-2024-21733 HTTP/1.1
Host: 192.168.3.144:8080
Sec-Ch-Ua: "Chromium";v="119", "Not?A_Brand";v="24"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Linux"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.6045.159 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Priority: u=0, i
Connection: keep-alive
Content-Length: 2
Content-Type: application/x-www-form-urlencoded

x

如此便可以完整的将previous request body获取到了

白盒分析

初窥门径

由于修复的地方位于
org.apache.coyote.http11.Http11InputBuffer#fill,所以先从这里开始找

private boolean fill(boolean block) throws IOException {

    ...

    byteBuffer.mark();
    if (byteBuffer.position() < byteBuffer.limit()) {
        byteBuffer.position(byteBuffer.limit());
    }
    byteBuffer.limit(byteBuffer.capacity());
    SocketWrapperBase<?> socketWrapper = this.wrapper;
    int nRead = -1;
    if (socketWrapper != null) {
        nRead = socketWrapper.read(block, byteBuffer);
    } else {
        throw new CloseNowException(sm.getString("iib.eof.error"));
    }
    byteBuffer.limit(byteBuffer.position()).reset();

    ...

}

根据修复点,我们知道了是
byteBuffer.limit(byteBuffer.position()).reset();这行代码没被执行的问题,所以问题出在前面的读数据部分,
进入方法
org.apache.tomcat.util.net.NioEndpoint.NioSocketWrapper#read(boolean, java.nio.Byte Buffer)
,可以发现此处的作用只是把完整的请求包读进来,但还不会处理参数

因此这里也不是根因,我们得继续找会处理
Content-Length的地方,这里用个投机取巧的方法,就是问一下gpt:

略有小成
– 此时我发出疑问,为什么超时才触发漏洞?

一针见血的,根据调用关系很快就定位到了这个方法:

org.apache.catalina.connector.Request# parseParameters,下个断点看看堆栈

parseParameters:3285, Request (org.apache.catalina.connector)
getParameter:1142, Request (org.apache.catalina.connector)
getParameter:381, RequestFacade (org.apache.catalina.connector)
cve:93, TestController (com.vvmdx.example.controller)

...

service:346, CoyoteAdapter (org.apache.catalina.connector)
service:374, Http11Processor (org.apache.coyote.http11)
process:65, AbstractProcessorLight (org.apache.coyote)
process:887, AbstractProtocol$ConnectionHandler (org.apache.coyote)
doRun:1684, NioEndpoint$SocketProcessor (org.apache.tomcat.util.net)
run:49, SocketProcessorBase (org.apache.tomcat.util.net)
runWorker:1149, ThreadPoolExecutor (java.util.concurrent)
run:624, ThreadPoolExecutor$Worker (java.util.concurrent)
run:61, TaskThread$WrappingRunnable (org.apache.tomcat.util.threads)
run:750, Thread (java.lang)

org.apache.catalina.connector.Request#parse Parameters这里,容易发现这才是处理请求包的逻辑

首先他会获取请求包Content-Length字段的值,然后根据长度执行
org.apache.catalina.connector .Request#readPostBody方法,将body读出来

当我们设置的
Content-Length大于实际的请求体时,会导致缓冲区的
remaining>0,程序误以为还有数据没读取完成,就会循环的去读取,最终就会在
org.apache.tomcat.util.net.NioSelectorPool#read抛出超时异常

虽然本质上是这个超时异常的锅,但实际上这个异常没有被更外层的逻辑抛出,而是由于此异常导致部分代码没有被执行,进而导致了其他异常
– 自己调试的时候,可以把application.properties中server.tomcat.connection-timeout=5000的值调节一下,方便调试时慢慢看,或者快点抛出异常

  • 然后断点打在如下地方,可以根据异常堆栈慢慢跟

渐入佳境
– 我们现在探索exp不同的
Content-Length和真实数据包长度,对于泄露数据有什么影响?

第一个数据包大致查看了一下数据流,我们再发送一个正常的请求age=12345试试

注意到
postData的值其实依然是上一次请求的缓存数据,但如果是一个正常的请求包,正常读取的话,是不会读到多余的数据的,并且在读取完,会调用 
org.apache.coyote.http11.Http11In putBuffer#nextRequest把position 重置为0


– 这里其实就可以回答我们在尝试不同的Content-Length的值以及exp的body实际长度时,为什么会发现有时候泄露的数据会被覆盖或者丢失,正是因为此处的postData为上一次的缓存,在读入本次请求体的数据后,就会覆盖之前的数据

大致画了个示意图(不完全相同,但帮助理解)

所以我们在构造exp时,要尽可能让请求体的数据少一点,这样就不会覆盖前一个请求的数据;同时
Content-Length只需要比真实请求体稍大即可,不然
position会根据
Content-Length的值调整位置,导致数据读的不完整

登堂入室
– 接下来我们思考
为什么抛出的异常堆栈会有请求体数据

发送exp后,我们在控制台可以捕获到如下两个日志信息

把对应字符串拿去tomcat源码中搜一下,不难定位其在如下地方抛出

  1. org.apache.coyote.http11.Http11Processor#service

  2. org.apache.coyote.http11.Http11InputBuffer#parseRequestLine:我们主要看这个,因为正是他抛出了请求体数据


org.apache.coyote.http11.Http11InputBuffer# parseRequestLine 这个方法的如下代码块中

if (parsingRequestLinePhase == 2) {
    //
    // Reading the method name
    // Method name is a token
    //
    boolean space = false;
    while (!space) {
        // Read new bytes if needed
        if (byteBuffer.position() >= byteBuffer.limit()) {
            if (!fill(false)) // request line parsing
                return false;
        }
        // Spec says method name is a token followed by a single SP but
        // also be tolerant of multiple SP and/or HT.
        int pos = byteBuffer.position();
        chr = byteBuffer.get();
        if (chr == Constants.SP || chr == Constants.HT) {
            space = true;
            request.method().setBytes(byteBuffer.array(), parsingRequestLineStart,
                    pos - parsingRequestLineStart);
        } else if (!HttpParser.isToken(chr)) {
            // Avoid unknown protocol triggering an additional error
            request.protocol().setString(Constants.HTTP_11);
            String invalidMethodValue = parseInvalid(parsingRequestLineStart, byteBuffer);
            throw new IllegalArgumentException(sm.getString("iib.invalidmethod", invalidMethodValue));
        }
    }
    parsingRequestLinePhase = 3;
}

其原作用是用来读取请求方法(例如GET、POST等)的

逻辑大致如下:

  1. 检查缓冲区是否读取完毕
    byteBuffer.position() >= byteBuffer.limit(),是的话则调用
    org.apache.coyote.http11. Http11InputBuffer#fill从底层Socket继续读

  2. byteBuffer逐字节读取,当读到空格或者制表符时
    chr == Constants.SP || chr == Constants.HT,方法名读取完毕,终止循环

  3. 检查当前字节是否是http方法名合法字符
    !HttpParser.isToken(chr),不是的话就将byteBuffer的数据抛出为异常

臻于化境

最后我们思考当我们发送exp时,此时byteBuffer是什么呢?

可以看到byteBuffer正是我们前面数据的残留,因此读到不合法字符(默认填充的
0x00)时,就将整个byteBuffer转String抛出了


6

参考

  1. CVE-2024-21733 Apache Tomcat HTTP Request Smuggling

  2. https://hackerone.com/reports/2327341

  3. https://lists.apache.org/thread/h9bjqdd0odj6lhs2o96qgowcc6hb0cfz

  4. https://packetstormsecurity.com/files/176951/Apache-Tomcat-8.5.63-9.0.43-HTTP-Response-Smuggling.html

  5. https://github.com/LtmThink/CVE-2024-21733/


本公众号发布、转载的文章所涉及的技术、思路、工具仅供学习交流,任何人不得将其用于非法用途及盈利等目的,否则后果自行承担!


点这里
关注我们,一键三连~