Skip to content

Plug sysroot bypasses in at-family path syscalls#27

Merged
jserv merged 1 commit into
mainfrom
path-audit
May 11, 2026
Merged

Plug sysroot bypasses in at-family path syscalls#27
jserv merged 1 commit into
mainfrom
path-audit

Conversation

@jserv
Copy link
Copy Markdown
Contributor

@jserv jserv commented May 11, 2026

A path-translation audit on the *at()/readlinkat/sysroot surface that does not flow through openat2 found a consistent set of gaps: sys_fchmodat, sys_fchownat, sys_mknodat, sys_utimensat, sys_chdir, and the four sys_xattr path handlers passed absolute guest paths straight to the host syscall, skipping path_resolve_sysroot_. A guest running chmod("/etc/foo") under --sysroot=/opt/sr therefore hit the host's /etc/foo rather than /opt/sr/etc/foo, contradicting the redirect contract every other path syscall already implements (sys_openat_path, sys_truncate, sys_statfs, stat_at_path).

Route each handler through the matching resolver: nofollow when AT_SYMLINK_NOFOLLOW or the lxattr variant is set, _create for mknodat since the target may not yet exist, _path for everything else.

sys_chroot accepted any guest-supplied path, stat()'d it on the host, and stored it via proc_set_sysroot without checking that the new root was reachable from the current one. A guest already running under --sysroot=/opt/sr could call chroot("/etc") and pivot to the host's /etc with no containment check. Reduce to a chroot("/") no-op and return -EPERM for anything else; this still satisfies the original motivation (coreutils stdbuf does fork -> chroot("/") -> exec) without keeping the pivot path. Real Linux chroot requires CAP_SYS_CHROOT, which the guest does not have in elfuse's single-process VM.

The sysroot_path static buffer was mutated by proc_set_sysroot and read by proc_get_sysroot with no lock. A sibling vCPU running chroot during another vCPU's path resolution could tear the snprintf source underneath the consumer. Add sysroot_lock and a copying proc_sysroot_snapshot helper, then migrate proc_resolve_sysroot_*, fork-state.c, and exec.c to the snapshot. proc_get_sysroot stays as-is for the NULL-test fast paths (path[0] != '/' early returns) that tolerate a racy read; the docstring spells out the contract. Also canonicalize the sysroot once at proc_set_sysroot time via realpath so subsequent containment checks compare against a stable form. The /lib/basename retry inside proc_resolve_sysroot_path_flags was an early hack that masked containment errors when the dynamic linker walked DT_RPATH/RUNPATH/LD_LIBRARY_PATH for itself; drop it.


Summary by cubic

Fixes sysroot bypass in at-family path syscalls and restricts chroot to chroot("/") to prevent escaping the configured root. Adds locking and snapshots for sysroot updates and hides the sysroot prefix from guest-visible paths.

  • Bug Fixes

    • Route absolute paths via sysroot for fchmodat, fchownat, mknodat (use create resolver), utimensat, chdir, and getxattr/setxattr/listxattr/removexattr (respect nofollow).
    • Strip the sysroot prefix in /proc/self/exe, getcwd, and /proc/self/cwd so guests see guest paths.
    • Resolve /dev/shm/<name> via a validated helper to block invalid suffixes.
  • Refactors

    • Restrict chroot to "/" (no-op); return -EPERM for any other path.
    • Add a sysroot lock and proc_sysroot_snapshot; canonicalize sysroot with realpath; migrate resolvers, execve, and fork state to snapshots; remove the /lib/<basename> fallback in the resolver.
    • Add proc_dev_shm_resolve API and a test-sysroot-chdir target.

Written for commit ca9c1be. Summary will update on new commits.

A path-translation audit on the *at()/readlinkat/sysroot surface that
does not flow through openat2 found a consistent set of gaps:
sys_fchmodat, sys_fchownat, sys_mknodat, sys_utimensat, sys_chdir, and
the four sys_*xattr path handlers passed absolute guest paths straight
to the host syscall, skipping path_resolve_sysroot_*. A guest running
chmod("/etc/foo") under --sysroot=/opt/sr therefore hit the host's
/etc/foo rather than /opt/sr/etc/foo, contradicting the redirect
contract every other path syscall already implements (sys_openat_path,
sys_truncate, sys_statfs, stat_at_path).

Route each handler through the matching resolver: nofollow when
AT_SYMLINK_NOFOLLOW or the lxattr variant is set, _create for mknodat
since the target may not yet exist, _path for everything else.

sys_chroot accepted any guest-supplied path, stat()'d it on the host,
and stored it via proc_set_sysroot without checking that the new root
was reachable from the current one. A guest already running under
--sysroot=/opt/sr could call chroot("/etc") and pivot to the host's
/etc with no containment check. Reduce to a chroot("/") no-op and
return -EPERM for anything else; this still satisfies the original
motivation (coreutils stdbuf does fork -> chroot("/") -> exec) without
keeping the pivot path. Real Linux chroot requires CAP_SYS_CHROOT,
which the guest does not have in elfuse's single-process VM.

The sysroot_path static buffer was mutated by proc_set_sysroot and
read by proc_get_sysroot with no lock. A sibling vCPU running chroot
during another vCPU's path resolution could tear the snprintf source
underneath the consumer. Add sysroot_lock and a copying
proc_sysroot_snapshot helper, then migrate proc_resolve_sysroot_*,
fork-state.c, and exec.c to the snapshot. proc_get_sysroot stays
as-is for the NULL-test fast paths (path[0] != '/' early returns)
that tolerate a racy read; the docstring spells out the contract.
Also canonicalize the sysroot once at proc_set_sysroot time via
realpath so subsequent containment checks compare against a stable
form. The /lib/basename retry inside proc_resolve_sysroot_path_flags
was an early hack that masked containment errors when the dynamic
linker walked DT_RPATH/RUNPATH/LD_LIBRARY_PATH for itself; drop it.
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 10 files

@jserv jserv merged commit bc5665e into main May 11, 2026
5 checks passed
@jserv jserv deleted the path-audit branch May 11, 2026 07:54
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