{"repro_id":"REPRO-2026-00206","version":8,"title":"runc symlink deletion via malicious /dev symlink in container image","repro_type":"security","status":"published","severity":"low","description":"runc is a CLI tool for spawning and running containers according to the OCI specification. In versions prior to 1.3.6, 1.4.0-rc.1 through 1.4.3, and 1.5.0-rc.1 through 1.5.0-rc.3, when setting up the container rootfs, setupPtmx and setupDevSymlinks call os.Remove and os.Symlink with a filepath.Join string which allow an image with /dev as a symlink to trick runc into deleting files called ptmx on the host or creating a hardcoded set of symlinks with specific names and targets in an arbitrary pre-existing host directory. This issue is not exploitable under Docker, but affects other Linux container tooling whose higher-level runtimes are built on runc. Fixed in versions 1.3.6, 1.4.3 and 1.5.0.","root_cause":"# CVE-2026-41579 — Root Cause Analysis\n\n## Summary\n\nCVE-2026-41579 is a low-severity host filesystem integrity issue in opencontainers/runc. When runc prepares a container rootfs, the functions `setupPtmx` and `setupDevSymlinks` operate on path strings under the bundle rootfs before `pivot_root(2)` occurs. If the container image has `/dev` as a symlink that points outside the rootfs (for example to a host directory controlled by the attacker), `filepath.Join(rootfs, \"/dev/ptmx\")` resolves through the symlink and runc deletes or re-creates files on the host. A malicious image can therefore trick runc into removing an existing file named `ptmx` and creating a small fixed set of device symlinks in an attacker-chosen host directory.\n\n## Impact\n\n- **Package / component:** opencontainers/runc\n- **Affected versions:** prior to 1.3.6, 1.4.0-rc.1 through 1.4.3, and 1.5.0-rc.1 through 1.5.0-rc.3\n- **Risk level:** low (per upstream advisory)\n- **Consequences:** Arbitrary deletion of a host file named `ptmx` and creation of a limited set of hardcoded symlinks in a host directory reachable via a malicious `/dev` symlink. Not exploitable under Docker, but exploitable via other runc-based runtimes that do not mask `/dev` with a top-level read-only layer.\n\n## Impact Parity\n\n- **Disclosed / claimed maximum impact:** Arbitrary file deletion and symlink creation on the host filesystem through a malicious container image (`/dev` symlink).\n- **Reproduced impact from this run:** Vulnerable runc deleted a decoy file named `ptmx` and replaced it with a symlink in an attacker-controlled directory; fixed runc left the decoy untouched.\n- **Parity:** `full` for the documented filesystem-integrity impact. The reproduction does not demonstrate privilege escalation or code execution, which is consistent with the advisory's low-severity rating.\n\n## Root Cause\n\nThe bug is in runc's rootfs preparation code. Before the container pivots into its rootfs, `setupPtmx` and `setupDevSymlinks` use `filepath.Join(rootfs, \"/dev/...\")` and then call `os.Remove` / `os.Symlink`. Because the operations happen before `pivot_root`, a `/dev` entry in the image that is a symlink to an attacker-controlled host directory is followed, causing the operations to affect the host path instead of the container rootfs.\n\nUpstream fix commit:\n\n- `opencontainers/runc@864db8042dbb` — \"rootfs: make /dev initialisation code fd-based\"\n\nThe fix rewrites the `/dev` setup code to operate on file descriptors relative to the opened rootfs directory, so symlinks in the image cannot redirect the operations to host paths.\n\n## Reproduction Steps\n\nThe reproduction is implemented in `bundle/repro/reproduction_steps.sh`. At a high level it:\n\n1. Verifies Docker is available.\n2. Downloads the vulnerable runc release binary (`v1.3.5`) and the fixed release binary (`v1.3.6`).\n3. Builds a minimal OCI rootfs from the official `busybox` image.\n4. Builds two privileged Docker images (`repro-runc-vuln` and `repro-runc-fixed`) that each contain one runc binary and the rootfs.\n5. Inside a privileged container, replaces `/bundle/rootfs/dev` with a symlink to `/controlled_dev` and creates a decoy `/controlled_dev/ptmx`.\n6. Generates an OCI bundle with `runc spec`, disables the terminal, and sets the command to `/bin/true`.\n7. Runs `runc run cve-ptmx-test -b /bundle`.\n8. Checks whether the decoy file was deleted.\n\nExpected evidence:\n\n- **Vulnerable (1.3.5):** the `ptmx` decoy is removed and `/controlled_dev` contains symlinks such as `ptmx -> pts/ptmx`, `core -> /proc/kcore`, `fd -> /proc/self/fd`, etc.\n- **Fixed (1.3.6):** the `ptmx` decoy remains untouched and runc does not create host symlinks.\n\n## Evidence\n\n- `bundle/logs/repro_vuln.log` — vulnerable runc 1.3.5 deletes the decoy and creates host symlinks.\n- `bundle/logs/repro_fixed.log` — fixed runc 1.3.6 preserves the decoy.\n- `bundle/logs/build_repro-runc-vuln.log` — Docker build log for the vulnerable image.\n- `bundle/logs/build_repro-runc-fixed.log` — Docker build log for the fixed image.\n- `bundle/repro/runtime_manifest.json` — runtime evidence manifest produced by the script.\n\nKey excerpts:\n\nVulnerable run:\n\n```text\nRUN_VERSION: runc version 1.3.5\nBEFORE: /controlled_dev/ptmx present?\n-rw-r----    1 root     root            10 ... ptmx\n...\nAFTER: /controlled_dev contents:\n-rw-r--r--    ... ptmx\nRESULT: decoy deleted\n```\n\nFixed run:\n\n```text\nRUN_VERSION: runc version 1.3.6\nBEFORE: /controlled_dev/ptmx present?\n-rw-r--r--    ... ptmx\n...\nAFTER: /controlled_dev contents:\n-rw-r--r--    ... ptmx\nRESULT: decoy preserved\n```\n\n## Recommendations / Next Steps\n\n- Upgrade runc to a patched version: **1.3.6**, **1.4.3**, or **1.5.0** (or later).\n- Higher-level runtimes that consume runc should ensure container images cannot ship a `/dev` symlink that resolves to a host path, or rely on the patched runc version.\n- Regression tests should include a rootfs where `/dev` is a symlink to a controlled host directory and verify that `setupPtmx`/`setupDevSymlinks` do not operate on the host path.\n\n## Additional Notes\n\n- The script is idempotent: it re-downloads only missing binaries, rebuilds the Docker images each run, and uses unique container names.\n- The reproduction uses the real `runc` CLI binary and the real OCI bundle execution path (`runc run`), not a reimplemented parser or mocked environment.\n- The Docker-in-Docker privileged container is required in this sandbox because the host environment lacks `CAP_SYS_ADMIN` and a writable cgroup hierarchy; inside the privileged container runc has the capabilities needed to create a genuine container.\n- No sanitizer or crash is involved; the proof relies on the filesystem state difference between the vulnerable and fixed versions.\n","cve_id":"CVE-2026-41579","cwe_id":"CWE-61 (UNIX Symbolic Link Following)","source_url":"opencontainers/runc","reproduced_at":"2026-07-02T19:38:13.955487+00:00","duration_secs":1521.0,"tool_calls":181,"handoffs":2,"total_cost_usd":2.199512040000001,"agent_costs":{"hypothesis_generator":0.07823505,"judge":0.258393,"repro":0.5869975700000001,"support":0.06744698,"vuln_variant":1.2084394399999998},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/kimi-k2p7-code":0.07823505},"judge":{"gpt-5.5":0.258393},"repro":{"accounts/fireworks/models/kimi-k2p7-code":0.5869975700000001},"support":{"accounts/fireworks/models/kimi-k2p7-code":0.06744698},"vuln_variant":{"accounts/fireworks/models/kimi-k2p7-code":1.2084394399999998}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-02T19:38:14.730457+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":7731,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":5749,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":12086,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":11485,"category":"analysis"},{"path":"bundle/ticket.md","filename":"ticket.md","size":892,"category":"ticket"},{"path":"bundle/ticket.json","filename":"ticket.json","size":1305,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":733,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":587,"category":"other"},{"path":"bundle/logs/build_repro-runc-vuln.log","filename":"build_repro-runc-vuln.log","size":1448,"category":"log"},{"path":"bundle/logs/build_repro-runc-fixed.log","filename":"build_repro-runc-fixed.log","size":1449,"category":"log"},{"path":"bundle/logs/repro_vuln.log","filename":"repro_vuln.log","size":985,"category":"log"},{"path":"bundle/logs/repro_fixed.log","filename":"repro_fixed.log","size":572,"category":"log"},{"path":"bundle/logs/vuln_variant/inner_scripts/relative_dev_symlink_vuln.sh","filename":"relative_dev_symlink_vuln.sh","size":1848,"category":"other"},{"path":"bundle/logs/vuln_variant/inner_scripts/relative_dev_symlink_fixed.sh","filename":"relative_dev_symlink_fixed.sh","size":1848,"category":"other"},{"path":"bundle/logs/vuln_variant/inner_scripts/pts_symlink_vuln.sh","filename":"pts_symlink_vuln.sh","size":1873,"category":"other"},{"path":"bundle/logs/vuln_variant/inner_scripts/pts_symlink_fixed.sh","filename":"pts_symlink_fixed.sh","size":1873,"category":"other"},{"path":"bundle/logs/vuln_variant/inner_scripts/create_start_entrypoint_vuln.sh","filename":"create_start_entrypoint_vuln.sh","size":1856,"category":"other"},{"path":"bundle/logs/vuln_variant/inner_scripts/create_start_entrypoint_fixed.sh","filename":"create_start_entrypoint_fixed.sh","size":1856,"category":"other"},{"path":"bundle/logs/vuln_variant/relative_dev_symlink_vuln.log","filename":"relative_dev_symlink_vuln.log","size":1248,"category":"log"},{"path":"bundle/logs/vuln_variant/relative_dev_symlink_fixed.log","filename":"relative_dev_symlink_fixed.log","size":823,"category":"log"},{"path":"bundle/logs/vuln_variant/pts_symlink_vuln.log","filename":"pts_symlink_vuln.log","size":1038,"category":"log"},{"path":"bundle/logs/vuln_variant/pts_symlink_fixed.log","filename":"pts_symlink_fixed.log","size":1038,"category":"log"},{"path":"bundle/logs/vuln_variant/create_start_entrypoint_vuln.log","filename":"create_start_entrypoint_vuln.log","size":1270,"category":"log"},{"path":"bundle/logs/vuln_variant/create_start_entrypoint_fixed.log","filename":"create_start_entrypoint_fixed.log","size":845,"category":"log"},{"path":"bundle/logs/vuln_variant/eval_relative_dev_symlink.txt","filename":"eval_relative_dev_symlink.txt","size":51,"category":"other"},{"path":"bundle/logs/vuln_variant/eval_pts_symlink.txt","filename":"eval_pts_symlink.txt","size":43,"category":"other"},{"path":"bundle/logs/vuln_variant/eval_create_start_entrypoint.txt","filename":"eval_create_start_entrypoint.txt","size":54,"category":"other"},{"path":"bundle/logs/vuln_variant/vulnerable_version.txt","filename":"vulnerable_version.txt","size":418,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":794,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":790,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":9628,"category":"documentation"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":875,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3060,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1049,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1851,"category":"other"}]}