BinaryHttpParser: Unauthenticated CPU-exhaustion DoS via infinite loop in field-section decoding
- ID: BHTTP-LOOP-001
- Severity: High
- CVSS v3.1: 7.5 —
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
- CWE: CWE-835 (Loop with Unreachable Exit Condition) — secondary CWE-400 (Uncontrolled Resource Consumption)
- Affected component:
codec-bhttp → io.netty.incubator.codec.bhttp.BinaryHttpParser#readFieldSection, file codec-bhttp/src/main/java/io/netty/incubator/codec/bhttp/BinaryHttpParser.java:619-626
- Affected version: netty-incubator-codec-ohttp HEAD
d3f2b49 (release 0.0.22.Final + 3 commits). The loop has existed since the parser was introduced and is present in the latest code; all published advisory fixes are already applied.
- Reachable from:
io.netty.incubator.codec.ohttp.OHttpRequestResponseContext$ContentDecoder#decodeChunk (codec-ohttp/.../OHttpRequestResponseContext.java:214), i.e. the auto-wired OHTTP server and client codecs.
- Confidence: High (empirically reproduced hang + thread dump against the unmodified parser).
Summary
BinaryHttpParser decodes Binary HTTP (RFC 9292) messages. An OHTTP gateway/client built on this library feeds the decrypted OHTTP body straight into BinaryHttpParser.parse(...). The field-section decoding loop terminates only on the exact condition fieldSectionLength != 0 and relies on a Java assert to guarantee forward progress. Because (a) the loop counter can be driven negative and (b) readFieldLine(...) legitimately consumes zero bytes and returns null on a truncated/over-long field line, the loop can spin forever. Assertions are disabled in any normal production JVM, so the two assert statements meant to catch this provide no protection.
A single ~17-byte Binary HTTP message — encapsulated by an unauthenticated attacker inside a normal OHTTP request, using the gateway's public key configuration — pins one Netty event-loop thread at 100% CPU permanently. A handful of such requests exhausts the entire event-loop group and takes the OHTTP gateway (or client) fully offline.
Root cause
BinaryHttpParser.java:619-626:
HeaderType lastType = HeaderType.PSEUDO_HEADER;
while (fieldSectionLength != 0) { // 619 — "!= 0", not "> 0"
int readableBytes = in.readableBytes();
lastType = readFieldLine(in, headers, lastType, trailers);
assert lastType != null; // 622 — no-op without -ea
int read = readableBytes - in.readableBytes();
assert read > 0; // 624 — no-op without -ea
fieldSectionLength -= read; // 625
}
Two cooperating defects:
-
Counter can never hit zero. fieldSectionLength is the declared field-section byte length read from the wire (line 592). The loop subtracts the bytes each readFieldLine actually consumes. If a field line consumes more bytes than the (attacker-understated) declared length, fieldSectionLength goes negative and != 0 stays true forever.
-
Zero-progress iterations. readFieldLine (lines 654-707) returns null without consuming any bytes when the remaining buffer cannot hold a complete field line — at lines 656, 664, 670, and 681 (the in.skipBytes(sumBytes) that advances the reader is only reached on the success path, line 705). When it returns null, read == 0, fieldSectionLength is unchanged, and the loop re-enters with identical state — a tight busy spin.
The only constructs that would have stopped either case are the assert statements on lines 622 and 624, which the JVM strips unless started with -ea. Production deployments do not run with assertions enabled.
Reachability (hop-by-hop, every guard resolved)
Attacker model: OHTTP gateways publish their HPKE key configuration so that any client can encrypt requests to them. The attacker therefore encrypts a malicious BHTTP body under the gateway's public key — a perfectly valid OHTTP request. HPKE decapsulation succeeds; the plaintext is attacker-chosen.
OHttpServerCodec.decode → OHttpRequestResponseContext.parse → chunk decode → ContentDecoder.decodeChunk.
OHttpRequestResponseContext.java:211 decrypts the chunk into decryptedChunk; line 212 cumulates it; line 214 calls binaryHttpParser.parse(binaryHttpCumulation, completeBodyReceived) — attacker-controlled plaintext, no application code in between.
parse → READ_KNOWN_LENGTH_REQUEST_HEAD → readRequestHead (line 190).
readRequestHead reads the control data, then at lines 445-451 slices all remaining readable bytes as the field section and calls readFieldSection(..., knownLength=true, maxFieldSectionSize).
- Inside
readFieldSection:
- Guard
checkFieldSectionTooLarge(fieldSectionLength, max) (line 607): bounds only the declared length, which the PoC sets to 1. Passes — not a barrier.
- Guard
in.readableBytes() < sumBytes (line 609): sumBytes is built from the declared length, also tiny. Passes — not a barrier.
- Guards
assert (lines 622, 624): no-ops in production. Defeated by default.
- Loop entered → spins forever (defects 1 + 2).
No reachable guard bounds the actual consumption or forces progress. maxFieldSectionSize is irrelevant because the declared length is small and the loop is CPU-bound on a fixed, small buffer (no allocation, no memory growth to trip any size cap).
Proof of concept (executed locally, benign liveness oracle)
The real codec-bhttp sources were compiled unmodified against netty 4.1.135.Final (the version pinned in pom.xml). The harness builds a valid known-length BHTTP request whose declared field-section length (0x01) is understated relative to the actual field line, then calls parse(in, true) on a worker thread with a 6-second watchdog. No payload, no side effects — purely a timing/CPU oracle.
Malicious message (17 bytes):
00 01 67 01 68 01 61 01 70 01 01 61 01 62 01 63 01
│ └method g └scheme h └auth a └path p │ └hdr a:b──┘ └ partial line
└ framing 0 (known-length request) └ declared field-section length = 1
Observed (production default, assertions OFF):
[*] malicious BHTTP bytes (17): 0001670168016101700101610162016301
[!!] HANG CONFIRMED: parse() still running after 6000 ms
[!!] worker thread CPU time: 6029 ms (≈100% of one core => busy spin)
[!!] worker stack (top frames):
at io.netty.incubator.codec.bhttp.BinaryHttpParser.readFieldSection(BinaryHttpParser.java:626)
at io.netty.incubator.codec.bhttp.BinaryHttpParser.readRequestHead(BinaryHttpParser.java:451)
at io.netty.incubator.codec.bhttp.BinaryHttpParser.parse(BinaryHttpParser.java:190)
CPU time ≈ wall time ⇒ a busy spin (RUNNABLE), not a blocked wait.
Controls:
- Same input with
-ea: parse() throws AssertionError at readFieldSection:624 immediately — proving the assertion is the only would-be guard and is absent in production.
- Well-formed request (declared length matches):
parse() returns DefaultBinaryHttpRequest promptly — proving the harness does not hang on valid input.
PoC sources: findings/netty-incubator-codec-ohttp/raw/Poc.java (hang + control 1) and raw/Poc2.java (negative control).
Impact
Unauthenticated, pre-business-logic remote denial of service. Each malicious request permanently consumes one Netty event-loop thread at 100% CPU. Netty event-loop groups have a small fixed thread count (default 2 × cores); a handful of requests exhausts every I/O thread, after which the gateway/client accepts no further connections and serves no traffic — a complete, persistent DoS that survives until process restart. Availability impact High; no confidentiality/integrity impact.
Adversarial re-reading (attempts to refute)
- "
maxFieldSectionSize caps it." No — the declared length in the PoC is 1; the cap (line 607) checks the declared value only. The spin happens on a 17-byte buffer with no allocation. Refutation fails.
- "An upstream HTTP size limit /
HttpObjectAggregator blocks it." No — the bug is CPU-bound, not memory-bound. The whole malicious message is tiny and well within any size limit. Refutation fails.
- "This is just CVE-2024-40642 (absent input validation)." No — that advisory was about missing validation of method/scheme/authority/path enabling injection; that fix (the
ALLOWED_TOKEN/ALLOWED_SCHEME validators, lines 76-122/461-466) is present and unrelated. This is a control-flow/termination defect in field-section length accounting. Distinct class, distinct code.
- "The hang might be a harness artifact." No — the thread dump pinpoints
readFieldSection:626; CPU≈wall confirms a spin; the -ea control throws at the exact assert; the well-formed control returns. The hang is for the claimed reason.
- "
completeBodyReceived must be true." Not required — the loop is inside readFieldSection, reached via readRequestHead once the control data is present, independent of that flag. The flag only affects a branch taken after readRequestHead returns null, which never happens here.
No concrete blocker survived. Verdict: CONFIRMED.
Remediation
- Change the loop exit condition to
while (fieldSectionLength > 0) so an overshoot (negative counter) terminates.
- Treat a
null / zero-progress return from readFieldLine while fieldSectionLength > 0 as a hard framing error — throw CorruptedFrameException("truncated or over-long field line") instead of re-looping.
- Reject any field line whose consumed byte count would drive
fieldSectionLength below 0 (the declared length must be consumed exactly, per RFC 9292 §3.6).
- Do not rely on
assert for wire-format invariants on attacker-controlled input; assertions are disabled in production. Promote lines 622/624 to explicit exceptions.
Example:
while (fieldSectionLength > 0) {
int readableBytes = in.readableBytes();
lastType = readFieldLine(in, headers, lastType, trailers);
int read = readableBytes - in.readableBytes();
if (lastType == null || read <= 0) {
throw new CorruptedFrameException("truncated or over-long field line");
}
if (read > fieldSectionLength) {
throw new CorruptedFrameException("field line exceeds declared field-section length");
}
fieldSectionLength -= read;
}
Notes
- The indeterminate-length field-section path (framing indicators 2/3) shares the same loop and the same
!= 0 / zero-progress structure; the fix above should cover both. A dedicated trace of getIndeterminateLength (lines 538-566) under non-default maxFieldSectionSize is recorded separately as a lead.
- Default
maxFieldSectionSize for the OHTTP codecs is 8 * 1024 (OHttpCodecBuilder.DEFAULT_MAX_FIELD_SECTION_SIZE), and is irrelevant to this CPU-bound spin.
BinaryHttpParser: Unauthenticated CPU-exhaustion DoS via infinite loop in field-section decoding
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:Hcodec-bhttp→io.netty.incubator.codec.bhttp.BinaryHttpParser#readFieldSection, filecodec-bhttp/src/main/java/io/netty/incubator/codec/bhttp/BinaryHttpParser.java:619-626d3f2b49(release0.0.22.Final+ 3 commits). The loop has existed since the parser was introduced and is present in the latest code; all published advisory fixes are already applied.io.netty.incubator.codec.ohttp.OHttpRequestResponseContext$ContentDecoder#decodeChunk(codec-ohttp/.../OHttpRequestResponseContext.java:214), i.e. the auto-wired OHTTP server and client codecs.Summary
BinaryHttpParserdecodes Binary HTTP (RFC 9292) messages. An OHTTP gateway/client built on this library feeds the decrypted OHTTP body straight intoBinaryHttpParser.parse(...). The field-section decoding loop terminates only on the exact conditionfieldSectionLength != 0and relies on a Javaassertto guarantee forward progress. Because (a) the loop counter can be driven negative and (b)readFieldLine(...)legitimately consumes zero bytes and returnsnullon a truncated/over-long field line, the loop can spin forever. Assertions are disabled in any normal production JVM, so the twoassertstatements meant to catch this provide no protection.A single ~17-byte Binary HTTP message — encapsulated by an unauthenticated attacker inside a normal OHTTP request, using the gateway's public key configuration — pins one Netty event-loop thread at 100% CPU permanently. A handful of such requests exhausts the entire event-loop group and takes the OHTTP gateway (or client) fully offline.
Root cause
BinaryHttpParser.java:619-626:Two cooperating defects:
Counter can never hit zero.
fieldSectionLengthis the declared field-section byte length read from the wire (line 592). The loop subtracts the bytes eachreadFieldLineactually consumes. If a field line consumes more bytes than the (attacker-understated) declared length,fieldSectionLengthgoes negative and!= 0stays true forever.Zero-progress iterations.
readFieldLine(lines 654-707) returnsnullwithout consuming any bytes when the remaining buffer cannot hold a complete field line — at lines 656, 664, 670, and 681 (thein.skipBytes(sumBytes)that advances the reader is only reached on the success path, line 705). When it returnsnull,read == 0,fieldSectionLengthis unchanged, and the loop re-enters with identical state — a tight busy spin.The only constructs that would have stopped either case are the
assertstatements on lines 622 and 624, which the JVM strips unless started with-ea. Production deployments do not run with assertions enabled.Reachability (hop-by-hop, every guard resolved)
Attacker model: OHTTP gateways publish their HPKE key configuration so that any client can encrypt requests to them. The attacker therefore encrypts a malicious BHTTP body under the gateway's public key — a perfectly valid OHTTP request. HPKE decapsulation succeeds; the plaintext is attacker-chosen.
OHttpServerCodec.decode→OHttpRequestResponseContext.parse→ chunk decode →ContentDecoder.decodeChunk.OHttpRequestResponseContext.java:211decrypts the chunk intodecryptedChunk; line 212 cumulates it; line 214 callsbinaryHttpParser.parse(binaryHttpCumulation, completeBodyReceived)— attacker-controlled plaintext, no application code in between.parse→READ_KNOWN_LENGTH_REQUEST_HEAD→readRequestHead(line 190).readRequestHeadreads the control data, then at lines 445-451 slices all remaining readable bytes as the field section and callsreadFieldSection(..., knownLength=true, maxFieldSectionSize).readFieldSection:checkFieldSectionTooLarge(fieldSectionLength, max)(line 607): bounds only the declared length, which the PoC sets to1. Passes — not a barrier.in.readableBytes() < sumBytes(line 609):sumBytesis built from the declared length, also tiny. Passes — not a barrier.assert(lines 622, 624): no-ops in production. Defeated by default.No reachable guard bounds the actual consumption or forces progress.
maxFieldSectionSizeis irrelevant because the declared length is small and the loop is CPU-bound on a fixed, small buffer (no allocation, no memory growth to trip any size cap).Proof of concept (executed locally, benign liveness oracle)
The real
codec-bhttpsources were compiled unmodified against netty4.1.135.Final(the version pinned inpom.xml). The harness builds a valid known-length BHTTP request whose declared field-section length (0x01) is understated relative to the actual field line, then callsparse(in, true)on a worker thread with a 6-second watchdog. No payload, no side effects — purely a timing/CPU oracle.Malicious message (17 bytes):
Observed (production default, assertions OFF):
CPU time ≈ wall time ⇒ a busy spin (RUNNABLE), not a blocked wait.
Controls:
-ea:parse()throwsAssertionErroratreadFieldSection:624immediately — proving the assertion is the only would-be guard and is absent in production.parse()returnsDefaultBinaryHttpRequestpromptly — proving the harness does not hang on valid input.PoC sources:
findings/netty-incubator-codec-ohttp/raw/Poc.java(hang + control 1) andraw/Poc2.java(negative control).Impact
Unauthenticated, pre-business-logic remote denial of service. Each malicious request permanently consumes one Netty event-loop thread at 100% CPU. Netty event-loop groups have a small fixed thread count (default
2 × cores); a handful of requests exhausts every I/O thread, after which the gateway/client accepts no further connections and serves no traffic — a complete, persistent DoS that survives until process restart. Availability impact High; no confidentiality/integrity impact.Adversarial re-reading (attempts to refute)
maxFieldSectionSizecaps it." No — the declared length in the PoC is1; the cap (line 607) checks the declared value only. The spin happens on a 17-byte buffer with no allocation. Refutation fails.HttpObjectAggregatorblocks it." No — the bug is CPU-bound, not memory-bound. The whole malicious message is tiny and well within any size limit. Refutation fails.ALLOWED_TOKEN/ALLOWED_SCHEMEvalidators, lines 76-122/461-466) is present and unrelated. This is a control-flow/termination defect in field-section length accounting. Distinct class, distinct code.readFieldSection:626; CPU≈wall confirms a spin; the-eacontrol throws at the exact assert; the well-formed control returns. The hang is for the claimed reason.completeBodyReceivedmust be true." Not required — the loop is insidereadFieldSection, reached viareadRequestHeadonce the control data is present, independent of that flag. The flag only affects a branch taken afterreadRequestHeadreturnsnull, which never happens here.No concrete blocker survived. Verdict: CONFIRMED.
Remediation
while (fieldSectionLength > 0)so an overshoot (negative counter) terminates.null/ zero-progress return fromreadFieldLinewhilefieldSectionLength > 0as a hard framing error — throwCorruptedFrameException("truncated or over-long field line")instead of re-looping.fieldSectionLengthbelow 0 (the declared length must be consumed exactly, per RFC 9292 §3.6).assertfor wire-format invariants on attacker-controlled input; assertions are disabled in production. Promote lines 622/624 to explicit exceptions.Example:
Notes
!= 0/ zero-progress structure; the fix above should cover both. A dedicated trace ofgetIndeterminateLength(lines 538-566) under non-defaultmaxFieldSectionSizeis recorded separately as a lead.maxFieldSectionSizefor the OHTTP codecs is8 * 1024(OHttpCodecBuilder.DEFAULT_MAX_FIELD_SECTION_SIZE), and is irrelevant to this CPU-bound spin.