Analyzing CVE-2024-34750 Apache Tomcat DoS

images

Preface

Bài viết sẽ phân tích lại CVE-2024-34750, CVE mà mình tìm được gần đây. Về web server Tomcat chắc cũng không còn phải giới thiệu nhiều, trong các ứng dụng java web servlet hay spring framework thì tomcat luôn là server được lựa chọn để deploy phổ biến nhất cho đến nay.

Nói ngắn gọn, CVE-2024-34750 cho phép DoS sập HTTP/2 connector của tomcat bằng cách trigger lỗi StreamException làm giá trị activeRemoteStreamCount bị đếm sai dẫn đến connection timeout bị set vô thời hạn (connection không được đóng sau khi xử lý xong). Khi số lượng connection được mở đạt đến giới hạn, tomcat connector không thể handle thêm request dẫn đến DoS.

Bài viết yêu cầu người đọc có hiểu biết basic về giao thức HTTP/2, có thể tham khảo chuẩn RFC: https://datatracker.ietf.org/doc/html/rfc9113

Versions affect:

Apache Tomcat 11.0.0-M1 to 11.0.0-M20
Apache Tomcat 10.1.0-M1 to 10.1.24
Apache Tomcat 9.0.0-M1 to 9.0.89
....và các versions cũ hơn mà apache không còn support

Conditions: Sử dụng giao thức HTTP/2 của tomcat (connector) Advisory: https://lists.apache.org/thread/4kqf0bc9gxymjc2x7v3p7dvplnl77y8l

Http2 Notes:

  • Connection: Http2 connection hay đúng hơn là TCP connection
  • Stream: Stream là một khối data chứa một hoặc nhiều các frames được gửi đi trong connection
  • Frame: Là một frame data theo chuẩn RFC ứng với các thành phần trong http / events như HEADERS, CONTINUATION, DATA, SETTINGS frames

Setup

Tại <tomcat-home>/conf/server.xml uncomment block sau để enable HTTP/2 connector:

1
2
3
4
5
6
7
8
<Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol"
           maxThreads="150" SSLEnabled="true">
    <UpgradeProtocol className="org.apache.coyote.http2.Http2Protocol" />
    <SSLHostConfig>
        <Certificate certificateKeystoreFile="conf/localhost-rsa.jks"
                     type="RSA" />
    </SSLHostConfig>
</Connector>

conf/localhost-rsa.jks có thể lấy từ đây

Analysis

Http2UpgradeHandler#upgradeDispatch()

Đầu tiên đến với Http2UpgradeHandler#upgradeDispatch()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
public SocketState upgradeDispatch(SocketEvent status) {
    if (log.isTraceEnabled()) {
        log.trace(sm.getString("upgradeHandler.upgradeDispatch.entry", connectionId, status));
    }

    // WebConnection is not used so passing null here is fine
    // Might not be necessary. init() will handle that.
    init(null);

    SocketState result = SocketState.CLOSED;

    try {
        switch (status) {
            case OPEN_READ:
                socketWrapper.getLock().lock();
                try {
                    if (socketWrapper.canWrite()) {
                        // Only send a ping if there is no other data waiting to be sent.
                        // Ping manager will ensure they aren't sent too frequently.
                        pingManager.sendPing(false);
                    }
                } finally {
                    socketWrapper.getLock().unlock();
                }
                try {
                    // Disable the connection timeout while frames are processed
                    setConnectionTimeout(-1);
                    while (true) {
                        try {
                            if (!parser.readFrame()) {
                                break;
                            }
                        } catch (StreamException se) {
                            // Log the Stream error but not necessarily all of
                            // them
                            UserDataHelper.Mode logMode = userDataHelper.getNextMode();
                            if (logMode != null) {
                                String message = sm.getString("upgradeHandler.stream.error", connectionId,
                                        Integer.toString(se.getStreamId()));
                                switch (logMode) {
                                    case INFO_THEN_DEBUG:
                                        message += sm.getString("upgradeHandler.fallToDebug");
                                        //$FALL-THROUGH$
                                    case INFO:
                                        log.info(message, se);
                                        break;
                                    case DEBUG:
                                        log.debug(message, se);
                                }
                            }
                            // Stream errors are not fatal to the connection so
                            // continue reading frames
                            Stream stream = getStream(se.getStreamId(), false);
                            if (stream == null) {
                                sendStreamReset(null, se);
                            } else {
                                stream.close(se);
                            }
                        } finally {
                            if (isOverheadLimitExceeded()) {
                                throw new ConnectionException(
                                        sm.getString("upgradeHandler.tooMuchOverhead", connectionId),
                                        Http2Error.ENHANCE_YOUR_CALM);
                            }
                        }
                    }

                    // Need to know the correct timeout before starting the read
                    // but that may not be known at this time if one or more
                    // requests are currently being processed so don't set a
                    // timeout for the socket...
                    socketWrapper.setReadTimeout(-1);

                    // ...set a timeout on the connection
                    setConnectionTimeoutForStreamCount(activeRemoteStreamCount.get());

// .....truncated
    return result;
}

Tất cả các http2 frame của client đều đi qua method này để phân loại và xử lý. Connection sẽ được set timeout vô thời hạn trong quá trình xử lý các frame này (line 27, giá trị -1 ứng với vô thời hạn). Các frame có thể đến từ nhiều streams khác nhau, sau khi đọc hết tất cả các frame, tại line 75 gọi setConnectionTimeoutForStreamCount():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
protected void setConnectionTimeoutForStreamCount(int streamCount) {
    if (streamCount == 0) {
        // No streams currently active. Use the keep-alive
        // timeout for the connection.
        long keepAliveTimeout = protocol.getKeepAliveTimeout();
        if (keepAliveTimeout == -1) {
            setConnectionTimeout(-1);
        } else {
            setConnectionTimeout(System.currentTimeMillis() + keepAliveTimeout);
        }
    } else {
        // Streams currently active. Individual streams have
        // timeouts so keep the connection open.
        setConnectionTimeout(-1);
    }
}

Để kiểm tra còn active stream nào tồn tại cần xử lý tiếp không, nếu không (ứng với line 2-10) sẽ thực hiện set timeout cho connection (default: protocol.getKeepAliveTimeout() ~ 20000ms). Nếu vẫn còn (line 11-15) tiếp tục set timeout vô hạn để tiếp tục xử lý.

Headers Frame

Một Frame chính trong http2 request mà không thể thiếu đó chính là Headers Frame, khi tomcat parse frame này tại Http2Parser#readHeadersFrame() có 2 nơi cần chú ý:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
protected void readHeadersFrame(int streamId, int flags, int payloadSize, ByteBuffer buffer)
        throws Http2Exception, IOException {

    headersEndStream = Flags.isEndOfStream(flags);

    if (hpackDecoder == null) {
        hpackDecoder = output.getHpackDecoder();
    }
    try {
        hpackDecoder.setHeaderEmitter(output.headersStart(streamId, headersEndStream));
    } catch (StreamException se) {
        swallowPayload(streamId, FrameType.HEADERS.getId(), payloadSize, false, buffer);
        throw se;
    }
    
    // .. truncated
    
    readHeaderPayload(streamId, payloadSize, buffer);

    swallowPayload(streamId, FrameType.HEADERS.getId(), padLength, true, buffer);

    // Validate the headers so far
    hpackDecoder.getHeaderEmitter().validateHeaders();

    if (Flags.isEndOfHeaders(flags)) {
        onHeadersComplete(streamId);
    } else {
        headersCurrentStream = streamId;
    }
}

Tại line 10 sẽ khởi tạo stream mới thông qua Http2UpgradeHandler#headersStart() để lưu / xử lý headers, cũng như sau khi đọc hết header data (line 18), sẽ gọi lần lượt onHeadersComplete() (line 26)–> Http2UpgradeHandler#headersEnd():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public void headersEnd(int streamId, boolean endOfStream) throws Http2Exception {
    AbstractNonZeroStream abstractNonZeroStream =
            getAbstractNonZeroStream(streamId, connectionState.get().isNewStreamAllowed());
    if (abstractNonZeroStream instanceof Stream) {
        boolean processStream = false;
        setMaxProcessedStream(streamId);
        Stream stream = (Stream) abstractNonZeroStream;
        if (stream.isActive()) {
            if (stream.receivedEndOfHeaders()) {

                if (localSettings.getMaxConcurrentStreams() < activeRemoteStreamCount.incrementAndGet()) {
                    decrementActiveRemoteStreamCount();
                    // Ignoring maxConcurrentStreams increases the overhead count
                    increaseOverheadCount(FrameType.HEADERS);
                    throw new StreamException(
                            sm.getString("upgradeHandler.tooManyRemoteStreams",
                                    Long.toString(localSettings.getMaxConcurrentStreams())),
                            Http2Error.REFUSED_STREAM, streamId);
                }
                // Valid new stream reduces the overhead count
                reduceOverheadCount(FrameType.HEADERS);

                processStream = true;
            }
        }

Tại line 11 ở câu điều kiện if sẽ gọi activeRemoteStreamCount.incrementAndGet() để tăng giá trị activeRemoteStreamCount lên += 1 đồng thời lấy số lượng stream đang active để so sánh

Sau khi đọc / xử lý xong Headers Frame này để trả về response sẽ gọi lại Http2UpgradeHandler#sentEndOfStream():

1
2
3
4
5
6
protected void sentEndOfStream(Stream stream) {
    stream.sentEndOfStream();
    if (!stream.isActive()) {
        decrementActiveRemoteStreamCount();
    }
}

Do stream không còn active (đã hoàn thành), gọi decrementActiveRemoteStreamCount():

1
2
3
protected void decrementActiveRemoteStreamCount() {
    setConnectionTimeoutForStreamCount(activeRemoteStreamCount.decrementAndGet());
}

Từ đó sẽ activeRemoteStreamCount giảm -= 1

==> Stream đã xử lý xong, loại stream khỏi active stream. activeRemoteStreamCount lúc này sẽ bằng 0, connection được set timeout trừ khi client request nhiều Headers Frame khác từ đó sẽ còn stream để tiếp tục xử lý. Ngoài Http2UpgradeHandler#sentEndOfStream() gọi đến decrementActiveRemoteStreamCount() thì các method sau cũng gọi đến để check và set timeout sau mỗi lần xử lý stream:

Http2AsyncUpgradeHandler:
    sendStreamReset
Http2UpgradeHandler:
    sendStreamReset
    sentEndOfStream
    receivedEndOfStream
    reset
    headersEnd

Có thể thấy các method này được gọi chủ yếu khi stream gặp lỗi / kết thúc stream

Break the logic

Như đã đề cập, activeRemoteStreamCount được += 1 khi đọc xong header data và thực hiện Http2UpgradeHandler#headersEnd(), cùng xem lại method readHeadersFrame

Vậy nếu ta có thể tìm cách trigger lỗi gì đó để khi tomcat đọc header mà không reach đến được Http2UpgradeHandler#headersEnd() -> activeRemoteStreamCount vẫn bằng 0. Tuy nhiên do lỗi mà tomcat phải end/reset stream mà gọi đến decrementActiveRemoteStreamCount() ==> activeRemoteStreamCount-1 –> setConnectionTimeoutForStreamCount() set timeout vô thời hạn.

Trong lúc test mình tình cờ phát hiện method Http2Parser#readHeaderPayload() (line 18) (tức ngay trước khi headersEnd() kết thúc frame):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
protected void readHeaderPayload(int streamId, int payloadSize, ByteBuffer buffer)
        throws Http2Exception, IOException {

    // ... truncated

        if (hpackDecoder.isHeaderSizeExceeded(headerReadBuffer.position())) {
            StreamException headerException = new StreamException(
                    sm.getString("http2Parser.headerLimitSize", connectionId, Integer.valueOf(streamId)),
                    Http2Error.ENHANCE_YOUR_CALM, streamId);
            hpackDecoder.getHeaderEmitter().setHeaderException(headerException);
        }

        if (hpackDecoder.isHeaderSwallowSizeExceeded(headerReadBuffer.position())) {
            throw new ConnectionException(
                    sm.getString("http2Parser.headerLimitSize", connectionId, Integer.valueOf(streamId)),
                    Http2Error.ENHANCE_YOUR_CALM);
        }
    }
}

Nếu isHeaderSizeExceeded() –> set StreamException / Tuy nhiên không vào isHeaderSwallowSizeExceeded() để tránh ConnectionException - luồng chương trình sau method này sẽ rơi về catch block StreamException của Http2UpgradeHandler#upgradeDispatch() (line 33-59) đầu bài

Đặc biệt tại line 57 sẽ gọi stream.close(se) để close stream, code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
final void close(Http2Exception http2Exception) {
    if (http2Exception instanceof StreamException) {
        try {
            StreamException se = (StreamException) http2Exception;
            if (log.isTraceEnabled()) {
                log.trace(sm.getString("stream.reset.send", getConnectionId(), getIdAsString(), se.getError()));
            }

            handler.sendStreamReset(state, se);

            cancelAllocationRequests();
            if (inputBuffer != null) {
                inputBuffer.swallowUnread();
            }
        } catch (IOException ioe) {
            ConnectionException ce =
                    new ConnectionException(sm.getString("stream.reset.fail", getConnectionId(), getIdAsString()),
                            Http2Error.PROTOCOL_ERROR, ioe);
            handler.closeConnection(ce);
        }
    } else {
        handler.closeConnection(http2Exception);
    }
    recycle();
}

Tại method này lại thấy có gọi đến sendStreamReset(), một method mà mình đã đề cập ở trên sẽ gọi đến được decrementActiveRemoteStreamCount() ==> Lúc này activeRemoteStreamCount sẽ giảm đi một và giá trị sẽ là -1. Ở đây cần né các exception gọi closeConnection() do nó sẽ không trigger được decrementActiveRemoteStreamCount().

Luồng chương trình lúc này sẽ đi qua vài lần method setConnectionTimeoutForStreamCount() nữa nhưng với activeRemoteStreamCount là số âm (khác 0) ==> luôn set timeout vô hạn ==> connection ở server không bị đóng và treo dù client đã send hết stream và drop connection.

PoC

Để trigger StreamExcetion qua isHeaderSizeExceeded(), headerSize cần nằm trong khoảng xy. Nếu headerSize quá y sẽ gây lỗi ConnectionException -> fail. Phần còn lại người đọc có thể review code để ra số chính xác.

Default maxConnections được cấu hình trong AbstractEndpoint8*1024 ~ 8192: AbstractEndpoint_maxConnections

Do đó có thể DoS tomcat khi gửi đủ 8192 connections trigger StreamExcetion, mọi requests sau 8192 đều sẽ bị connection refused cho đến khi tomcat được restart lại. Ngoài ra nếu server cấu hình memory có giới hạn cũng sẽ gây lỗi java.lang.OutOfMemoryError và cũng gây ra DoS.

poc_

Fix

Bản fix sẽ tăng activeRemoteStreamCount khi headersStart thay vì headersEnd: fix_increment

Thêm method Stream#decrementAndGetActiveRemoteStreamCount() để đảm bảo khi giảm activeRemoteStreamCount, mỗi stream chỉ giảm đúng 1 lần: new_decrementAndGetActiveRemoteStreamCount

Chi tiết commit: https://github.com/apache/tomcat/commit/2344a4c0d03e307ba6b8ab6dc8b894cc8bac63f2

Thoughts

CVE này mình tìm ra khi đang reproduce lại CVE-2024-24549, thanks for disclosure! Lúc mới làm tomcat mình khá mới với codebase cho nên khi PoC được bug này mình cũng chưa thể hiểu hết được logic xử lý, sau khi xem lại commit fix của tomcat kết hợp dynamic debug thì mình mới hiểu sâu hơn về lỗ hổng này.

Cảm ơn vì đã đọc đến đây :)