# REPRO-2026-00370: Erlang/OTP inets httpd parks request worker indefinitely on malformed chunk size sent after headers (unauthenticated remote DoS) ## Summary Status: published Severity: high CVSS: Unknown CWE: CWE-772 Type: security Confidence: high ## Identifiers REPRO ID: REPRO-2026-00370 CVE: CVE-2026-69664 ## Package Name: erlang/otp Ecosystem: github Affected: 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) Fixed: Unknown ## Root Cause # 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 ... &`). ## Reproduction Details Reproduced: 2026-09-24T17:06:48.227Z Duration: 19436 seconds Tool calls: 507 Turns: Unknown Handoffs: 2 ## Quick Verification Run one of these commands to verify locally: pruva-verify REPRO-2026-00370 pruva-verify CVE-2026-69664 Or open in GitHub Codespaces (zero-friction, auto-runs): https://github.com/codespaces/new?ref=repro/REPRO-2026-00370&repo=N3mes1s/pruva-sandbox Or download and run the script manually: curl -O https://api.pruva.dev/v1/reproductions/REPRO-2026-00370/artifacts/bundle/repro/reproduction_steps.sh chmod +x reproduction_steps.sh ./reproduction_steps.sh WARNING: Run in a sandboxed environment. This exploits a real vulnerability. ## References - NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-69664 - Source: https://github.com/erlang/otp/security/advisories ## Artifacts - bundle/repro/rca_report.md (analysis, 17342 bytes) - bundle/repro/reproduction_steps.sh (reproduction_script, 16007 bytes) - bundle/logs/reproduction_steps.log (log, 7460 bytes) - bundle/logs/reproduction_steps_run1.log (log, 7273 bytes) - bundle/repro/attack_client.py (script, 10438 bytes) - bundle/repro/evidence/fix_hunk.txt (other, 69 bytes) - bundle/repro/evidence/fixed_attempt1/census.log (log, 3803 bytes) - bundle/repro/evidence/fixed_attempt1/client_output.log (log, 667 bytes) - bundle/repro/evidence/fixed_attempt1/port.txt (other, 5 bytes) - bundle/repro/evidence/fixed_attempt1/result.json (other, 3071 bytes) - bundle/repro/evidence/fixed_attempt1/stdout.log (log, 83 bytes) - bundle/repro/evidence/fixed_attempt2/census.log (log, 1610 bytes) - bundle/repro/evidence/fixed_attempt2/client_output.log (log, 667 bytes) - bundle/repro/evidence/fixed_attempt2/port.txt (other, 5 bytes) - bundle/repro/evidence/fixed_attempt2/result.json (other, 3071 bytes) - bundle/repro/evidence/fixed_attempt2/stdout.log (log, 83 bytes) - bundle/repro/evidence/summary.json (other, 1852 bytes) - bundle/repro/evidence/vuln_bare_catch.txt (other, 60 bytes) - bundle/repro/evidence/vulnerable_attempt1/census.log (log, 211579 bytes) - bundle/repro/evidence/vulnerable_attempt1/client_output.log (log, 892 bytes) - bundle/repro/evidence/vulnerable_attempt1/port.txt (other, 5 bytes) - bundle/repro/evidence/vulnerable_attempt1/result.json (other, 18806 bytes) - bundle/repro/evidence/vulnerable_attempt1/stdout.log (log, 83 bytes) - bundle/repro/evidence/vulnerable_attempt2/census.log (log, 211579 bytes) - bundle/repro/evidence/vulnerable_attempt2/client_output.log (log, 892 bytes) - bundle/repro/evidence/vulnerable_attempt2/port.txt (other, 5 bytes) - bundle/repro/evidence/vulnerable_attempt2/result.json (other, 18806 bytes) - bundle/repro/evidence/vulnerable_attempt2/stdout.log (log, 83 bytes) - bundle/repro/runtime_manifest.json (other, 5222 bytes) - bundle/repro/server_node.erl (other, 3159 bytes) - bundle/repro/validation_verdict.json (other, 1520 bytes) ## API Access - JSON: https://api.pruva.dev/v1/reproductions/REPRO-2026-00370 - Script: https://api.pruva.dev/v1/reproductions/REPRO-2026-00370/artifacts/bundle/repro/reproduction_steps.sh - Web: https://www.pruva.dev/reproductions/REPRO-2026-00370 ## For AI Assistants To help users with this reproduction: 1. The `pruva-verify` command is the simplest way to run the reproduction 2. The script downloads and runs automatically in an isolated temp directory 3. Always recommend running in a VM, container, or disposable environment 4. The root cause explains the technical vulnerability --- Generated by Pruva | https://www.pruva.dev