# RCA Report — CVE-2026-69664: Erlang/OTP inets httpd request-worker parking (unauthenticated remote DoS)

## Summary

In Erlang/OTP's built-in `inets` HTTP server (`httpd`), a request that uses
`Transfer-Encoding: chunked` and whose chunk-size line arrives in a TCP write
**separate from the headers** is decoded through a continuation invoked via a
bare `catch` in `httpd_request_handler:handle_info/2`. When the chunk-size
line is not valid hexadecimal (e.g. `ZZZ\r\n`), `http_chunk:decode_size/4`
throws `{error, {chunk_size, Line}}`; the bare `catch` flattens this throw
into a plain term that matches none of the error clauses, so it falls into the
catch-all `NewMFA` clause: the error tuple is stored as the next decoder
continuation and the socket is re-armed with `{active, once}`. Because the
request timeout was already cancelled when the headers were accepted and
`minimum_bytes_per_second` is disabled by default, no timer ever reclaims the
worker — it is parked indefinitely while the attacker keeps the TCP connection
open with zero further bytes. Repeating this across connections exhausts the
request-worker pool (`max_clients`, documented default 150) and denies service
to legitimate clients (CWE-772, Missing Release of Resource after Effective
Lifetime).

## Impact

- **Package/component affected**: `lib/inets` `http_server/httpd_request_handler.erl`
  (interaction with `http_server/http_chunk.erl` `decode_size/4`)
- **Affected versions**: Erlang/OTP >= 18.1.4 < 27.3.4.17 | >= 28.0 < 28.5.0.6 |
  >= 29.0 < 29.0.6 (inets >= 6.0.3 < 9.3.2.7 | >= 9.4 < 9.6.2.3 | >= 9.7 < 9.7.2).
  Introduced by commit 77acb473d8f056f6f534395f131c6e45693797f0.
  Tested here: OTP-27 maintenance line, vulnerable commit
  `e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8` (parent of the fix; inets 9.3.2.6).
- **Risk level and consequences**: High (CVSS 8.7, AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H).
  Unauthenticated remote attacker, default configuration, availability-only
  impact: each parked connection permanently consumes one request-handler
  process and one socket until the attacker voluntarily disconnects; enough
  such connections make the server answer every new client with
  `503 Service Unavailable` ("heavy load").

## Impact Parity

- **Disclosed/claimed maximum impact**: Unauthenticated remote denial of service
  against the default inets httpd configuration (`dos`).
- **Reproduced impact from this run**: `dos` — full remote denial of service
  demonstrated end-to-end over the real TCP listener:
  - a single segmented request parks a `httpd_request_handler` process
    indefinitely (the same process pid observed alive with
    `{message_queue_len,0},{status,waiting}` and zero traffic for the full
    **165 s** observation window, beyond the 150 s default keep-alive timeout;
    no response is ever sent to the parked socket);
  - a 300-connection exhaustion wave of identical parked requests fills the
    worker pool at the documented `max_clients` default of 150 (confirmed
    against the server-side census before the probe is fired), after which
    fresh legitimate `GET` requests are denied with `503 Service Unavailable`
    ("heavy load"); the denial persists while the attacker holds the sockets
    (re-verified after a 25 s wait, census still pinned at 150) and disappears
    only after the attacker disconnects (census drops to 0, legit `200`);
  - the fixed build (fix commit `a3adf630...`) responds `400 Bad Request`
    immediately on the exact same segmented input, closes the connection,
    reclaims the worker (census 0), and the same 300-connection wave cannot
    deny service to legitimate clients (200 throughout).
- **Parity**: `full` (claimed DoS fully demonstrated; no code execution was
  claimed or observed).
- **Not demonstrated**: none — no impact beyond availability was claimed.

## Root Cause

`httpd_request_handler.erl`, `handle_info({Proto, Socket, Data}, ...)` clause
(vulnerable code, line 241 of the tested checkout):

```erlang
PROCESSED = (catch Module:Function([Data | Args])),
...
case PROCESSED of
    {ok, Result} -> ...;
    {error, {size_error, ...}, Version} -> ...;
    {error, {version_error, ...}, Version} -> ...;
    {error, {bad_request, ...}, Version} -> ...;
    {http_chunk = Module, Function, Args} when ChunkState =/= undefined -> ...;
    NewMFA ->
        setopts(Socket, SockType, [{active, once}]),
        {noreply, State#state{mfa = NewMFA}}   %% <-- parks here
end
```

Flow for a chunked request:

1. Headers arrive; `httpd_request:parse/2` returns
   `{ok, {continue, http_chunk, decode_size, Args}}`; `handle_msg/2` sets
   `mfa = {http_chunk, decode_size, Args}` **after** the request timeout was
   cancelled in the `{ok, ...}` branch (`cancel_request_timeout/1`). No new
   timer is armed; `minimum_bytes_per_second` is `false` by default, so
   `data_receive_counter/2` arms nothing either.
2. The chunk-size line arrives in a later TCP write, so
   `handle_info({tcp, Socket, Data}, ...)` runs with
   `Module = http_chunk, Function = decode_size`.
3. `http_chunk:decode_size(<<"ZZZ\r\n">>, ...)` **throws**
   `{error, {chunk_size, Line}}` for a non-hex chunk size.
4. The bare `catch` converts the throw into the plain term
   `{error, {chunk_size, Line}}`, which matches no clause of the `case`
   (the `{error, ...}` clauses expect 3-tuples with a `Version`), so the
   catch-all `NewMFA` clause stores the error tuple as the next continuation
   MFA and re-arms the socket with `{active, once}`.
5. The attacker sends no more bytes; no timeout exists; the worker waits
   forever (only `tcp_closed` would free it). Each such connection leaks one
   handler process + one socket until client disconnect.

Note the contrast that proves segmentation matters: when the body arrives
**together with** the headers, `handle_body/3` calls `http_chunk:decode/...`
inside a proper `try ... catch` and returns `400 Bad Request` — that path was
never vulnerable. The trigger requires the chunk-size line to arrive in a
later write, exactly as the advisory states.

- **Fix**: OTP maintenance commits
  `a3adf63078438c86527d704e23282b7721d8ca12` (OTP 27),
  `df1a9ca4666e2fdfc44886bfaae76de086d803f6` (OTP 28),
  `bd4e74348c6be8a49f060da6fd48d43f3a960292` (OTP 29).
  The fix wraps the decoder call as
  `catch throw:{error, Error} when Module =:= http_chunk ->` (line 287 in the
  fixed tree) so a thrown chunk-decode error is routed to the error handling
  that sends `400 Bad Request` and terminates the worker, plus a regression
  test in `httpd_SUITE` for the segmented-write case.

## Reproduction Steps

1. Script: `bundle/repro/reproduction_steps.sh` (self-contained; run twice
   consecutively — both runs must exit 0).
2. What the script does:
   - Resolves the prepared project cache (`bundle/project_cache_context.json`);
     with no cached repo present it clones/fetches OTP fix commit
     `a3adf63078438c86527d704e23282b7721d8ca12` (depth 2) from
     `https://github.com/erlang/otp.git` into `bundle/artifacts/otp`.
   - Builds the real Erlang/OTP from source at the vulnerable commit
     `e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8` (parent of the fix; the
     top-level `make` tolerates the wx-disabled `debugger` failure, then
     `make local_setup` + targeted `lib/stdlib`/`lib/inets` builds complete
     the system) and verifies the vulnerable checkout **lacks** the fix hunk
     (records the bare `catch` at line 241 in
     `evidence/vuln_bare_catch.txt`).
   - For each of two clean vulnerable attempts: starts a fresh `erl` node
     running the real `inets` httpd on an ephemeral port with default
     configuration (`keep_alive_timeout` 150 s and `minimum_bytes_per_second`
     false left at their defaults on purpose; `max_clients` set to its
     documented default of 150 — `httpd_conf` only persists the key when the
     user supplies it, and the manager's `handle_new_connection/4` looks it
     up **without** a default, so an unset key means no bound at all), plus a
     1 Hz server-side census of `httpd_request_handler` processes. Then
     `attack_client.py`:
     (a) healthchecks with `GET` (Connection: close),
     (b) opens a POST with `Transfer-Encoding: chunked`, sends headers in one
         TCP write, waits 0.3 s, sends `ZZZ\r\n` as a separate write, and holds
         the socket open with no further bytes for **165 s** (beyond the 150 s
         default keep-alive timeout),
     (c) sends a legit request mid-park (server still appears healthy),
     (d) opens a 300-connection wave of identical parked requests, **waits
         until the server-side census confirms the pool is actually full
         (>= 150 parked handlers)** before probing (this removes the
         accept-backlog race where a probe fired while the pool was still
         filling could be served 200), then shows fresh legitimate requests
         are denied (`503 Service Unavailable`, "heavy load"), still denied
         after a 25 s wait,
     (e) disconnects the attacker and shows the server recovers (`200`,
         census back to 0).
   - Checks out the fix commit, rebuilds `lib/inets` in the same tree,
     verifies the fix hunk is present (line 287 recorded in
     `evidence/fix_hunk.txt`), and runs two clean fixed attempts with the same
     attack: the bad chunk-size line must produce `400 Bad Request` + server
     close on the parked socket, census back to 0, and legit requests still
     served `200` during the full exhaustion wave.
   - Evaluates every attempt against the criteria above
     (`evidence/summary.json`, `pass` must be true), writes
     `runtime_manifest.json`, and exits 0 = confirmed / 1 = not reproduced.
3. Expected evidence of reproduction (all produced by the script):
   - `evidence/summary.json` with `pass: true` and every per-attempt check
     true for two vulnerable and two fixed attempts;
   - `evidence/vulnerable_attempt{1,2}/census.log`: `PATH vsn=9.3.2.6` with
     the inets beams loaded from the build tree, then `CENSUS` lines showing
     one parked handler for the whole 165 s window and 150 parked handlers
     during the wave;
   - `evidence/vulnerable_attempt{1,2}/result.json`:
     `parked_socket_status: "timeout"`, `parked_socket_recv: ""`,
     `legit_after_exhaustion` → `HTTP/1.1 503 Service Unavailable`,
     `exhausted_recheck` → still 503, `after_close_legit` → 200;
   - `evidence/fixed_attempt{1,2}/result.json`:
     `parked_socket_recv` starting with `HTTP/1.1 400 Bad Request`,
     `parked_socket_status: "closed"`, census 0, legit 200 during the wave.

## Evidence

- Environment: Linux x86_64, 4 cores; OTP built from source at
  `bundle/artifacts/otp` (fallback location — the prepared project cache
  contained no repo); vulnerable checkout `e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8`,
  fixed checkout `a3adf63078438c86527d704e23282b7721d8ca12`, both verified by
  hunk inspection before their respective attempts.
- Primary logs and artifacts (all under `bundle/`):
  - `bundle/logs/reproduction_steps.log` — full transcript of the final
    passing run (run 2); `bundle/logs/reproduction_steps_run1.log` — the
    first consecutive passing run. Both end with
    `RESULT: CVE-2026-69664 CONFIRMED on vulnerable build; fixed build fails closed`.
  - `bundle/repro/evidence/summary.json` — machine-readable pass/fail per
    attempt and per criterion (`pass: true`, 4/4 attempts pass).
  - `bundle/repro/evidence/vulnerable_attempt{1,2}/census.log` — server-side
    process census, 1 Hz, 196 samples per attempt (PATH line + CENSUS lines;
    final entry after attacker disconnect shows count 0).
  - `bundle/repro/evidence/vulnerable_attempt{1,2}/result.json`,
    `client_output.log` — client-side observations of each phase.
  - `bundle/repro/evidence/fixed_attempt{1,2}/...` — same for the fixed build.
  - `bundle/repro/evidence/vuln_bare_catch.txt` (`241: PROCESSED = (catch
    Module:Function([Data | Args])),`), `fix_hunk.txt` (`287: catch
    throw:{error, Error} when Module =:= http_chunk ->`) — source-line proof
    that the vulnerable tree has the bare `catch` and the fixed tree has the
    corrected clause.
  - `bundle/repro/runtime_manifest.json` — runtime evidence manifest with
    per-artifact SHA-256 (all verified) and target identity
    `git:https://github.com/erlang/otp@e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8`
    (digest `fac886b1b27ab179e45c1c7bf166b06ff9e1f3800dd1334f5f510e6a5cf67449`).
- Key excerpts (from the final run's result.json files):
  - Vulnerable, parked worker census (both attempts):
    `CENSUS ... 1 <0.NNN.0>:[{message_queue_len,0},{status,waiting}]` at
    t=3/45/95/162 s — the **same pid** for the entire 165 s window;
    `parked_socket_status: "timeout"`, `parked_socket_recv: ""`.
  - Vulnerable, exhaustion:
    `parked=300/300; pool_full=True (census=150 ...)`; 
    `legit_after_exhaustion` → `HTTP/1.1 503 Service Unavailable`
    (heavy load); `exhausted_recheck` after 25 s → still
    `HTTP/1.1 503 Service Unavailable`, census 150;
    `after_close_legit` → `HTTP/1.1 200 OK`, census 0.
  - Fixed: `parked_socket_recv` = `HTTP/1.1 400 Bad Request` ...,
    `parked_socket_status: "closed"`, census count 0, and
    `legit_after_exhaustion` → `HTTP/1.1 200 OK` during the same wave.
- Note on version labels: the fixed checkout still reports
  `vsn=9.3.2.6` in its PATH line because the maintenance fix commit does not
  bump the OTP/inets version file; the authoritative discriminators between
  the two builds are the recorded fix-hunk lines (241 bare `catch` vs 287
  `catch throw:{error, Error}`) and the behavioral divergence
  (indefinite park + 503 exhaustion vs immediate 400 + close + no denial).

## Recommendations / Next Steps

- **Upgrade guidance**: upgrade to OTP 27.3.4.17 / 28.5.0.6 / 29.0.6
  (inets 9.3.2.7 / 9.6.2.3 / 9.7.2) or later, which contain the
  `catch throw:{error, Error}` fix.
- **Mitigations for interim**: enable `minimum_bytes_per_second` in the httpd
  config so slow/stalled body receives are reaped; reduce exposure of inets
  httpd to untrusted networks. Be aware that leaving `max_clients` **unset**
  does not fall back to the documented default of 150 — `httpd_conf` only
  stores the value when the user supplies it, so an unset value effectively
  disables the connection cap (an even easier unbounded DoS); explicitly set
  it, ideally below 150.
- **Suggested fix approach**: the merged fix wraps the decoder continuation
  call so chunk-decode throws are converted into a proper `400 Bad Request`
  response and handler termination; keep that structure for every
  continuation invoked from `handle_info`.
- **Testing recommendations**: the fix commit adds an `httpd_SUITE` case
  (invalid chunk size in a separate write). Keep regression coverage for the
  segmented-write case specifically — the single-write case was never
  vulnerable (`handle_body/3` already had a proper try/catch), so a test that
  coalesces headers and body will not catch this class of bug. Consider also
  auditing other `handle_info` continuation consumers for bare `catch`
  patterns, asserting that a request timeout is always armed while a
  partially-received request is outstanding, and giving
  `handle_new_connection/4`'s `max_clients` lookup the documented default.

## Additional Notes

- **Idempotency**: `reproduction_steps.sh` is safe to re-run; it reuses the
  in-tree build under `bundle/artifacts/otp`, re-checks out each commit
  (`git checkout -f`), and incrementally rebuilds only the changed `inets`
  modules. Two consecutive full runs were executed in this session and both
  passed (exit 0, `summary.json` `pass: true`); transcripts:
  `bundle/logs/reproduction_steps_run1.log` and
  `bundle/logs/reproduction_steps_run2.log`.
- **Negative/scope controls included in the proof**:
  - fixed-commit build fails closed on the exact same segmented input (400 +
    close, worker reclaimed, no denial of service) — confirms the fix, not an
    environmental artifact;
  - the single-write variant (headers + `ZZZ\r\n` in one TCP write) returns
    400 even on the vulnerable build, matching the advisory's note that the
    `handle_body/3` path was never vulnerable (verified during development).
- **Deterministic exhaustion probe**: the attack client polls the
  server-side census until the pool is observed full (>= 150 handlers) before
  firing the post-exhaustion legitimate request; an earlier revision with a
  fixed 2-second sleep could, under scheduling pressure, fire the probe while
  the accept backlog was still draining and observe a misleading 200.
- **Limitations**: The DoS demonstration binds `max_clients` at its documented
  default (150); deployments that raised it need proportionally more parked
  connections, and deployments that left it unset are unbounded (see
  Recommendations). The census samples at 1 Hz from inside the Erlang node,
  so a handler that lived for only a fraction of a second could in principle
  be missed — not an issue here since the parked handler is verified alive
  across the whole 165 s observation window with the same pid. Background
  orchestration note: the full script takes ~10 minutes; execution
  environments with per-command time limits must run it detached
  (e.g. `setsid nohup ... &`).
