Skip to content

Root changes directories and opens files whatever their mode (CAP_DAC_OVERRIDE) - #34

Merged
chandarith-chea merged 3 commits into
mainfrom
root-dac-override
Nov 14, 2026
Merged

chandarith-chea merged 3 commits into
mainfrom
root-dac-override

Conversation

@chandarith-chea

@chandarith-chea chandarith-chea commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

The bug

tar, running as root in a Dockiva container, could not restore a Go module cache (the app repo's CI, 2026-11-14): Go makes module directories 0555, tar as root creates each directory with its final mode and then its contents, and the nested mkdir answered EACCES. Linux lets uid 0 through (CAP_DAC_OVERRIDE). Here the container's files are the Mac user's, so macOS enforces a mode's owner bits against the guest's root too.

As root on 0.6.10 (engine af13b2b), in a directory a after chmod 555 a:

operation 0.6.10 Linux, and this branch
mkdir a/d, ln -s x a/l, mkfifo a/p, rmdir a/d Permission denied works
mv / rm of a file already on disk in a Permission denied works
append to a 0444 file, read a 000 file Permission denied works
after chmod 000 a: ls a, touch a/z, cd a Permission denied works
a new file in a (touch a/f) seemed to work works

The last row hid a second bug. New regular files are made in the in-memory upper layer (native/upm.h), and the write-back into a 0555 directory got EACCES every time, so the file stayed in memory and was lost when the container stopped: mkdir a; echo x > a/f; chmod 555 a, then docker cp showed an empty a/. That is the order Go uses for a module directory (fill it, then make it read-only).

The fix

A file call the host refused with EACCES is done once more when the caller is root holding CAP_DAC_OVERRIDE (FS_DAC_REDO around fs_call and chdir in native/fs.h). Every path call passes through that one place, and a call that succeeds pays nothing new.

  • The second pass takes no fast path, so the walk opens each directory on the way. Each one of the container's that lacks owner rwx is given it for the rest of the call: by descriptor, or, where the mode refuses the open itself, by its name in its already open parent with no symlink followed. Nothing outside the container's tree can be changed.
  • open, truncate, chdir and rename do the same for the leaf (a 000 file, a 000 directory to list, a 0555 directory moved to another parent).
  • When the call ends the modes are put back by descriptor (the entry may have been renamed or removed by then), unless something else changed them meanwhile. The call's own chmod is what stays.
  • A mode that cannot be put back is reported on stderr (the container's log) with both modes and the error. Where a leaf had to be widened by name and could not then be opened, the mode goes back by name only if the name still is that file (device and inode).
  • The image layer is never changed, nor anything under a read-only bind mount.
  • The write-back of an in-memory file uses the same pass, whoever made the file.

Another user still gets EACCES, and so does root after a capset that drops the capability. The in-memory layer let both of them create and rename new files inside a 0555 directory (its mirror directories are 0777); it now asks the host directory's mode.

Limits

  • While a redone call runs (microseconds), the container's other processes can see the wider mode; an engine killed at that moment leaves it.
  • --cap-drop is not supported: dockivad answers Unsupported HostConfig: CapDrop. The capability can only be dropped from inside the container.
  • Image files and directories without owner read or search permission (Fedora's 0000 /etc/shadow) stay shut to root.
  • exec, chown, inotify and unix-socket paths through an unsearchable directory are not redone.
  • For users other than root, creating, renaming or removing an in-memory file now costs one host stat of its directory.

Checks

  • New tests/native/rootdac_guest.c, run by scripts/verify_overlay.py on all four runtime builds (interpreter, HVF, both sanitizer builds), with and without the memory layer. It does each operation above as root in a directory made in the container, a bind mount, and a 0555 directory of the image, at 0555 and at 000, on files on disk and files still in memory; then as uid 1000 and as root without the capability, expecting EACCES. The verifier then checks the files and modes left on the host, that the image and a read-only mount are unchanged, and busybox tar extracting plus rm -rf removing a tree of 0555 directories as root. On main it fails at the first mkdir (rootdac FAIL line 102 /w); on this branch all eight runs pass.
  • scripts/verify_overlay.py: all 54 lines PASS.
  • Also passing: verify_fs_escape, verify_memfs, verify_lookup_cache, verify_procfs, verify_fork_sockets, verify_fexecve, verify_accept, verify_listen, verify_epoll, verify_fadvise, verify_madvise, verify_map_stack, verify_sysinfo, verify_compat_limits, verify_address_space, verify_cpu_state, verify_memory_backing, verify_hvf.
  • Against a locally built bundle: verify_dockivad.py (31 checks, with and without --new-home) and verify_tty.py pass. go vet and go test ./... in dockivad pass.
  • The runtime builds with GCC 13.3 on Ubuntu 24.04 with the CI's flags, plain and sanitized (the first push failed there: fchmodat is warn_unused_result in glibc).
  • verify_cow (sanitizer assertion) and verify_decode_cache (its test no longer compiles) fail, the same way on main.
  • End to end with the locally built engine and ubuntu:24.04: a tree with 0555 directories, 0444 files and symlinks, archived with tar --posix -cf x.tar -P, extracts as root with exit 0, empty stderr and 33 of 33 entries, identical content (diff -r) and the modes kept; rm -rf then removes it. The same operations work in a named volume, a bind mount and /tmp; uid 1000 gets Permission denied for each; docker cp after the lost-file sequence above now shows the files; a two-step docker build that adds to and removes from a 0555 directory of the previous layer works.

Found on the way, not changed here

  • Hard links inside a bind mount or named volume answer ENOENT on 0.6.10 (ln /v/a /v/b), whatever the modes: linkat reads ov_leaf.layer, which means nothing for a mount's leaf. The new test makes hard links in the container layer only.
  • dockivad itself trips over such directories: docker commit of a container with nested 0555 directories fails, docker cp of a tree with a 000 directory is truncated, and docker rmi of an image with a non-empty 0555 directory removes image.json but not the rootfs, after which docker images answers "Host operation failed". Several paths use os.RemoveAll and plain os.Mkdir where removeTree exists for this.

The GOFLAGS=-modcacherw workaround in the app repo's CI stays until a release carries this.

…_OVERRIDE)

tar, running as root in a container, could not restore a Go module cache: Go
makes module directories 0555, tar as root creates each directory with its
final mode and then its contents, and the nested mkdir answered EACCES. The
container's files are the Mac user's, so macOS enforces a mode's owner bits
against the guest's root as against anyone; Linux lets uid 0 through. As root
on 0.6.10, in a directory after chmod 555: mkdir, symlink, mkfifo, rmdir, and
rename or unlink of a file on disk all failed; so did appending to a 0444 file,
reading a 000 one, and listing or entering a 000 directory.

New regular files seemed to work, because they are made in the in-memory upper
layer (upm.h). They did not: the write-back into the 0555 directory got EACCES
every time, the file stayed in memory and was lost when the container stopped
(mkdir a; echo x > a/f; chmod 555 a; then docker cp showed an empty a/).

A file call the host refused with EACCES is now done once more when the caller
is root holding CAP_DAC_OVERRIDE (FS_DAC_REDO around fs_call and chdir, so
every path call passes through it and the first attempt costs nothing more).
That pass takes no fast path: the walk opens each directory on the way, and each
one of the container's that lacks owner rwx is given it for the rest of the
call, by descriptor, or, where the mode refuses the open itself, by its name in
its already open parent with no symlink followed, so nothing outside the root
changes. open, truncate, chdir and rename do the same for the leaf. The modes
are put back by descriptor when the call ends (the entry may have been renamed
or removed), unless something else changed them meanwhile; the call's own
chmod is what stays. The image layer is never changed (no container writes
it), nor a read-only bind mount. The write-back uses the same pass, whoever
made the file.

Another user still gets EACCES, and so does root after a capset that drops the
capability. The in-memory layer let both make and rename new files in a 0555
directory (its mirror directories are 0777); it now asks the host directory's
mode, and treats root without the capability as bound by it.

Limits: while a redone call runs (microseconds) the container's other
processes can see the wider mode, and an engine killed just then leaves it.
dockivad rejects --cap-drop, so the capability can only be dropped from inside.
Image files and directories without owner read or search permission stay shut
to root. exec, chown, inotify and unix-socket paths are not redone.

tests/native/rootdac_guest.c (scripts/verify_overlay.py, every runtime build,
with and without the memory layer) does each operation as root in a directory
made in the container, a bind mount and a 0555 directory of the image, at 0555
and 000, on files on disk and still in memory; then as another user and as
root without the capability, expecting EACCES; the verifier checks the files
and modes left on the host, that the image and a read-only mount are
unchanged, and busybox tar extracting and rm -rf removing a tree of 0555
directories as root. It fails on main at the first mkdir.
The Linux build (GCC, glibc) failed: fs_dac_leaf discarded fchmodat's result
where it puts a mode back after the open it widened it for failed, and glibc
marks fchmodat warn_unused_result, which a cast does not silence.

That restore went by name, and it could also hit the wrong file: when the name
had been renamed over or removed between the chmod and the open, it set the old
file's mode on whatever was there now. The descriptor opened is now compared
with the file that was widened (device and inode) before it is kept, and the
mode goes back by name only where the name still is that file and its mode is
still the one set here. Where it cannot go back there, and where fs_dac_end's
fchmod fails, the engine says so on stderr (the container's log) with both
modes and the error: the guest's call is already done or refused by then, so
there is no result to carry it.

Builds with GCC 13.3 on Ubuntu 24.04 with the CI's flags, plain and sanitized.
@chandarith-chea
chandarith-chea merged commit 6df19f0 into main Nov 14, 2026
2 checks passed
@chandarith-chea
chandarith-chea deleted the root-dac-override branch November 14, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant