Skip to content

overlay: support relative pathnames in mount options - #14732

Open
kvanzuijlen wants to merge 1 commit into
google:masterfrom
kvanzuijlen:overlay-relative-mount-pathnames
Open

kvanzuijlen wants to merge 1 commit into
google:masterfrom
kvanzuijlen:overlay-relative-mount-pathnames

Conversation

@kvanzuijlen

@kvanzuijlen kvanzuijlen commented Sep 13, 2026

Copy link
Copy Markdown

Fixes #14699

Problem

Linux resolves relative pathnames in overlayfs mount options against the working directory of the mounting process. gVisor rejects them:

lowerPath := fspath.Parse(lowerPathname)
if !lowerPath.Absolute {
    ctx.Infof("...: lowerdir %q must be absolute", lowerPathname)
    return nil, nil, linuxerr.EINVAL
}

containerd switches to relative pathnames after a chdir, once the absolute option string would exceed one page. A shallow image therefore works and a deep one fails. With the default --data-root the cutoff is near 38 layers. Docker can neither run nor build an image past it.

The default Cloud Native Buildpacks builder has 39 layers. pack build and Spring Boot's bootBuildImage fail under gVisor, which is how I hit this.

Docker is not needed to reproduce:

mkdir -p /t/rel && cd /t/rel && mkdir -p l0 l1 u w m
mount -t overlay overlay -o lowerdir=l0:l1,upperdir=u,workdir=w m
# gVisor: Invalid argument
# Linux:  succeeds

This is not the same as #12475. That one goes away when a tmpfs is mounted at /var/lib/docker, which this setup already does. With the tmpfs in place, absolute pathnames still mount at 80 layers, and keep working until the option string exceeds one page. Only the relative form returns EINVAL.

Fix

CtxWorkingDirectory and WorkingDirectoryFromContext sit next to the existing CtxRoot and RootFromContext. Task.contextValue answers them from t.FSContext().WorkingDirectory(). overlay.GetFilesystem reads the working directory once, holds a reference for the call, and resolves each pathname through resolveStart.

Only a task has a working directory. Every other context yields a zero VirtualDentry, and resolveStart returns EINVAL rather than falling back to vfsroot. Root stays vfsroot and Start becomes the working directory, which is the pairing getTaskPathOperation already uses for any relative pathname at AT_FDCWD.

Reading the working directory from the context also covers fsconfig(2). Fd.DoCmdCreate passes the task to NewFilesystem, which passes it to GetFilesystem.

Empty pathnames now return EINVAL. gVisor previously accepted an empty workdir=, because "" + "/work" parses as the absolute path /work. Linux rejects all three. upperdir and workdir use fsparam_file_or_string without fs_param_can_be_empty, so the generic fs_parser rejects them before overlay sees them, and ovl_fill_super rejects an empty lowerdir on ctx->nr == 0. I confirmed the lowerdir case returns EINVAL on 6.12.95.

Why the check existed

Not as a security boundary. The check appears verbatim in 77c206e ("Add //pkg/sentry/fsimpl/overlay.", 2020-06-12), the commit that created the package, when Start was unconditionally vfsroot and GetFilesystem had no access to a working directory. That commit lists eight deliberate divergences from Linux. This is not among them. 1c8e91d ("fs/overlay: create the "work" directory in workdir", 2023-09-20) copied the check for workdir without stating a reason. No CVE, escape, or traversal fix in the package's history refers to the check.

CVE-2021-30465

gVisor's CVE record marks that symlink-exchange race exploitable_under_gvisor. The stated gap is that runsc handles mount paths similarly to runc. This change does not increase that exposure.

GetFilesystem reads the working directory once and holds a reference for the whole call. Nothing can exchange the start dentry between the workdir, upperdir, and lowerdir resolutions, not even a concurrent chdir() in the calling process. GetDentryAt returns each layer dentry with a reference held, so a later swap cannot retarget the mount. The symlink walk beneath the start dentry is unchanged from the existing absolute resolution against vfsroot.

A task that performs this mount already holds CAP_SYS_ADMIN in its mount namespace and can bind-mount arbitrary paths. Resolving against its working directory grants no new reach.

getTaskPathOperation already pairs Root: RootDirectory() with Start: WorkingDirectory() for every relative pathname at AT_FDCWD. A task whose working directory sits outside its root, after a chroot without a chdir, reaches those paths today with openat(AT_FDCWD, "../.."). Overlay now resolves relative pathnames the same way the rest of the syscall surface does.

Testing

MountTest.OverlayfsRelativePaths chdirs into a directory that is not the mount target's parent, then mounts with lowerdir=../l0:../l1,upperdir=../u,workdir=../w. It checks that both lower layers are visible through the merged directory and that a new file lands in the upper layer. The test fails if any of the three pathnames resolves incorrectly.

make test TARGETS=//test/syscalls:mount_test_native
make syscall-tests TARGETS="//test/syscalls:mount_test_runsc_systrap_directfs //test/syscalls:mount_test_runsc_systrap_shared //test/syscalls:mount_test_runsc_systrap_overlay"
make test TARGETS=//pkg/sentry/fsimpl/overlay:overlay_test

mount_test_native passes, so the test matches Linux. mount_test_runsc_systrap_directfs, mount_test_runsc_systrap_shared, and mount_test_runsc_systrap_overlay pass. Reverting the fix and keeping the test fails all three runsc variants:

test/syscalls/linux/mount.cc:3107: Failure
Value of: mount("overlay", merged.c_str(), "overlay", 0,
                "lowerdir=../l0:../l1,upperdir=../u,workdir=../w")
Expected: not -1 (success)
  Actual: -1 (of type int), with errno PosixError(errno=22 Invalid argument)

Host kernel 6.12.95, platform systrap.

overlay_test covers the rejections. mount(2) cannot reach them, because a task always has a working directory. contexttest.Context supplies credentials but no working directory.

AI assistance

I wrote this patch with Claude Code. The commit message carries an Assisted-by trailer. I reviewed every line and ran the tests above myself, using the provided makefile.

Credit

@jdymitarai took the same approach in #14728, including the context key, then withdrew it before review. This version differs in what happens when there is no working directory: #14728 falls back to vfsroot, so a relative pathname resolves against the root instead of failing. This one returns EINVAL. #14728 also answers CtxWorkingDir with the root directory in createProcessContext and supervisorContext; this one leaves pkg/sentry/kernel/kernel.go alone, so only a task supplies a working directory.

Linux resolves relative pathnames in overlayfs mount options against the
working directory of the mounting process. gVisor rejected every
non-absolute pathname in lowerdir, upperdir, and workdir with EINVAL.

containerd depends on the Linux behaviour. Once the absolute option string
would exceed one page, containerd chdir()s to the snapshot directory and
passes relative pathnames instead. Docker 29 uses the containerd image
store by default. Under gVisor, Docker can then neither run nor build an
image of about 38 layers or more:

  failed to mount /tmp/containerd-mount3312412956: fstype: overlay, data:
  "workdir=/var/lib/docker/containerd/daemon/io.containerd.snapshotter.v1.
  overlayfs/snapshots/60/work,upperdir=.../60/fs,lowerdir=59/fs:58/fs:...:
  13/fs", err: invalid argument

paketobuildpacks/builder-jammy-java-tiny has 39 layers. It is the default
Cloud Native Buildpacks builder, so pack build and Spring Boot's
bootBuildImage do not work.

Take the working directory from the context, alongside the existing VFS
root, and resolve relative pathnames against it. This covers both mount(2)
and fsconfig(2) without either call site changing.

A context without a task has no working directory. Such a context still
returns EINVAL for a relative pathname rather than falling back to the
root. No mount resolves to a directory the caller did not name.

Also reject empty pathnames. gVisor previously accepted an empty workdir=,
because "" + "/work" parses as the absolute path /work.

Fixes google#14699
Assisted-by: Claude Code
@kvanzuijlen
kvanzuijlen force-pushed the overlay-relative-mount-pathnames branch from bde91b6 to 37b0aad Compare September 13, 2026 15:43
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.

overlayfs rejects relative lowerdir paths, capping image depth under Docker 29

1 participant