A zip-bomb stuffed into a SPDY header that makes your kubelet eat itself
spdystream is the Go library moby/spdystream, used inside the Kubernetes / container ecosystem to multiplex streams for things like kubectl exec, kubectl port-forward, kubectl attach, and CRI streaming. Versions ≤ 0.5.0 read three attacker-controlled 32-bit fields directly out of a SPDY/3 control frame — the SETTINGS entry count, the header count in parseHeaderValueBlock, and per-header field lengths — and pass each one straight into a Go allocation without bounds checking. Because SPDY header blocks are zlib-compressed, a few hundred bytes on the wire decompresses into gigabytes of demanded memory, so a single crafted frame causes the receiving process to OOM-kill. Fixed in 0.5.1.
There is no vendor CVSS to compare against, but third parties (SentinelOne, OffSeq) have floated 8.7 HIGH. That is too hot. The bug is a deterministic DoS — no RCE, no info-disclosure, no memory corruption — and the realistic exposure is kubelet's streaming endpoint, which sits behind webhook/TLS-client auth in any reasonable cluster. The pattern matters (every node, every pod-exec path) but the impact ceiling is *process crash and restart*, not cluster takeover.
4 steps from start to impact.
Identify a service running spdystream
github.com/moby/spdystream — overwhelmingly this is kubelet's streaming server (port 10250) or a CRI shim, occasionally Cilium / Hubble / older Docker daemon paths. Tooling: kube-hunter, nuclei kubelet templates, Shodan dork product:"kubelet".- Network reachability to a kubelet streaming endpoint or other spdystream consumer
- Target version of spdystream ≤ 0.5.0 still resident in the binary
- Kubelet read-only port 10255 is disabled by default since 1.10
- Hardened clusters require client-cert / webhook auth on 10250
Reach the SPDY upgrade handler
/exec, /attach, or /portForward on kubelet and negotiating the SPDY/3.1 upgrade. Tooling: a hand-rolled Go client built on moby/spdystream, or a forked kubectl with a tampered transport.- Ability to complete TLS handshake (and present a client cert if required)
- Endpoint actually invokes the spdystream frame parser before authz on the streamed action
- Kubelet authn (
--anonymous-auth=false) blocks unauthenticated callers before the SPDY upgrade in most managed-Kubernetes distros (EKS/AKS/GKE) - Service meshes and admission policies typically deny non-API-server traffic to 10250
Send the crafted SETTINGS / SYN_STREAM frame
2^31, or a header count / per-header length in the same range. The vulnerable parser calls make([]Setting, n) or equivalent with the attacker's value. PoC pattern: small zlib input → multi-gigabyte allocation. No public weaponized PoC is on GitHub at the time of writing, but the bug class is trivially reproducible from the patch diff.- A live SPDY session post-upgrade
- Library code path that has not been wrapped by a higher-layer size cap
- Per-container cgroup memory limits cap blast radius to the kubelet process, which systemd restarts
- Linux OOM killer typically picks the kubelet before the host panics
node_memory_MemAvailable_bytes collapse is the post-hoc signalLoop for sustained node DoS
kubectl exec cluster-wide. On nodes without Restart=always or with crashloop backoff tuned aggressively, this manifests as NodeNotReady and pod evictions.- Persistent reachability
- No rate-limit or IP block between attacker and kubelet
- Any halfway-tuned NetworkPolicy / security group blocks east-west traffic to 10250
- Node auto-repair (GKE/EKS) will replace the node before sustained impact
up{job="kubelet"} == 0 and control-plane node-status churn are loudThe supporting signals.
| In the wild | No known exploitation. Not on CISA KEV. No GreyNoise tag as of 2026-06-22. |
|---|---|
| Public PoC | No weaponized PoC published. Patch diff in moby/spdystream PR for 0.5.1 is sufficient to reconstruct one in <1 hour. |
| EPSS | 0.0043 (~0.43%) — bottom-quartile exploitation probability. |
| KEV | Not listed. |
| CVSS | No NVD score. Third-party (SentinelOne) floats 8.7 / HIGH — noisgate disagrees, see The Call. |
| Affected | github.com/moby/spdystream ≤ 0.5.0. Transitively reaches anything pulling k8s.io/apimachinery/pkg/util/httpstream/spdy or older docker/spdystream forks. |
| Fixed | 0.5.1. Kubernetes will pick this up in subsequent kubelet patch releases; check your distro vendor (RHEL/SUSE/Chainguard) for backports. |
| Exposure | Shodan shows ~5–8k internet-facing kubelets on 10250 globally — most respond 401 Unauthorized to anonymous calls. |
| Disclosed | 2026-04-16 via the Kubernetes / moby coordinated advisory. |
| Reporter | Researcher credit per the moby/spdystream GHSA; reported through the Kubernetes Security Response Committee. |
noisgate verdict.
This is a deterministic memory-exhaustion DoS with no RCE, no integrity impact, and no path to credential or data exposure — the impact ceiling is a kubelet OOM and node restart. The single most decisive factor is that the only realistic listener is kubelet's authenticated streaming endpoint, which rejects anonymous callers by default in every modern Kubernetes distribution.
Why this verdict
- Impact ceiling is DoS. CWE-770 with no follow-on primitive — process crashes, systemd restarts kubelet, no code execution, no data exposure.
- Authn gate stands in front. Kubelet's
--anonymous-auth=false(default on EKS, AKS, GKE, OpenShift, kubeadm ≥1.10) means an unauthenticated network attacker hits 401 before the SPDY parser is reached. - Blast radius is one node. Even on success, cgroup limits and node auto-repair contain the outage to a single kubelet; no lateral, no cluster-wide collapse from one frame.
- Role multiplier: kubelet (typical role). Kubelet runs on every node and is canonically a high-value component, BUT the chain here ends in *node restart*, not domain/cluster takeover. No identity, secrets, or control-plane compromise flows from this bug. Floor stays at MEDIUM.
- Role multiplier: exposed CRI/Docker shim (rare). If a legacy
docker/spdystream-backed daemon is exposed without auth (very uncommon in 2026), same DoS, same single-host blast radius. - No KEV, EPSS 0.43%, no public weaponization. Threat tempo does not justify upgrading above MEDIUM.
Why not higher?
HIGH would require either a meaningful path to RCE / privilege escalation (there is none — Go's allocator faults cleanly), or that the typical deployment exposes spdystream to the open internet without auth (it does not — kubelet authn is on by default and the bug sits behind it). A node-restart DoS on hardened, authenticated endpoints does not clear the HIGH bar.
Why not lower?
LOW would mean either trivial mitigation in default configs or negligible impact; neither holds. Misconfigured clusters with --anonymous-auth=true or exposed read paths do exist, and a single frame causing repeatable kubelet OOM on every node in a cluster is operationally painful enough to clear MEDIUM. The bug is also reachable internally by any compromised pod that can dial the node IP.
What to do — in priority order.
- Confirm
--anonymous-auth=falseon every kubelet — This is the single control that turns a remote DoS into a closed door. Audit withkubectl get --raw /api/v1/nodes/<node>/proxy/configzor check the kubelet config file directly. MEDIUM verdict carries no noisgate mitigation SLA, but do this audit as part of the next monthly hardening cycle. - Restrict network access to TCP/10250 — Cloud security groups / on-prem firewall rules should permit only the control-plane CIDR and approved monitoring agents to talk to kubelet. Block pod-to-node-IP egress to 10250 with a default-deny NetworkPolicy (Calico/Cilium GlobalNetworkPolicy). Same monthly hardening cadence — no formal mitigation SLA at MEDIUM.
- Inventory transitive use of
moby/spdystream— Rungo mod why github.com/moby/spdystreamacross every Go binary you build, and SBOM-scan vendor images. Anything pinning ≤ 0.5.0 lands on the patch queue. Complete within 30 days so you have a clean rollout target. - Set per-process memory limits on kubelet — A systemd drop-in with
MemoryMax=andRestart=alwaysensures one OOM does not cascade to host kernel panic and gives you fast self-healing while you wait on the binary update. - Schedule the actual fix — Roll kubelet / CRI / Cilium / any spdystream consumer to a build that vendors 0.5.1+ within the noisgate remediation SLA (365 days for MEDIUM, but most cluster operators will pick this up automatically in the next minor bump).
- WAFs / API gateways — kubelet:10250 is not behind your edge WAF; this is a node-direct port.
- Rate limiting at the LB — the bug fires on one frame, not a flood; rate limits do nothing.
- EDR / runtime security tools — they cannot see SPDY frame internals, only the post-hoc kubelet crash.
- Pinning kubectl client version — the bug is in the server parser; client version is irrelevant.
Crowdsourced verification payload.
Run on each Linux node as root (or via your config-management tool) to check the spdystream version vendored into kubelet and the containerd/cri-o binary. Invocation: sudo ./check-cve-2026-35469.sh /usr/bin/kubelet. Requires read access to the binaries; no network calls.
#!/usr/bin/env bash
# check-cve-2026-35469.sh — detect vulnerable moby/spdystream (<= 0.5.0) inside a Go binary
# Usage: sudo ./check-cve-2026-35469.sh <path-to-go-binary> [more binaries...]
# Exits 0 PATCHED, 1 VULNERABLE, 2 UNKNOWN
set -u
FIXED="0.5.1"
status=0
ver_ge() {
# returns 0 if $1 >= $2 (semver-ish)
[ "$1" = "$2" ] && return 0
printf '%s\n%s\n' "$2" "$1" | sort -V -C
}
check_one() {
local bin="$1"
if [ ! -r "$bin" ]; then
echo "UNKNOWN $bin (cannot read)"
return 2
fi
if ! command -v go >/dev/null 2>&1; then
# Fall back to strings — less reliable but no toolchain needed
local hit
hit=$(strings "$bin" 2>/dev/null | grep -Eo 'github.com/moby/spdystream[[:space:]]*v[0-9]+\.[0-9]+\.[0-9]+' | head -n1)
if [ -z "$hit" ]; then
echo "UNKNOWN $bin (no spdystream string; may be statically dropped or absent)"
return 2
fi
local v=${hit##*v}
if ver_ge "$v" "$FIXED"; then
echo "PATCHED $bin spdystream $v"
return 0
fi
echo "VULNERABLE $bin spdystream $v (fix: $FIXED)"
return 1
fi
local mod
mod=$(go version -m "$bin" 2>/dev/null | awk '$2=="dep" && $3 ~ /spdystream$/ {print $4; exit}')
if [ -z "$mod" ]; then
echo "UNKNOWN $bin (spdystream not in module table)"
return 2
fi
local v=${mod#v}
if ver_ge "$v" "$FIXED"; then
echo "PATCHED $bin spdystream v$v"
return 0
fi
echo "VULNERABLE $bin spdystream v$v (fix: v$FIXED)"
return 1
}
if [ $# -eq 0 ]; then
set -- /usr/bin/kubelet /usr/local/bin/kubelet \
/usr/bin/containerd /usr/local/bin/containerd \
/usr/bin/crio /usr/local/bin/crio \
/usr/bin/cilium-agent /opt/cni/bin/cilium-cni
fi
for b in "$@"; do
[ -e "$b" ] || continue
check_one "$b"
rc=$?
[ $rc -gt $status ] && status=$rc
done
exit $status
If you remember one thing.
moby/spdystream to a build that vendors 0.5.1+ within the next major patch cycle. Monday morning, kick off two parallel workstreams: (1) SBOM-scan every Go binary in your registry for moby/spdystream ≤ 0.5.0 and tag them for the next minor bump, (2) audit every kubelet for --anonymous-auth=false and a NetworkPolicy that blocks pod-to-node 10250 — that is the control which keeps this bug from ever being reachable. Skip the fire drill; this is hygiene, not a page.Sources
What defenders are saying.
Crowdsourced verification outputs.
Results submitted by users who ran the verification payload against their environment.