Skip to content

sysroot permit ENOENT and ENOTDIR for containment checks on absent paths#212

Open
doanbaotrung wants to merge 1 commit into
sysprog21:mainfrom
open-sources-port:eloop
Open

sysroot permit ENOENT and ENOTDIR for containment checks on absent paths#212
doanbaotrung wants to merge 1 commit into
sysprog21:mainfrom
open-sources-port:eloop

Conversation

@doanbaotrung

@doanbaotrung doanbaotrung commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

sysroot permit ENOENT and ENOTDIR for containment checks on absent paths

When a path component (like a parent directory) does not exist,
realpath fails with ENOENT or ENOTDIR. Previously, this failure
was mapped to a containment check violation, which then aborted the
syscall with ELOOP.

We now recursively walk up the directory tree to find the closest
existing parent, check its containment, and permit the syscall if
the parent is contained. This ensures guest programs receive ENOENT
rather than ELOOP on absent parent paths.

Fix #187


Summary by cubic

Fix sysroot containment so missing or non-directory path components no longer trigger ELOOP. We now resolve the nearest existing parent to validate containment and let the syscall return ENOENT or ENOTDIR as expected.

  • Bug Fixes
    • Added realpath_existing_parent() to find the closest existing parent when realpath fails with ENOENT/ENOTDIR.
    • Applied to both follow-final and parent-only checks in sysroot_path_is_contained (including "." and "..").
    • Added tests for opening with a non-existent parent and a TOCTOU race that creates/removes the parent; ensure ENOENT and never ELOOP.

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

Review in cubic

@doanbaotrung
doanbaotrung force-pushed the eloop branch 3 times, most recently from 860a5a9 to 33fc1df Compare July 17, 2026 04:22

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review completed against the latest diff

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/syscall/proc-state.c
Comment thread tests/test-sysroot-create-paths.c Outdated
@Max042004

Copy link
Copy Markdown
Collaborator

Looking at the callers, containment is invoked only after sysroot_path_exists(), after
access(parent), or after sysroot_ensure_dir_exists(parent). In a stable filesystem each guarantees
that the subsequent realpath() has an existing path to resolve. The new fallback appears reachable
only through a TOCTOU change between those operations and realpath().

The issue’s /etc/sysusers.d/basic.conf example also does not take this path today: when the
sysroot parent is absent and the path is outside the forced /tmp, /var/tmp, and .ccache routing
set, proc_resolve_sysroot_create_path() returns the literal host path before containment is
checked.

Please provide a concrete reproducer that reaches this fallback, or clarify that the change only
addresses a TOCTOU case and add a deterministic test for it. The current regression test should
also restore the /tmp fixture state.

When a path component (like a parent directory) does not exist,
realpath fails with ENOENT or ENOTDIR. Previously, this failure
was mapped to a containment check violation, which then aborted the
syscall with ELOOP.

We now recursively walk up the directory tree to find the closest
existing parent, check its containment, and permit the syscall if
the parent is contained. This ensures guest programs receive ENOENT
rather than ELOOP on absent parent paths.

Fix sysprog21#187
@doanbaotrung

Copy link
Copy Markdown
Collaborator Author

Actions Completed

  1. Restored /tmp Fixture State: Cleaned up the leftover ${sysroot}/tmp symlink in test-sysroot-create-paths.c by unlinking it and recreating /tmp as a normal directory at the end of the symlink escape test (unlink_symlink_escape_done). This prevents downstream /tmp operations from hitting the symlink-rejecting creation code path and triggering unexpected ELOOP failures.
  2. Clarified and Tested TOCTOU Mitigation: As you correctly noted, under stable filesystem conditions the resolver callers guarantee existence before calling sysroot_path_is_contained, making realpath_existing_parent only reachable via a TOCTOU race (where a directory is removed/replaced in between the check and realpath).
    To verify this mitigation deterministically, we implemented a concurrent TOCTOU race test in test-sysroot-create-paths.c. The test spawns a child process that loops creating and deleting /tmp/race_dir while the parent attempts open(O_CREAT) on /tmp/race_dir/file.txt. The test asserts that the race eventually triggers ENOENT (due to the recursive parent resolution fallback) and never returns ELOOP (which would signify a containment check violation).

@doanbaotrung
doanbaotrung requested a review from Max042004 July 18, 2026 17:23

@jserv jserv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The new walk-up in sysroot_path_is_contained fires only under the single-window TOCTOU that the module comment (proc-state.c:469) already declares out of scope, so the actionable issue is the fail-open regression noted inline, not the race itself. Two lower-priority items without their own anchor: str_copy_trunc in realpath_existing_parent (:448) silently truncates an over-long path and could authorize a different, shorter contained path — security code should fail closed with ENAMETOOLONG; and the test's rmdir(race_dir) cleanup can leave residue (ENOTEMPTY) if a race iteration created file.txt inside.

Comment thread src/syscall/proc-state.c
return false;
if (!realpath(parent, real_path)) {
if (errno != ENOENT && errno != ENOTDIR)
return false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When realpath(parent) fails and realpath_existing_parent climbs more than one level (a missing intermediate component, not just the leaf's parent), the snprintf below reattaches only the final basename (slash + 1) onto the shallower ancestor. For ${sr}/a/b/c with b missing it validates ${sr}/a/c, not the ${sr}/a/b/c the syscall runs on — the middle is silently dropped. This authorizes the wrong object even without an adversary, and converts a case that previously failed closed (returned false -> ELOOP) into fail-open: if b is later created as a symlink out of the sysroot, the syscall escapes. Either keep the fail-closed return when realpath(parent) fails, or validate the entire stripped remainder (reject .., confirm the full reconstructed path is prefix-contained) rather than reattaching one component.

mounted_sysroot_root);
snprintf(race_file, sizeof(race_file), "/tmp/race_dir/file.txt");

pid_t pid = fork();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

fork()'s return isn't checked. On -1 the child branch is skipped and kill(pid, SIGKILL) becomes kill(-1, SIGKILL) — SIGKILL to every process the user can signal, then waitpid(-1, ...) reaps an arbitrary child. Add if (pid < 0) FAIL(...) before the split.

int hit_enoent = 0;
int hit_eloop = 0;
int iterations = 0;
while (hit_enoent == 0 && iterations < 50000) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The test only PASSes if it wins the race and observes ENOENT, so it's flaky on a single core or loaded CI (hit_enoent stays 0 -> spurious FAIL). It also asserts only the errno mapping, never that nothing was created outside the sysroot — so it can't catch the fail-open case in the parent-only branch. Make the ENOENT-mapping check deterministic (open through a statically missing intermediate, assert ENOENT and not ELOOP), keep the racer as a non-fatal stress pass, and add an assertion that no artifact exists outside mounted_sysroot_root.

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.

Path containment checks fail with ELOOP on non-existent parent directories

3 participants