# Stale Docker Images and PHP2Shell / Node2Shell: Audit Cloud Run, Kubernetes and AWS Lambda for Pre-Oct-21 Base Layers
> A floating tag like php:8.3-fpm or node:22-alpine is a moving target. If your CI cached the layer before 2026-10-21, you may be shipping the vulnerable binary today.

- **ID**: PAIN-2026-005
- **Rule**: HL-R5-DOCKERBASE
- **CVE**: OCI-BASE-STALE-2026
- **Author**: Hajime Chiba (HadayaLab / 45x Google Cloud & DeepMind Skill Badges)
- **Published**: 2026-10-24
- **Canonical URL**: https://hadayalab.com/blog/docker-aws-lambda-php2shell-node2shell-stale-image-audit


## 01. Observed Symptoms & Why Standard Package Audits Fail

Observed (context): The PHP project announced its fixes on 2026-10-21 (https://www.php.net/archive/2026.php#2026-10-21-5), and Node.js published its security releases at https://nodejs.org/en/blog/vulnerability/. Images built from FROM php:8.3-fpm or FROM node:22-alpine before the base tag was republished, or built later from a cached layer, can still contain the old interpreter. Observed (Docker behaviour): a tag is a mutable pointer, and BuildKit reuses cached layers unless told to pull.

Symptoms: (1) The Dockerfile has not changed since September, and every deploy since has 'succeeded'. (2) Cloud Run, Kubernetes or Lambda reports a healthy revision but nobody can state the base image digest. (3) A scanner reports 'no vulnerabilities' on a freshly built image, because the scanner's database or the image's package metadata does not cover the interpreter binary. (4) Different environments run different patch levels of the same tag.

Why package audits fail: composer audit and npm audit inspect application dependencies. They never open the base image. An image scanner only helps if its database includes these CVEs and it identifies the interpreter correctly. Inference: neither is guaranteed for a CVE announced days ago. The deterministic control is to execute the interpreter inside the image and compare its version to the fixed release.


## 02. Root-Cause Forensic Analysis (Runtime & Protocol Level)

Root cause: version drift between what the Dockerfile says and what the registry, the build cache and the deployed revision hold. Three places hide the old binary: the CI cache (cache-from restoring pre-fix layers), the registry tag (pushed once and never rebuilt), and the platform revision (Cloud Run revision, Kubernetes pod or Lambda function that has not been redeployed since).

The commands below answer 'what is really running' on each platform. Use image digests, not tags, as the unit of evidence. The final two commands execute the interpreter inside the image, which is the only check that does not depend on a vulnerability database.

```bash
// What is actually deployed, by platform
# Cloud Run: image (ideally with @sha256 digest) behind the live revision
gcloud run services describe SERVICE --region REGION --format='value(spec.template.spec.containers[0].image)'

# Kubernetes: resolved image digest of every running container
kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,IMAGEID:.status.containerStatuses[*].imageID'

# AWS Lambda (container image): digest-resolved URI
aws lambda get-function --function-name FUNCTION --query 'Code.ResolvedImageUri'

# Execute the interpreter inside the image: no scanner database required
docker run --rm --entrypoint php IMAGE@sha256:DIGEST -v
docker run --rm --entrypoint node IMAGE@sha256:DIGEST -v
```


## 03. Immediate Zero-Downtime Triage (Non-Breaking Mitigation)

Step 1: rebuild every image with --pull and --no-cache once, to purge pre-fix cached layers. Step 2: resolve the current digest of each base tag and pin it. Step 3: assert the interpreter version in the build, so a stale base fails the build instead of shipping. Step 4: deploy by digest. Cloud Run and Kubernetes roll revisions without downtime when health checks pass.

Version floors: the PHP fixed release must be resolved from the PHP advisory commit (see PAIN-2026-001); the Node floors are v22.23.4, v24.22.1 and v26.12.1. Do not hard-code a PHP version from memory.

Inference (Distroless): distroless Node images shrink the OS attack surface but do not change the Node binary's patch level. A distroless tag is also a mutable pointer, so you still need a digest pin and a version assertion.

```dockerfile
// Digest-pinned base with a build-time version assertion (Node example)
# resolve first:  crane digest node:22-alpine
FROM node:22-alpine@sha256:REPLACE_WITH_CURRENT_DIGEST AS base
RUN node -e "const v=process.versions.node.split('.').map(Number); if (v[0]!==22 || v[1]<23 || (v[1]===23 && v[2]<4)) { console.error('Node below v22.23.4'); process.exit(1); }"
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
USER node
CMD ["node", "server.js"]
```

```bash
// Purge stale layers, then deploy by digest
docker build --pull --no-cache -t REGION-docker.pkg.dev/PROJECT/REPO/app:build .
docker push REGION-docker.pkg.dev/PROJECT/REPO/app:build
DIGEST=$(crane digest REGION-docker.pkg.dev/PROJECT/REPO/app:build)
gcloud run deploy SERVICE --region REGION --image REGION-docker.pkg.dev/PROJECT/REPO/app@${DIGEST}
```


## 04. Permanent Remediation & Cryptographic SBOM Attestation

Tooling correction (Observed): SBOM generation is done by anchore/syft. anchore/grype consumes an SBOM or image and reports vulnerabilities. The pipeline is: syft emits a CycloneDX 1.6 SBOM, grype scans it, and the signed SBOM becomes an attestation on the image digest.

Pipeline: (1) build and push; (2) syft generates the CycloneDX 1.6 SBOM from the digest; (3) grype scans the SBOM and fails on high severity; (4) cosign attaches the SBOM as a signed attestation; (5) a Binary Authorization attestation is created for the digest; (6) the Cloud Run service or GKE cluster enforces that policy so unattested digests cannot deploy. Inference: because scanner coverage of a days-old CVE is not guaranteed, keep the in-image version assertion from Section 03 as a separate, database-independent gate.

Credential reference (HadayaLab badge ledger): GSP521 / Badge #1164 covers Binary Authorization attestation practice.

Primary sources: https://www.php.net/archive/2026.php#2026-10-21-5 | https://nodejs.org/en/blog/vulnerability/

HadayaLab operates with 45x Google Cloud & DeepMind Skill Badges (https://www.skills.google/public_profiles/49cdbdb6-20d4-4661-934a-d882c7f5c6cf). We audit Docker, Kubernetes and Cloud Run estates and wire the attestation gate into your CI.

```bash
// SBOM (CycloneDX 1.6), scan, attest, enforce
IMG=REGION-docker.pkg.dev/PROJECT/REPO/app@${DIGEST}

syft "$IMG" -o cyclonedx-json@1.6=sbom.cdx.json
grype sbom:./sbom.cdx.json --fail-on high

cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "$IMG"

gcloud container binauthz attestations sign-and-create --artifact-url="$IMG" --attestor=ATTESTOR --attestor-project=PROJECT --keyversion-project=PROJECT --keyversion-location=global --keyversion-keyring=KEYRING --keyversion-key=KEY --keyversion=1

gcloud run services update SERVICE --region REGION --binary-authorization=default
```


## Frequently Asked Questions

### How do I check whether my Docker image still contains a vulnerable PHP or Node binary?
Run the interpreter inside the exact image digest: docker run --rm --entrypoint php IMAGE@sha256:DIGEST -v, or the same with node. Compare the result with the fixed releases (Node v22.23.4+, v24.22.1+, v26.12.1+; PHP from the commit lookup in PAIN-2026-001). This does not depend on any scanner database.

### Why is FROM php:8.3-fpm or FROM node:22-alpine risky in CI/CD?
Tags are mutable pointers and BuildKit reuses cached layers. A pipeline that cached the base before 2026-10-21 can keep rebuilding the old interpreter indefinitely. Pin by SHA-256 digest, build with --pull, and assert the interpreter version at build time.

### Which tool generates a CycloneDX 1.6 SBOM: Syft or Grype?
Syft generates it, for example syft IMAGE -o cyclonedx-json@1.6=sbom.cdx.json. Grype scans an SBOM or image for vulnerabilities, for example grype sbom:./sbom.cdx.json --fail-on high. They are from the same vendor and are normally run in sequence.

### How do I stop unverified images from deploying to Cloud Run?
Create a Binary Authorization attestor and policy, sign each image digest after your build, scan and version checks pass, and enable enforcement on the service with gcloud run services update --binary-authorization=default. A digest without a valid attestation is then rejected at deploy time.


