Step 1 of 5 · Reading · ~3 min
Read
Filesystems & Images
rootfs: Container Filesystem
Namespaces and cgroups gave the process a private view and a budget. It still needs something to be: a /bin/sh to execute, a /lib to link against, a /etc to read. The rootfs is the container's whole world, and it is a great deal less magical than it looks — a directory on the host that the process has been made to believe is /.
What docker run actually does with it
1. resolve the image, extract its layers to /var/lib/docker/overlay2/<id>/diff/
2. unshare(CLONE_NEWNS) a private mount table to make a mess in
3. mount the fresh /proc, /sys, /dev, /tmp inside it
4. pivot_root into the extracted tree, then unmount the old root
5. exec the entrypoint as PID 1
Step 4 is the one that matters. After pivot_root and the unmount, the host filesystem is not hidden from the container behind a permission check — it is absent from the container's mount table, and there is no path expressible from inside that names it. chroot alone would leave the real root mounted underneath and reachable by a process that kept a file descriptor open across the call; that is the documented escape, and it is why real runtimes do not use chroot.
What has to be in there
/bin/shor a static binary — something for the entrypoint to be./lib,/lib64— unless the binary is statically linked, in which case neither is needed./dev/null,/dev/zero,/dev/random— bind-mounted in by the runtime, not shipped in the image./proc,/sys— mounted at runtime, so they show this namespace's processes./tmp— usually a tmpfs, which is why it is empty at every start.
The scratch image is the limiting case: zero files. A statically linked Go or Rust binary plus nothing, a container that starts in tens of milliseconds and has no shell for an attacker to find. The cost is that there is no shell for you to find either — no ls, no cat, and debugging happens from outside with docker cp or a second container sharing the namespaces.
Why the exercise is a simulator
chroot and pivot_root both need CAP_SYS_CHROOT or CAP_SYS_ADMIN, which a submission does not get. But the security property everything above rests on is not the syscall — it is path resolution that cannot produce a result outside the root, and that is a string algorithm you can write exactly.
Your exercise: Rootfs Path Resolution
| The kernel does | Your simulator does |
|---|---|
chroot(dir) sets the process's root | ROOT <path> |
| the tree the image extracted to | FILE <virt_path> — register a path that exists |
resolve a path relative to that root, so .. at the root cannot leave it | RESOLVE <req> — the host path, or 403 |
Normalise the way the kernel walks: drop empty segments (so //lib//ld.so is /lib/ld.so), drop ., and pop one segment for each ... If a .. arrives with nothing left to pop, the request tried to climb above the root — answer 403. If normalising consumes everything, the answer is the root itself with a trailing slash, not an error: /, /./ and /a/.. all name the root, and naming the root is not an escape.
Resolution is defined for paths that do not exist — RESOLVE /missing returns a host path, because the failure of open() afterwards is ENOENT, a different thing from a refusal to resolve. Keep those two answers apart and you have written the check that makes -v mounts and docker cp safe.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…