
PoC funcional de CVE-2026-17106 (CopyEscape): carrera TOCTOU en docker cp que permite escritura arbitraria en el host Docker. Laboratorio Docker + monitor inotify + LD_PRELOAD. Variante macOS inocua y Linux destructiva.
Educational Proof of Concept of CopyEscape (CVE-2026-17106), a TOCTOU race condition vulnerability in
docker cpthat allows a malicious container to write arbitrary files on the Docker host. Inspired by the original work of masasron, who discovered and documented the vulnerability in the blog post "CopyEscape: Taking Over Docker Hosts with docker cp".
CVE-2026-17106 is a race condition vulnerability (TOCTOU) in the tar file extraction that the Docker daemon produces during docker cp <contenedor>:<ruta> <destino>. It affects Docker CLI 29.6.1 and earlier versions; patched as of 29.7.0. The bug resides in the CLI (host-side tar extraction), not in the daemon.
The chain dubbed CopyEscape demonstrates how, starting solely from a running container with no extra capabilities, an attacker can:
/watched/file.txt/ as a regular file to any process that opens it inside the container, using an LD_PRELOAD library that intercepts open, openat, openat2, fopen, stat, lstat, fstatat, statx, and the 64 variants.aaa.txt that forces the daemon to take long enough to produce the tar for the monitor to win the race.inotify and, during the race window, replace an escape/ subdirectory with an absolute symlink to the host target ( on macOS or on Linux) using two atomic calls with rollback.The result is arbitrary file write on the host with the permissions of the process running docker cp. The Linux variant overwrites /usr/bin/runc and achieves root code execution on the host at the next docker run / exec.
This repository contains strictly educational and defensive material.
runc: use it only in a disposable VM with a verified backup.The PoC ships with a harmless macOS variant (writes ~/pwnd as a marker) whose sole purpose is to serve as a reproducible demonstration.
┌─────────────────────────────────────────────────────────────────────┐
│ 1. LD_PRELOAD camufla /watched/file.txt como archivo regular │
│ open/openat/openat2/fopen/stat/lstat/fstatat/statx → decoy │
│ docker exec ... cat /watched/file.txt → "top-level file" │
└──────────────────────────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 2. Layout real dentro del contenedor │
│ /watched/file.txt/ │
│ ├── aaa.txt (16 MiB de 'B', cebo lento) │
│ ├── escape/ (directorio real inicial) │
│ ├── escape/<basename> (payload: runc o pwnd) │
│ └── .swap-escape (symlink → /Users/<usr> o /usr/bin) │
└──────────────────────────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 3. docker cp pide tar al daemon │
│ daemon recorre /watched/file.txt/ y abre aaa.txt │
│ inotify detecta IN_OPEN sobre aaa.txt │
└──────────────────────────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 4. Monitor pivot: dos rename(2) atomicos con rollback │
│ escape/ → .old-escape/ (backup) │
│ .swap-escape → escape/ (symlink al target del host) │
└──────────────────────────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 5. Tar resultante contiene: │
│ escape → /Users/<usr> (symlink absoluto) │
│ escape/<basename> (entrada hija que atraviesa el symlink) │
└──────────────────────────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 6. CLI vulnerable extrae symlink + hija │
│ crea escape → /Users/<usr> en el destino local │
│ extrae escape/<basename> A TRAVES del symlink │
│ escribe /Users/<usr>/<basename> en el host │
└──────────────────────────────────────────┬──────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ macOS: ~/pwnd con "COPYESCAPE_MACOS_DEMO" (demo inocua) │
│ Linux: /usr/bin/runc sobrescrito (RCE como root) │
└─────────────────────────────────────────────────────────────────────┘
The PoC is built to run on macOS with:
curl, tar, git, and docker in PATH.alpine:3.21.HOME shaped like /Users/<usuario>.No root is needed for the macOS variant. The Linux variant requires root and a disposable VM.
The run-poc.sh runner automatically downloads the vulnerable CLI 29.6.1 binary from Docker's official site (download.docker.com). It does not modify the system's Docker Desktop installation: it uses the current daemon but invokes the downloaded binary for the docker cp operation.
POC CopyEscape (CVE-2026-17106)/
├── README.md ← Este archivo
├── LICENSE ← Licencia MIT
├── .gitignore
├── Dockerfile ← Dockerfile unificado (macos | linux)
├── entrypoint.sh ← Arranque del monitor sin LD_PRELOAD
├── monitor.c ← Monitor inotify unificado (una sola fuente)
├── watched_preload.c ← LD_PRELOAD con cobertura ampliada
└── run-poc.sh ← Runner robusto con reintentos
The
cli-vulnerable/directory (downloaded CLI 29.6.1) is excluded from the repository via.gitignore. The runner downloads it automatically if it is not present.
cd poc-v2
./run-poc.sh
The runner:
copyescape-macos-pocv2:local.COPYESCAPE_HOST_HOME=$HOME./watched/file.txt is readable inside the container.docker cp with the vulnerable CLI.~/pwnd marker../run-poc.sh 10 # 10 intentos (la carrera es probabilistica)
./run-poc.sh --cleanup # 5 intentos y borra la imagen al final
[run-poc] CLI vulnerable: 29.6.1
[run-poc] Intento 1/5 (contenedor copyescape-pocv2-NNNN-RRRR)
[run-poc] /watched/file.txt lee: top-level file
[run-poc] docker cp (CLI 29.6.1)...
[run-poc] EXITO en intento 1: se creo /Users/<tu-usuario>/pwnd
Contenido del marcador: COPYESCAPE_MACOS_DEMO
[run-poc] Logs del monitor:
[monitor] plataforma=macos target_dir=/Users/<tu-usuario> target_file=/Users/<tu-usuario>/pwnd quiet_ms=400 trigger=aaa.txt
[monitor] pivoted /watched/file.txt/escape -> /Users/<tu-usuario>
[run-poc] Resumen: 1 exito en 1 intento(s).
rm -f -- "$HOME/pwnd"
rm -rf -- poc-v2/file.txt
docker image rm copyescape-macos-pocv2:local
Once the chain is complete, the following elements serve as evidence:
The idea and strategy are masasron's: the TOCTOU race against the tar producer, the LD_PRELOAD camouflage, the 16 MiB decoy, the two-rename(2) pivot, and the escape/ + .swap-escape structure. Without that design, this rewrite would not exist. The conceptual scheme is identical.
Approximately 15-20% are lines that match literally (short utilities and layout constants). The rest is new code.
docker cp from a container you do not trust, and never as root on Linux.docker cp destination during extraction, or block in the daemon tars that contain child entries under a symlink.Distributed under the MIT license. See the LICENSE file for more details.
/Users/<usuario>/usr/binrename(2)escape symlink followed by a child entry escape/<basename>; the vulnerable CLI creates the symlink on the client and extracts the child entry through it, writing outside the destination chosen by the user.| Resource | Expected evidence |
|---|
~/pwnd | File with content COPYESCAPE_MACOS_DEMO\n |
poc-v2/file.txt/ | Directory (not a file) with escape -> /Users/<tu-usuario> (symlink) and aaa.txt (16 MiB) |
docker logs <contenedor> | Line pivoted /watched/file.txt/escape -> /Users/<tu-usuario> |
CLI 29.7.2 (docker cp with patched CLI) | The marker is not created; the PoC fails |
| File | Original lines | New lines | Change |
|---|
watched_preload.c | 89 | 257 | Rewritten: openat2, fstatat, statx, stat64, lstat64, open64, fopen64 wrappers; thread-safe constructor; guard against dlsym == NULL; O_TMPFILE |
monitor.c (macos + linux) | 259 + 233 = 492 (duplicated) | 344 (unified) | Rewritten and unified with -DCOPYESCAPE_TARGET_*; rollback in try_pivot; configurable exit_quiet_ms and trigger; robust detection |
entrypoint.sh | 3 | 5 | env -u LD_PRELOAD instead of unset |
Dockerfile | 22 | 43 | Unified; digest pin; --platform; no -Werror; -static; LABEL |
demo-macos.sh → run-poc.sh | 88 | 166 | Rewritten: race retries; failure logs; --cleanup; validation delegated to the binary |