{"repro_id":"REPRO-2026-00370","version":6,"title":"Erlang/OTP inets httpd parks request worker indefinitely on malformed chunk size sent after headers (unauthenticated remote DoS)","repro_type":"security","status":"published","severity":"high","description":"CWE-772 Missing Release of Resource after Effective Lifetime in Erlang/OTP inets httpd (CVE-2026-69664, GHSA-mr35-8h7w-w3gq). Unauthenticated remote DoS in default config: malformed chunk-size line in a chunked request parks a request-handler worker process forever.","root_cause":"# RCA Report — CVE-2026-69664: Erlang/OTP inets httpd request-worker parking (unauthenticated remote DoS)\n\n## Summary\n\nIn Erlang/OTP's built-in `inets` HTTP server (`httpd`), a request that uses\n`Transfer-Encoding: chunked` and whose chunk-size line arrives in a TCP write\n**separate from the headers** is decoded through a continuation invoked via a\nbare `catch` in `httpd_request_handler:handle_info/2`. When the chunk-size\nline is not valid hexadecimal (e.g. `ZZZ\\r\\n`), `http_chunk:decode_size/4`\nthrows `{error, {chunk_size, Line}}`; the bare `catch` flattens this throw\ninto a plain term that matches none of the error clauses, so it falls into the\ncatch-all `NewMFA` clause: the error tuple is stored as the next decoder\ncontinuation and the socket is re-armed with `{active, once}`. Because the\nrequest timeout was already cancelled when the headers were accepted and\n`minimum_bytes_per_second` is disabled by default, no timer ever reclaims the\nworker — it is parked indefinitely while the attacker keeps the TCP connection\nopen with zero further bytes. Repeating this across connections exhausts the\nrequest-worker pool (`max_clients`, documented default 150) and denies service\nto legitimate clients (CWE-772, Missing Release of Resource after Effective\nLifetime).\n\n## Impact\n\n- **Package/component affected**: `lib/inets` `http_server/httpd_request_handler.erl`\n  (interaction with `http_server/http_chunk.erl` `decode_size/4`)\n- **Affected versions**: Erlang/OTP >= 18.1.4 < 27.3.4.17 | >= 28.0 < 28.5.0.6 |\n  >= 29.0 < 29.0.6 (inets >= 6.0.3 < 9.3.2.7 | >= 9.4 < 9.6.2.3 | >= 9.7 < 9.7.2).\n  Introduced by commit 77acb473d8f056f6f534395f131c6e45693797f0.\n  Tested here: OTP-27 maintenance line, vulnerable commit\n  `e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8` (parent of the fix; inets 9.3.2.6).\n- **Risk level and consequences**: High (CVSS 8.7, AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H).\n  Unauthenticated remote attacker, default configuration, availability-only\n  impact: each parked connection permanently consumes one request-handler\n  process and one socket until the attacker voluntarily disconnects; enough\n  such connections make the server answer every new client with\n  `503 Service Unavailable` (\"heavy load\").\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact**: Unauthenticated remote denial of service\n  against the default inets httpd configuration (`dos`).\n- **Reproduced impact from this run**: `dos` — full remote denial of service\n  demonstrated end-to-end over the real TCP listener:\n  - a single segmented request parks a `httpd_request_handler` process\n    indefinitely (the same process pid observed alive with\n    `{message_queue_len,0},{status,waiting}` and zero traffic for the full\n    **165 s** observation window, beyond the 150 s default keep-alive timeout;\n    no response is ever sent to the parked socket);\n  - a 300-connection exhaustion wave of identical parked requests fills the\n    worker pool at the documented `max_clients` default of 150 (confirmed\n    against the server-side census before the probe is fired), after which\n    fresh legitimate `GET` requests are denied with `503 Service Unavailable`\n    (\"heavy load\"); the denial persists while the attacker holds the sockets\n    (re-verified after a 25 s wait, census still pinned at 150) and disappears\n    only after the attacker disconnects (census drops to 0, legit `200`);\n  - the fixed build (fix commit `a3adf630...`) responds `400 Bad Request`\n    immediately on the exact same segmented input, closes the connection,\n    reclaims the worker (census 0), and the same 300-connection wave cannot\n    deny service to legitimate clients (200 throughout).\n- **Parity**: `full` (claimed DoS fully demonstrated; no code execution was\n  claimed or observed).\n- **Not demonstrated**: none — no impact beyond availability was claimed.\n\n## Root Cause\n\n`httpd_request_handler.erl`, `handle_info({Proto, Socket, Data}, ...)` clause\n(vulnerable code, line 241 of the tested checkout):\n\n```erlang\nPROCESSED = (catch Module:Function([Data | Args])),\n...\ncase PROCESSED of\n    {ok, Result} -> ...;\n    {error, {size_error, ...}, Version} -> ...;\n    {error, {version_error, ...}, Version} -> ...;\n    {error, {bad_request, ...}, Version} -> ...;\n    {http_chunk = Module, Function, Args} when ChunkState =/= undefined -> ...;\n    NewMFA ->\n        setopts(Socket, SockType, [{active, once}]),\n        {noreply, State#state{mfa = NewMFA}}   %% <-- parks here\nend\n```\n\nFlow for a chunked request:\n\n1. Headers arrive; `httpd_request:parse/2` returns\n   `{ok, {continue, http_chunk, decode_size, Args}}`; `handle_msg/2` sets\n   `mfa = {http_chunk, decode_size, Args}` **after** the request timeout was\n   cancelled in the `{ok, ...}` branch (`cancel_request_timeout/1`). No new\n   timer is armed; `minimum_bytes_per_second` is `false` by default, so\n   `data_receive_counter/2` arms nothing either.\n2. The chunk-size line arrives in a later TCP write, so\n   `handle_info({tcp, Socket, Data}, ...)` runs with\n   `Module = http_chunk, Function = decode_size`.\n3. `http_chunk:decode_size(<<\"ZZZ\\r\\n\">>, ...)` **throws**\n   `{error, {chunk_size, Line}}` for a non-hex chunk size.\n4. The bare `catch` converts the throw into the plain term\n   `{error, {chunk_size, Line}}`, which matches no clause of the `case`\n   (the `{error, ...}` clauses expect 3-tuples with a `Version`), so the\n   catch-all `NewMFA` clause stores the error tuple as the next continuation\n   MFA and re-arms the socket with `{active, once}`.\n5. The attacker sends no more bytes; no timeout exists; the worker waits\n   forever (only `tcp_closed` would free it). Each such connection leaks one\n   handler process + one socket until client disconnect.\n\nNote the contrast that proves segmentation matters: when the body arrives\n**together with** the headers, `handle_body/3` calls `http_chunk:decode/...`\ninside a proper `try ... catch` and returns `400 Bad Request` — that path was\nnever vulnerable. The trigger requires the chunk-size line to arrive in a\nlater write, exactly as the advisory states.\n\n- **Fix**: OTP maintenance commits\n  `a3adf63078438c86527d704e23282b7721d8ca12` (OTP 27),\n  `df1a9ca4666e2fdfc44886bfaae76de086d803f6` (OTP 28),\n  `bd4e74348c6be8a49f060da6fd48d43f3a960292` (OTP 29).\n  The fix wraps the decoder call as\n  `catch throw:{error, Error} when Module =:= http_chunk ->` (line 287 in the\n  fixed tree) so a thrown chunk-decode error is routed to the error handling\n  that sends `400 Bad Request` and terminates the worker, plus a regression\n  test in `httpd_SUITE` for the segmented-write case.\n\n## Reproduction Steps\n\n1. Script: `bundle/repro/reproduction_steps.sh` (self-contained; run twice\n   consecutively — both runs must exit 0).\n2. What the script does:\n   - Resolves the prepared project cache (`bundle/project_cache_context.json`);\n     with no cached repo present it clones/fetches OTP fix commit\n     `a3adf63078438c86527d704e23282b7721d8ca12` (depth 2) from\n     `https://github.com/erlang/otp.git` into `bundle/artifacts/otp`.\n   - Builds the real Erlang/OTP from source at the vulnerable commit\n     `e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8` (parent of the fix; the\n     top-level `make` tolerates the wx-disabled `debugger` failure, then\n     `make local_setup` + targeted `lib/stdlib`/`lib/inets` builds complete\n     the system) and verifies the vulnerable checkout **lacks** the fix hunk\n     (records the bare `catch` at line 241 in\n     `evidence/vuln_bare_catch.txt`).\n   - For each of two clean vulnerable attempts: starts a fresh `erl` node\n     running the real `inets` httpd on an ephemeral port with default\n     configuration (`keep_alive_timeout` 150 s and `minimum_bytes_per_second`\n     false left at their defaults on purpose; `max_clients` set to its\n     documented default of 150 — `httpd_conf` only persists the key when the\n     user supplies it, and the manager's `handle_new_connection/4` looks it\n     up **without** a default, so an unset key means no bound at all), plus a\n     1 Hz server-side census of `httpd_request_handler` processes. Then\n     `attack_client.py`:\n     (a) healthchecks with `GET` (Connection: close),\n     (b) opens a POST with `Transfer-Encoding: chunked`, sends headers in one\n         TCP write, waits 0.3 s, sends `ZZZ\\r\\n` as a separate write, and holds\n         the socket open with no further bytes for **165 s** (beyond the 150 s\n         default keep-alive timeout),\n     (c) sends a legit request mid-park (server still appears healthy),\n     (d) opens a 300-connection wave of identical parked requests, **waits\n         until the server-side census confirms the pool is actually full\n         (>= 150 parked handlers)** before probing (this removes the\n         accept-backlog race where a probe fired while the pool was still\n         filling could be served 200), then shows fresh legitimate requests\n         are denied (`503 Service Unavailable`, \"heavy load\"), still denied\n         after a 25 s wait,\n     (e) disconnects the attacker and shows the server recovers (`200`,\n         census back to 0).\n   - Checks out the fix commit, rebuilds `lib/inets` in the same tree,\n     verifies the fix hunk is present (line 287 recorded in\n     `evidence/fix_hunk.txt`), and runs two clean fixed attempts with the same\n     attack: the bad chunk-size line must produce `400 Bad Request` + server\n     close on the parked socket, census back to 0, and legit requests still\n     served `200` during the full exhaustion wave.\n   - Evaluates every attempt against the criteria above\n     (`evidence/summary.json`, `pass` must be true), writes\n     `runtime_manifest.json`, and exits 0 = confirmed / 1 = not reproduced.\n3. Expected evidence of reproduction (all produced by the script):\n   - `evidence/summary.json` with `pass: true` and every per-attempt check\n     true for two vulnerable and two fixed attempts;\n   - `evidence/vulnerable_attempt{1,2}/census.log`: `PATH vsn=9.3.2.6` with\n     the inets beams loaded from the build tree, then `CENSUS` lines showing\n     one parked handler for the whole 165 s window and 150 parked handlers\n     during the wave;\n   - `evidence/vulnerable_attempt{1,2}/result.json`:\n     `parked_socket_status: \"timeout\"`, `parked_socket_recv: \"\"`,\n     `legit_after_exhaustion` → `HTTP/1.1 503 Service Unavailable`,\n     `exhausted_recheck` → still 503, `after_close_legit` → 200;\n   - `evidence/fixed_attempt{1,2}/result.json`:\n     `parked_socket_recv` starting with `HTTP/1.1 400 Bad Request`,\n     `parked_socket_status: \"closed\"`, census 0, legit 200 during the wave.\n\n## Evidence\n\n- Environment: Linux x86_64, 4 cores; OTP built from source at\n  `bundle/artifacts/otp` (fallback location — the prepared project cache\n  contained no repo); vulnerable checkout `e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8`,\n  fixed checkout `a3adf63078438c86527d704e23282b7721d8ca12`, both verified by\n  hunk inspection before their respective attempts.\n- Primary logs and artifacts (all under `bundle/`):\n  - `bundle/logs/reproduction_steps.log` — full transcript of the final\n    passing run (run 2); `bundle/logs/reproduction_steps_run1.log` — the\n    first consecutive passing run. Both end with\n    `RESULT: CVE-2026-69664 CONFIRMED on vulnerable build; fixed build fails closed`.\n  - `bundle/repro/evidence/summary.json` — machine-readable pass/fail per\n    attempt and per criterion (`pass: true`, 4/4 attempts pass).\n  - `bundle/repro/evidence/vulnerable_attempt{1,2}/census.log` — server-side\n    process census, 1 Hz, 196 samples per attempt (PATH line + CENSUS lines;\n    final entry after attacker disconnect shows count 0).\n  - `bundle/repro/evidence/vulnerable_attempt{1,2}/result.json`,\n    `client_output.log` — client-side observations of each phase.\n  - `bundle/repro/evidence/fixed_attempt{1,2}/...` — same for the fixed build.\n  - `bundle/repro/evidence/vuln_bare_catch.txt` (`241: PROCESSED = (catch\n    Module:Function([Data | Args])),`), `fix_hunk.txt` (`287: catch\n    throw:{error, Error} when Module =:= http_chunk ->`) — source-line proof\n    that the vulnerable tree has the bare `catch` and the fixed tree has the\n    corrected clause.\n  - `bundle/repro/runtime_manifest.json` — runtime evidence manifest with\n    per-artifact SHA-256 (all verified) and target identity\n    `git:https://github.com/erlang/otp@e9f49f57cef6e38fd13c4b0cee1eb5509ef471e8`\n    (digest `fac886b1b27ab179e45c1c7bf166b06ff9e1f3800dd1334f5f510e6a5cf67449`).\n- Key excerpts (from the final run's result.json files):\n  - Vulnerable, parked worker census (both attempts):\n    `CENSUS ... 1 <0.NNN.0>:[{message_queue_len,0},{status,waiting}]` at\n    t=3/45/95/162 s — the **same pid** for the entire 165 s window;\n    `parked_socket_status: \"timeout\"`, `parked_socket_recv: \"\"`.\n  - Vulnerable, exhaustion:\n    `parked=300/300; pool_full=True (census=150 ...)`; \n    `legit_after_exhaustion` → `HTTP/1.1 503 Service Unavailable`\n    (heavy load); `exhausted_recheck` after 25 s → still\n    `HTTP/1.1 503 Service Unavailable`, census 150;\n    `after_close_legit` → `HTTP/1.1 200 OK`, census 0.\n  - Fixed: `parked_socket_recv` = `HTTP/1.1 400 Bad Request` ...,\n    `parked_socket_status: \"closed\"`, census count 0, and\n    `legit_after_exhaustion` → `HTTP/1.1 200 OK` during the same wave.\n- Note on version labels: the fixed checkout still reports\n  `vsn=9.3.2.6` in its PATH line because the maintenance fix commit does not\n  bump the OTP/inets version file; the authoritative discriminators between\n  the two builds are the recorded fix-hunk lines (241 bare `catch` vs 287\n  `catch throw:{error, Error}`) and the behavioral divergence\n  (indefinite park + 503 exhaustion vs immediate 400 + close + no denial).\n\n## Recommendations / Next Steps\n\n- **Upgrade guidance**: upgrade to OTP 27.3.4.17 / 28.5.0.6 / 29.0.6\n  (inets 9.3.2.7 / 9.6.2.3 / 9.7.2) or later, which contain the\n  `catch throw:{error, Error}` fix.\n- **Mitigations for interim**: enable `minimum_bytes_per_second` in the httpd\n  config so slow/stalled body receives are reaped; reduce exposure of inets\n  httpd to untrusted networks. Be aware that leaving `max_clients` **unset**\n  does not fall back to the documented default of 150 — `httpd_conf` only\n  stores the value when the user supplies it, so an unset value effectively\n  disables the connection cap (an even easier unbounded DoS); explicitly set\n  it, ideally below 150.\n- **Suggested fix approach**: the merged fix wraps the decoder continuation\n  call so chunk-decode throws are converted into a proper `400 Bad Request`\n  response and handler termination; keep that structure for every\n  continuation invoked from `handle_info`.\n- **Testing recommendations**: the fix commit adds an `httpd_SUITE` case\n  (invalid chunk size in a separate write). Keep regression coverage for the\n  segmented-write case specifically — the single-write case was never\n  vulnerable (`handle_body/3` already had a proper try/catch), so a test that\n  coalesces headers and body will not catch this class of bug. Consider also\n  auditing other `handle_info` continuation consumers for bare `catch`\n  patterns, asserting that a request timeout is always armed while a\n  partially-received request is outstanding, and giving\n  `handle_new_connection/4`'s `max_clients` lookup the documented default.\n\n## Additional Notes\n\n- **Idempotency**: `reproduction_steps.sh` is safe to re-run; it reuses the\n  in-tree build under `bundle/artifacts/otp`, re-checks out each commit\n  (`git checkout -f`), and incrementally rebuilds only the changed `inets`\n  modules. Two consecutive full runs were executed in this session and both\n  passed (exit 0, `summary.json` `pass: true`); transcripts:\n  `bundle/logs/reproduction_steps_run1.log` and\n  `bundle/logs/reproduction_steps_run2.log`.\n- **Negative/scope controls included in the proof**:\n  - fixed-commit build fails closed on the exact same segmented input (400 +\n    close, worker reclaimed, no denial of service) — confirms the fix, not an\n    environmental artifact;\n  - the single-write variant (headers + `ZZZ\\r\\n` in one TCP write) returns\n    400 even on the vulnerable build, matching the advisory's note that the\n    `handle_body/3` path was never vulnerable (verified during development).\n- **Deterministic exhaustion probe**: the attack client polls the\n  server-side census until the pool is observed full (>= 150 handlers) before\n  firing the post-exhaustion legitimate request; an earlier revision with a\n  fixed 2-second sleep could, under scheduling pressure, fire the probe while\n  the accept backlog was still draining and observe a misleading 200.\n- **Limitations**: The DoS demonstration binds `max_clients` at its documented\n  default (150); deployments that raised it need proportionally more parked\n  connections, and deployments that left it unset are unbounded (see\n  Recommendations). The census samples at 1 Hz from inside the Erlang node,\n  so a handler that lived for only a fraction of a second could in principle\n  be missed — not an issue here since the parked handler is verified alive\n  across the whole 165 s observation window with the same pid. Background\n  orchestration note: the full script takes ~10 minutes; execution\n  environments with per-command time limits must run it detached\n  (e.g. `setsid nohup ... &`).\n","cve_id":"CVE-2026-69664","cwe_id":"CWE-772","source_url":"https://github.com/erlang/otp/security/advisories","package":{"name":"erlang/otp","ecosystem":"github","affected_versions":"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)"},"reproduced_at":"2026-09-24T17:06:48.227884+00:00","duration_secs":19436.0,"tool_calls":507,"handoffs":2,"total_cost_usd":10.562078,"agent_costs":{"claim_matcher":0.021093,"judge":0.552169,"learning_policy":0.027454,"repro":3.558699,"support":0.024866,"vuln_variant":6.377797},"cost_breakdown":{"claim_matcher":{"gpt-5.4-mini-2026-03-17":0.021093},"judge":{"gpt-5.6-sol":0.552169},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.027454},"repro":{"accounts/fireworks/models/glm-5p3":3.558699},"support":{"accounts/fireworks/models/glm-5p3":0.024866},"vuln_variant":{"accounts/fireworks/models/glm-5p3":6.377797}},"vulnerable_version_variant_outcome":"unknown","fix_bypass_outcome":"unknown","variant_disclosure_state":"unknown","quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":2,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-09-24T17:06:49.358907+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":17342,"category":"analysis"},{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":16007,"category":"reproduction_script"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":7460,"category":"log"},{"path":"bundle/logs/reproduction_steps_run1.log","filename":"reproduction_steps_run1.log","size":7273,"category":"log"},{"path":"bundle/repro/attack_client.py","filename":"attack_client.py","size":10438,"category":"script"},{"path":"bundle/repro/evidence/fix_hunk.txt","filename":"fix_hunk.txt","size":69,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt1/census.log","filename":"census.log","size":3803,"category":"log"},{"path":"bundle/repro/evidence/fixed_attempt1/client_output.log","filename":"client_output.log","size":667,"category":"log"},{"path":"bundle/repro/evidence/fixed_attempt1/port.txt","filename":"port.txt","size":5,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt1/result.json","filename":"result.json","size":3071,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt1/stdout.log","filename":"stdout.log","size":83,"category":"log"},{"path":"bundle/repro/evidence/fixed_attempt2/census.log","filename":"census.log","size":1610,"category":"log"},{"path":"bundle/repro/evidence/fixed_attempt2/client_output.log","filename":"client_output.log","size":667,"category":"log"},{"path":"bundle/repro/evidence/fixed_attempt2/port.txt","filename":"port.txt","size":5,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt2/result.json","filename":"result.json","size":3071,"category":"other"},{"path":"bundle/repro/evidence/fixed_attempt2/stdout.log","filename":"stdout.log","size":83,"category":"log"},{"path":"bundle/repro/evidence/summary.json","filename":"summary.json","size":1852,"category":"other"},{"path":"bundle/repro/evidence/vuln_bare_catch.txt","filename":"vuln_bare_catch.txt","size":60,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt1/census.log","filename":"census.log","size":211579,"category":"log"},{"path":"bundle/repro/evidence/vulnerable_attempt1/client_output.log","filename":"client_output.log","size":892,"category":"log"},{"path":"bundle/repro/evidence/vulnerable_attempt1/port.txt","filename":"port.txt","size":5,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt1/result.json","filename":"result.json","size":18806,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt1/stdout.log","filename":"stdout.log","size":83,"category":"log"},{"path":"bundle/repro/evidence/vulnerable_attempt2/census.log","filename":"census.log","size":211579,"category":"log"},{"path":"bundle/repro/evidence/vulnerable_attempt2/client_output.log","filename":"client_output.log","size":892,"category":"log"},{"path":"bundle/repro/evidence/vulnerable_attempt2/port.txt","filename":"port.txt","size":5,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt2/result.json","filename":"result.json","size":18806,"category":"other"},{"path":"bundle/repro/evidence/vulnerable_attempt2/stdout.log","filename":"stdout.log","size":83,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":5222,"category":"other"},{"path":"bundle/repro/server_node.erl","filename":"server_node.erl","size":3159,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":1520,"category":"other"}]}