Skip to content

Wave 6 — Cloudflare Containers exec() probe family (W-CTR-01…11) - #10

Merged
jlgore merged 3 commits into
mainfrom
wave6-containers-exec
Jul 30, 2026
Merged

Wave 6 — Cloudflare Containers exec() probe family (W-CTR-01…11)#10
jlgore merged 3 commits into
mainfrom
wave6-containers-exec

Conversation

@jlgore

@jlgore jlgore commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

What

New probe family targeting Cloudflare Containers ctx.container.exec() — the API that lets Worker code hand attacker-controlled bytes and attacker-influenced argv to a real Linux process, including streaming a request body straight into stdin.

This is a different trust boundary from every existing track: full POSIX, a writable filesystem, its own egress path, and a lifetime decoupled from the request. On Lambda / Azure Functions / GCP the container is the function sandbox, so there is no isolate→container process API to attack — that framing is the comparison block on all 11 probes.

  • packages/container/ (new @wulf/container) — wulf-container Worker owning the CtrTarget container DO: a deliberately-vulnerable JSON-RPC surface over ctx.container (argv exec, sh -c interpolation, request-body→stdin, detached spawn, egress, snapshots, interception, getTcpPort), plus an InterceptSink entrypoint. Mirrors the packages/llamafirewall layout.
  • W-CTR-01…11 in packages/worker, driven over the SVC_CTR service binding, with _ctr.ts as the shared client. Optional binding → every probe reports blocked locally / pre-deploy.
  • docs/wave6-containers-exec.md — rationale, architecture, safety rails, per-probe roster, deploy runbook, full results.

Deploy-verify (2026-07-30, run wave6-20260730)

Image 548 MB, instance_type = "basic", colo iad21. 10 accessible, 1 blocked.

ID Result Sev Finding
W-CTR-01 accessible info root, Seccomp: 0, CapEff: 000001ffffffffff, NoNewPrivs: 0. inspect() / interceptOutboundTcp() typed but not implemented.
W-CTR-02 accessible high argv is literal; the same string via sh -c executes and returns uid=0(root).
W-CTR-03 accessible medium Byte-exact stdin (incl. chunked, no Content-Length), real RIFF…WEBP from ffmpeg, and the payload selected the concat demuxer on auto-probed input — ffmpeg's -safe default refused the local path.
W-CTR-04 accessible high Clean per-exec env, but /proc/1/environ yields the secret canary plus CLOUDFLARE_DURABLE_OBJECT_ID / DEPLOYMENT_ID / PLACEMENT_ID / LOCATION.
W-CTR-05 accessible medium Raw TCP to :22 connects (beyond fetch()'s reach). No link-local / foreign RFC1918. enableInternet: false does bind exec'd processes.
W-CTR-06 accessible medium A detached process survives the spawning request and a multi-request gap.
W-CTR-07 accessible medium Default root, /usr/bin writable, user option not honoured by name. /dev + initial userns show this is a microVM guest — the VM, not the container, is the boundary.
W-CTR-08 accessible low 96 MB buffers into the isolate; 192 MB fails cleanly with Memory limit exceeded before EOF.
W-CTR-09 blocked info snapshotDirectory/Container() callable but return internal errors — typed and exposed, not functional.
W-CTR-10 accessible info HTTP interception works; HTTPS interception fails cert validation, so it is not transparent.
W-CTR-11 accessible medium A port opened by exec() and declared nowhere is reachable via getTcpPort(); dead port correctly refused.

The centre of gravity is the pairing: W-CTR-02 supplies code execution from request data, and 04/05/06/07/11 each price it. Three results cut the other way and are stated as plainly — the containment story is better than the capability inventory suggests.

Security note

wulf-container is gated by a shared secret (WULF_CTR_AUTH, x-wulf-ctr-auth) in addition to workers_dev = false. The first deploy enabled a workers.dev route by default and the arbitrary-sh -c endpoint was confirmed executing a command from an unauthenticated public request; it was closed immediately. Routing is not an access control for that Worker.

Notes for review

  • Five probe bugs found and fixed mid-run (a destroy() that wedged a shared DO into serialized-request timeouts, a TCP classifier that missed a successful connect, a pkill -f cleanup that killed itself, an "RFC1918" target that was the container's own address, an interception credited to a failed install). All documented in the wave doc's Probe corrections section; results above are from the re-run.
  • Build the image with docker build --network=host — on the WSL2 host the default Docker bridge resolves deb.debian.org AAAA-only with no IPv6 route.
  • Repo-wide pnpm -r typecheck clean; deployed registry lists 81 probes, 11 of them W-CTR-*.
  • Raw probe JSON is under packages/harness/results/ (gitignored, per repo convention).

🤖 Generated with Claude Code

jlgore and others added 3 commits July 29, 2026 11:10
Deploy-verified all seven W-WC probes against wulf-worker.sharted.workers.dev
(version 4fd485f8-98ae-44ff-8cb7-d9a9499dcc30). W-WC-04 confirmed critical: the
identity header is not part of the cache key, so one caller receives another
caller's per-user secret.

Wiring:
- Uncomment the SVC_WC_VICTIM loopback service binding.
- Enable Workers Cache on the WcVictim entrypoint only, keeping the /probe/*
  router uncached so existing Track-1 probes stay deterministic. Syntax verified
  against wrangler 4.108.0: `exports` is a map keyed by export name and each
  entry requires type = "worker" (not an [[exports]] array with a name key).

Probe corrections found during deploy-verify:
- W-WC-02: the post-purge re-read raced purge propagation and always reported
  "not evicted". Added a 10s propagation wait before the re-read; no verdict
  derives from elapsed time, so the nonce oracle stays host-clock-independent.
- W-WC-05: split into two arms. The Authorization auto-bypass is decided on the
  inbound request, so sending it masked the heuristic-freshness test entirely.
  Arm A (no Authorization) exercises heuristic freshness; arm B keeps it as a
  control. Arm B refutes the "defeated auto-bypass" thesis for Authorization —
  the bypass held despite the Worker stripping the header from the response.
- /wc/sensitive now emits a w-wc-05 canary instead of w-wc-04's.

Remaining gap: the cross-PoP amplification half of W-WC-06/07 still needs an
external multi-region driver.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pinned driver

The cross-PoP half of W-WC-06/07 never needed an external multi-region driver. A
Durable Object is the only primitive that lets Worker code choose where it runs:
locationHint pins a new object to a region, and a service-binding fetch from
inside it dispatches in that object's own data center. WcRegionDriver drives
SVC_WC_VICTIM from objects pinned to wnam/weur/apac and reports the colo each one
actually ran in (read back from /cdn-cgi/trace), so a conclusion is only drawn
from confirmed-different colos. Observed: DFW/AMS/KIX, DEN/MRS/SIN, LAX/CDG/HKG,
SJC/LIS. The object stores nothing; a DO class still needs a migration, hence the
first [[migrations]] tag on wulf-worker.

Both cross-PoP results are defensive, and both bound blast radius:

- W-WC-06 amplification REFUTED. Remote colos did not serve the origin's nonce,
  and on a 15s re-read each returned the nonce it had created itself as a HIT —
  every data center holds its own entry, which excludes propagation lag as the
  explanation. Verified by hand first (same run_id 45s apart), then encoded as
  the probe's own `per-colo` verdict so it no longer rests on a hand-run. This
  downgrades the practical reach of W-WC-03: a poisoned entry does not go global
  for free. Kept as a regression — it flips to `confirmed` if tiering changes.
- W-WC-07 collapsing is bounded to one data center. Simultaneous 8-way bursts
  from SJC and LIS produced disjoint nonce sets, so victim-joining is capped at
  one PoP's concurrent traffic.

W-WC-05 arm C closes the Set-Cookie half: re-attaching Set-Cookie to the shape arm
A already proved cacheable yields BYPASS, so the auto-bypass is real and it is the
Worker's response-side strip that destroys it. The two halves of the thesis have
opposite answers — Authorization is decided inbound and cannot be defeated
response-side (arm B), Set-Cookie can.

Deploy-verified on version bd8f2395-a5a3-4cd1-9997-1cd1e67fe0b2; raw results in
packages/harness/results/w-wc-0*-wave5-xpop-bd8f2395.json. W-WC-01..04 re-run
unchanged. Wave 5 has no open items left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ily (Wave 6)

Cloudflare Containers shipped `ctx.container.exec()`, letting Worker code hand
attacker-controlled bytes and attacker-influenced argv to a real Linux process —
including streaming a request body straight into stdin. That is a different trust
boundary from every existing track: full POSIX, a writable filesystem, its own
egress path, and a lifetime decoupled from the request. On Lambda/Azure/GCP the
container *is* the function sandbox, so there is no isolate->container process API
to attack; that framing is the comparison block on all 11 probes.

New package @wulf/container: the `CtrTarget` container DO exposing a deliberately
vulnerable JSON-RPC surface over ctx.container (argv exec, sh -c interpolation,
request-body->stdin, detached spawn, egress, snapshots, interception, getTcpPort),
plus an InterceptSink entrypoint. Driven from wulf-worker over SVC_CTR.

Deploy-verified 2026-07-30 (run wave6-20260730): 10 accessible, 1 blocked.
- W-CTR-02 (high): argv is literal; the same string via sh -c executes as root.
- W-CTR-04 (high): exec'd processes get a clean env, but /proc/1/environ yields the
  secret canary plus Cloudflare's DURABLE_OBJECT_ID/DEPLOYMENT_ID/PLACEMENT_ID.
- W-CTR-03: byte-exact stdin incl. chunked with no Content-Length, a real WebP from
  ffmpeg, and a payload that selected the concat demuxer on auto-probed input.
- W-CTR-05/06/07/11: raw TCP beyond fetch()'s reach, processes outliving the
  request, root with full caps and a writable /usr/bin, exec-opened ports reachable.
- Countervailing: enableInternet:false binds exec'd processes, output() enforces a
  real memory ceiling, HTTPS interception fails cert validation. W-CTR-09 blocked —
  the snapshot API is typed and callable but returns internal errors.

wulf-container is gated by a shared secret (WULF_CTR_AUTH) in addition to
workers_dev=false: the first deploy enabled a workers.dev route by default and the
arbitrary-sh -c endpoint was briefly reachable from the internet. Routing is not an
access control for that Worker.

Full results, per-probe rationale, safety rails and the corrections made mid-run
(a destroy() that wedged a shared DO, a TCP classifier that missed a successful
connect, a pkill cleanup that killed itself) are in docs/wave6-containers-exec.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jlgore
jlgore merged commit 4cfa9dc into main Jul 30, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant