---
id: CVE-2026-63202
aliases:
  - GHSA-4899-mpch-38p3
title: >-
  netty-incubator-codec-ohttp BinaryHttpParser: Unauthenticated CPU-exhaustion
  DoS via infinite loop in field-section decoding
summary: >-
  netty-incubator-codec-ohttp BinaryHttpParser: Unauthenticated CPU-exhaustion
  DoS via infinite loop in field-section decoding
severity: high
cvss: 7.5
cwe:
  - CWE-400
  - CWE-835
vendor: netty
product: 'io.netty.incubator:netty-incubator-codec-bhttp'
ecosystem: maven
affected:
  - 'io.netty.incubator:netty-incubator-codec-bhttp <= 0.0.22.Final'
patched:
  - 'io.netty.incubator:netty-incubator-codec-bhttp 0.0.23.Final'
published: '2026-08-20'
updated: '2026-08-20'
source: GHSA
sourceUrl: 'https://github.com/advisories/GHSA-4899-mpch-38p3'
references:
  - url: >-
      https://github.com/netty/netty-incubator-codec-ohttp/security/advisories/GHSA-4899-mpch-38p3
  - url: >-
      https://github.com/netty/netty-incubator-codec-ohttp/releases/tag/netty-incubator-codec-parent-ohttp-0.0.23.Final
  - url: 'https://github.com/advisories/GHSA-4899-mpch-38p3'
tags:
  - ghsa
  - maven
ingestedAt: '2026-08-20T18:59:53.712Z'
---

## Overview

# 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`:

```java
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:

1. **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.

2. **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.

1. `OHttpServerCodec.decode` → `OHttpRequestResponseContext.parse` → chunk decode → `ContentDecoder.decodeChunk`.
2. `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.
3. `parse` → `READ_KNOWN_LENGTH_REQUEST_HEAD` → `readRequestHead` (line 190).
4. `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)`.
5. 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

1. Change the loop exit condition to `while (fieldSectionLength > 0)` so an overshoot (negative counter) terminates.
2. 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.
3. 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).
4. 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:
```java
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.

## Affected packages

- `io.netty.incubator:netty-incubator-codec-bhttp <= 0.0.22.Final`

## Remediation

Upgrade to a patched release:

- `io.netty.incubator:netty-incubator-codec-bhttp 0.0.23.Final`
