Skip to content
Lesson 11 of 19

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/sh or 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 doesYour simulator does
chroot(dir) sets the process's rootROOT <path>
the tree the image extracted toFILE <virt_path> — register a path that exists
resolve a path relative to that root, so .. at the root cannot leave itRESOLVE <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.

Up nextLayered ImagesFilesystems & Images

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…