Skip to content

fix: preserve caller access checks and pin chroot targets - #274

Open
jamesboyzj-design wants to merge 8 commits into
multikernel:mainfrom
jamesboyzj-design:fix/chroot-open-effects-150
Open

jamesboyzj-design wants to merge 8 commits into
multikernel:mainfrom
jamesboyzj-design:fix/chroot-open-effects-150

Conversation

@jamesboyzj-design

@jamesboyzj-design jamesboyzj-design commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Access queries on unchanged COW paths were being answered by the supervisor against a workdir-confined target. That broke lower symlinks to external executables and used the wrong caller credentials. The revision follows the scope requested in #274 (comment).

  • Unchanged COW targets use the caller's kernel access check. The final generic metadata fallback no longer recaptures those queries. Earlier proc-net virtualization and user handlers remain registered. Raw access/faccessat/faccessat2 tests compare all eight permission combinations through absolute and relative external links against a kernel-only reference.
  • Copied-up targets and whiteouts remain mediated. Changed targets reached through unchanged lower links are selected through the COW view; upper confinement, nofollow/dangling behavior and whiteouts remain. Only mediated COW W_OK is waived. For an unchanged read-only lower file, W_OK now returns the native denial, even though a future COW write could copy it up; this is the requested bounded delegation behavior. Tests cover copied-up permissions, unchanged aliases, blocked-directory search, link limits and mixed access modes.
  • Chroot pathname queries pin the constrained O_PATH target, recheck its virtual policy mapping and query the retained fd with AT_EMPTY_PATH. Deterministic dangling-host-link and bounded replacement regressions exercise this change. Pinning does not fix the separate credential semantics.

The open creation/truncation side-effect fix 70e258b has been extracted into #277, independently based on latest main a0eb434. This PR retains the original shared commit without rewriting history; once #277 lands, its change will disappear from this PR's effective diff.

Caller-credential correctness in mediated metadata, chroot and modified COW paths is deferred to #278, with verified command-line reproductions for both false denials and false allows, including capability drops. The failing chroot credential acceptance cases from the earlier local experiment were not included in this bounded revision and are not claimed fixed. The prior local checkpoint remains preserved. The retained unchanged-COW identity tests use genuine kernel-only references and pass as both ordinary user and root.

Validation history and current revision

Local full-workspace results below are for 5749f7e; the final d3e647b integration suite and current-head CI are reported separately below.

Linux aarch64, Rust 1.96.1, kernel 7.0.14-orbstack-00380-ga7e0a2dc9535, Landlock ABI 8; Docker --init --security-opt seccomp=unconfined, without privileged mode. Serial unprivileged test execution uses uid/gid 65534 and cleared supplementary groups, native Linux /tmp output; C smoke and explicit HOME/root controls run as root.

  • Core integration: 537 passed, 0 failed, 0 ignored. Includes all original open/access regressions and the new COW/chroot cases.
  • Core unit: 880 passed, 1 failed: unchanged control::tests::server_closes_on_another_uid_without_answering hit BrokenPipe. A bounded matched check of that exact test on reviewed 49197ebc and this revision produced 9 passes / 1 BrokenPipe failure out of 10 on each. No control test or implementation was modified.
  • All 22 workspace test binaries completed. Besides the control flake, both the prior baseline and current run have the inherited HOME=/root learner failure under uid 65534; its exact case passes in separate root controls. The profile HOME case was explicitly run separately as root and passed. This is not a fully green local workspace claim.
  • The two unchanged-COW identity tests also pass as root on the final workspace-built executable. access queries in mediated metadata paths use supervisor credentials #278's ordinary metadata/chroot CLI reproduction continues to show the deferred mismatch as both uid 65534 and root.
  • git diff --check passed. Native x86_64 legacy open/access and release workspace coverage are delegated to CI for this actual pushed head, not to old-head results. Local aarch64 legacy fixtures use raw *at fallback. Checkpoint restore is unverified.

Build: cargo test --offline --locked --workspace --no-run --message-format=json; run the Cargo-reported executables with --test-threads=1, generally through setpriv --reuid=65534 --regid=65534 --clear-groups. Identity subset: access_matches_ in the core integration executable. The previous broad identity matrices are documented in #278; no ignored credential test is counted as a pass here.

The kernel re-reads mutable paths after Continue; this is not a snapshot guarantee or a resolution of #27. No credential-matching executor, mode-bit approximation, setfsuid-only workaround, supervisor credential mutation, or blanket uid-mode denial is introduced. #273/#275 functionality and future write-only semantics are not imported. This PR does not close #27, #143 or #150.

ARM replacement investigation

The initial stress fixture made only the target file unreadable, so any readable-parent answer also failed its assertion. On Linux 6.17.0-1022-azure aarch64, a standalone C program with no Sandlock, seccomp or supervisor reproduced the same wrong-parent selection: 1,000,000 raw confined openat2 calls produced 334,075 files, 2 directories and 665,923 errors. Local Linux 7.0.14 produced 337,119 files, 0 directories and 662,881 errors. This establishes a native lookup limitation on that runner; it is not a credential result or a claim that this PR fixes kernel object selection.

Reproduction: standalone source, ARM run. Compile with cc -O2 -pthread diagnostics/openat2-race.c -o /tmp/openat2-race and run it as an ordinary user. It uses synthetic temporary files only.

f16e8f4 pins the original virtual request rather than reopening the preliminary resolved name, but the expanded ARM run still reproduced native wrong-parent selection; that commit alone is not represented as resolving the failure. d3e647b makes the fixture's in-root parents unreadable while retaining search/write permissions, so the bounded stress assertion specifically detects access to the readable outside sentinel. The deterministic dangling-host-link regression remains unchanged. The test does not establish exact object selection beyond the kernel's guarantees. Diagnostic instrumentation, 80,000-query expansion and the standalone reproducer are confined to the separate diagnostic branch.

Final d3e647b: all 17 official CI checks passed (run); native ARM core unit/integration counts are 887/537, x86 counts are 900/538, all with zero failures. Final local core integration: 537 passed. The isolated expanded ARM run (843a335) also passed the full release workspace, including 80,000 confinement queries (run). The expansion uses a unique fixture per iteration; its earlier directory-reuse setup failure is not counted as a pass.

@jamesboyzj-design jamesboyzj-design changed the title fix: require write grants for chroot open side effects fix: enforce chroot open effects and pathname access modes Oct 3, 2026
@jamesboyzj-design jamesboyzj-design changed the title fix: enforce chroot open effects and pathname access modes fix: enforce chroot open and access permissions Oct 3, 2026
@jamesboyzj-design jamesboyzj-design changed the title fix: enforce chroot open and access permissions fix: enforce open and access checks in virtual filesystems Oct 3, 2026

@congwang-mk congwang-mk 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 chroot open and access fixes look right. The COW access rewrite regresses symlink lookups; see inline.

};
let open_flags = libc::O_PATH | libc::O_CLOEXEC
| if flags & libc::AT_SYMLINK_NOFOLLOW != 0 { libc::O_NOFOLLOW } else { 0 };
match open_confined(cow.upper_dir(), cow.workdir(), &real, open_flags, 0) {

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.

RESOLVE_IN_ROOT breaks symlinks that leave the workdir: .venv/bin/python -> /usr/bin/python3 now resolves to <workdir>/usr/bin/python3 and access(X_OK) returns ENOENT, so PATH lookup fails. Unmodified read opens still pass through (needs_read_intercept), so cat works but access does not. Please confine only paths the COW layer changed, and add a test for this.

Err(a) => return a,
};
let ret = unsafe {
libc::faccessat(libc::AT_FDCWD, path.as_ptr(), kernel_mode, flags as i32)

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.

This follows host symlinks on real_path. If the child swaps the last component after resolution, it learns the permission bits of any host path. Pin with an O_PATH open first and query with AT_EMPTY_PATH, as the COW handler does.

}
};
// Query the pinned object, avoiding a second pathname resolution after
// confinement. The supervisor shares the sandbox's filesystem identity.

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.

Not true with the uid option: the child is root in its user namespace, so for files owned by the mapped user its access results differ from the supervisor's. This applies to the chroot path too.

@jamesboyzj-design

Copy link
Copy Markdown
Contributor Author

Review follow-up: validation report and remaining blockers

The current revision is not ready to be presented as a completed fix. This report records the local follow-up to the three review comments; it does not request that the threads be resolved.

Versions: main 1e697ce90f2e345a130f2626e23a209f40c97eac; reviewed/public head 49197ebc00f4336a7497d35c24ab7f55b44a245c; local checkpoint 44eebd99f44e3cdf2f84b4f3403a58a346918a62. The local checkpoint and additional tests have not been pushed, so the results below are local evidence, not updated public CI. The baseline comparison uses reviewed-head production code with identical new test sources.

Correction to the identity validation

My earlier local identity matrices used an ordinary supervised sandbox as the supposed native reference. That was incorrect: the generic procfs/metadata.rs access handler also runs faccessat2 under the supervisor's credentials. Agreement between those paths did not establish caller-identity correctness. I withdraw that conclusion, including the earlier held-O_PATH comparisons.

The corrected references use no_supervisor(true), retaining the child user mapping and kernel confinement while letting the child execute the actual permission query. A second helper drops and verifies the child's effective, permitted and inheritable capability sets before querying. Setup failures fail the tests.

Findings against the three comments

  • External lower symlinks — r4174284508: still open. Twenty-four raw access queries through absolute/relative lower links to an authorized external executable pass on original main but return ENOENT on reviewed head and the local revision. A separate local repair makes unchanged final lower links respect copied-up target permissions and target whiteouts; that seven-query regression fails on reviewed head and passes locally. This remains partial: external/intermediate/parent-component traversal and a shared lower/upper link budget need a coherent visible resolver. No blanket Continue shortcut or unconstrained host reopen was introduced; upper confinement remains.
  • Chroot pathname replacement — r4174284513: partial repair only. The local branch pins a constrained O_PATH target, rechecks its virtual policy mapping and queries the retained fd with AT_EMPTY_PATH. The deterministic dangling-host-link regression (24 queries) and bounded replacement stress (4,000 queries) fail on reviewed head and pass locally. This does not establish caller credentials, all directory-search permissions, or a synchronized post-resolution replacement boundary. Stress alone is not a complete race proof.
  • Caller credentials — r4174284517: confirmed and unresolved. With a uid/gid-65534 supervisor and a child mapped to uid 0, a mode-000 R_OK query succeeds in the kernel reference but returns EACCES through COW/chroot. The corrected matrices expose 8 COW and 32 chroot mismatches, including AT_EACCESS, held descriptors and chroot COW on/off. With a root supervisor and a child that has discarded its capabilities, the direction reverses: the reference denies mode-000 reads while COW allows them in 2 queries and chroot in 8 pathname/held-fd queries. These failures occur on both reviewed head and the local revision. All fixtures are synthetic.

Matched validation

Environment: Linux aarch64, kernel 7.0.14-orbstack-00380-ga7e0a2dc9535, Landlock ABI 8, Rust 1.96.1; Docker --init --security-opt seccomp=unconfined, without --privileged. Workspace binaries run serially as uid/gid 65534 with supplementary groups cleared, except C smoke and explicit root controls.

Check Local checkpoint Reviewed head + same tests
Core unit 881 passed / 0 failed 881 passed / 0 failed
Core integration 536 passed / 3 failed 533 passed / 6 failed
Four identity tests, uid 65534 2 passed / 2 failed 2 passed / 2 failed
Four identity tests, root 1 passed / 3 failed 1 passed / 3 failed

All 22 workspace test binaries completed per version. Both runs also have the same HOME-dependent learner failure under the inherited root HOME; that exact case passes in separate root controls. The profile HOME case was explicitly excluded from the unprivileged run and passed separately as root. No review regression was ignored. The historical BrokenPipe unit failure did not recur. Doc tests executed zero examples, with 8 ignored.

Build entrypoint: cargo test --offline --locked --workspace --no-run --message-format=json; generated executables were selected from Cargo JSON and run with --test-threads=1 using setpriv --reuid=65534 --regid=65534 --clear-groups. The identity subset filter is access_matches_ in the core integration binary; it executes the four COW/chroot mapping and capability-drop tests. These additional tests are currently local only.

The three remaining local integration failures are external lower symlinks and the corrected COW/chroot identity matrices. The original open side-effect protections and the local confined-object/visible-target regressions pass. Native x86_64 legacy syscalls, the release CI matrix and checkpoint restore remain unverified for this revision; old-head CI is not evidence for the local changes.

Remaining implementation boundary

A candidate solution is an isolated kernel permission executor matching the notification task's current IDs, namespace, groups and capability sets, with directory-search checks and retained, policy-bounded COW/virtual-mount targets. Entering the child's namespace alone is insufficient and can grant capabilities it has discarded. I have not substituted mode-bit emulation, a setfsuid-only workaround, supervisor credential mutation or blanket uid-mode denial.

The generic metadata access path shares the credential issue. Related chroot stat/statx/readlink paths retain separate resolution/reopen concerns and are not claimed fixed here.

No functionality from #273 or #275 was imported. #273 shares chroot/dispatch.rs and needs conflict review whichever lands second, but no semantic dependency was established. #275 still adds explicit read-write APIs while preserving existing fs_write behavior; no future write-only contract or forced rebase dependency is imposed. This report does not close #27, #143 or #150.

@congwang-mk

Copy link
Copy Markdown
Contributor

Thanks for the thorough follow-up. To keep this PR bounded:

  1. COW: please let the kernel answer access queries for paths COW hasn't modified, and answer them on the child's behalf only for copied-up or whited-out paths. This fixes the external-symlink regression, and for unchanged paths the kernel uses the caller's real credentials.
  2. Chroot: the O_PATH + AT_EMPTY_PATH pinning is what I asked for, so please push it. The credential mismatch already exists in procfs/metadata.rs; let's track it in a separate issue rather than build a credential-matching checker here.
  3. Please split the open side-effect fix (70e258b) into its own PR so it can land now.

Recheck virtual policy on the retained object and query it through faccessat2 AT_EMPTY_PATH. This local partial revision does not resolve COW external-link compatibility or prove caller credential equivalence.
@jamesboyzj-design jamesboyzj-design changed the title fix: enforce open and access checks in virtual filesystems fix: preserve caller access checks and pin chroot targets Oct 5, 2026
@jamesboyzj-design

Copy link
Copy Markdown
Contributor Author

Implemented the three requested scope changes:

  1. Pushed 5749f7e: unchanged COW access targets now reach the caller's kernel, including external lower links and all permission combinations. I also removed the generic metadata fallback for non-chroot COW access so a Continue is not immediately converted back into a supervisor-credential query. Copied-up/whiteout targets remain mediated with upper confinement; unchanged aliases to changed targets are covered. Unchanged lower W_OK now has native kernel semantics, while copied-up W_OK retains COW handling.
  2. Pushed the constrained O_PATH + AT_EMPTY_PATH chroot pinning in 52d9cf4, with deterministic dangling-link and bounded replacement regressions in the follow-up. The broader credential mismatch is now tracked separately in access queries in mediated metadata paths use supervisor credentials #278, including a runnable, verified CLI reproduction. It is explicitly not claimed fixed by pinning.
  3. Open side-effect commit 70e258b is now independently submitted as fix: require chroot write grants for open side effects #277 (9d32abc on latest main a0eb434), without access changes. I kept fix: preserve caller access checks and pin chroot targets #274 fast-forward-only; the shared open diff will drop out once fix: require chroot write grants for open side effects #277 lands.

Local validation: #274 has 537/537 core integration; #277 has 887/887 core unit and 525/525 core integration. #274's full core unit run was 880/881 due to the existing control BrokenPipe race; an exact matched 10-run check produced 9 passes and 1 identical BrokenPipe on each of reviewed head and revision. Both workspace runs completed, with the already documented HOME-environment learner case passing in explicit root controls. The PR bodies record these limits rather than claiming fully green local workspaces.

Current-head CI is running for both PRs. Review threads remain open for your assessment; #278 is deferred work, not silently counted as resolved.

@jamesboyzj-design

Copy link
Copy Markdown
Contributor Author

Validation update: please keep this PR under review. The new replacement regression fails on the native ARM CI runner, although the local aarch64 integration suite (537 tests) and native x86_64 CI pass. The independent open fix #277 has all 17 checks passing and is unaffected.

An isolated instrumented branch showed that some /data/link queries were answered against the readable parent /data. Pinning the original virtual request in f16e8f4 did not eliminate that failure in the expanded ARM stress run, so I am not claiming this race is fixed. I am now comparing the confined-open primitive with a standalone raw openat2 reproduction on the same runner to identify the remaining cause. No diagnostic instrumentation is in this PR, and no failing test is ignored.

The credential mismatch remains separately tracked in #278, as requested; it is not being used to explain away this object-selection failure.

@jamesboyzj-design

Copy link
Copy Markdown
Contributor Author

The three requested changes are now published for review:

  1. COW: unchanged targets reach the caller's kernel access check, including external lower symlinks and the final metadata dispatch fallback. Copied-up targets and whiteouts remain mediated. Regression coverage includes all eight access modes, all three syscall ABIs, changed targets reached through unchanged aliases, search/depth limits, and kernel-only credential comparisons.
  2. Chroot: the confined O_PATH + AT_EMPTY_PATH implementation is pushed. The broader supervisor/caller credential mismatch is tracked in access queries in mediated metadata paths use supervisor credentials #278, with runnable ordinary-user and root reproductions; no credential-matching checker is introduced here.
  3. Open side effects: 70e258b is extracted as fix: require chroot write grants for open side effects #277, independently based on main. Its 17 checks pass. The shared commit remains in this PR's history without a force push; landing fix: require chroot write grants for open side effects #277 will remove that overlap from the effective diff.

Current #274 head is d3e647b, with all 17 official checks passing: CI run. I inspected the actual native ARM and x86 test logs: core unit/integration totals are 887/537 on ARM and 900/538 on x86, with zero failures. The final local core integration run also passed all 537 cases. Earlier local control/HOME failures remain documented separately in the PR body rather than being folded into these counts.

The ARM investigation has an important boundary: a standalone C program without Sandlock also returned the parent directory during final-link replacement (2 wrong-parent results in 1,000,000 raw openat2 calls on Linux 6.17.0-1022-azure; a subsequent run reproduced it). Source, run. Pinning the original request alone did not fix that native behavior. The final stress fixture makes all relevant in-root parents unreadable too, so its assertion measures confinement against a readable outside sentinel. This PR does not claim to fix kernel object-selection correctness. The deterministic dangling-host-link regression remains, and no regression is ignored.

Expanded ARM confinement result: 80,000 queries passed, and the complete diagnostic release workspace run passed, on isolated branch 843a335: run. Instrumentation and the expanded loop are not part of this PR.

The PR body contains the complete validation history and boundaries. I have left review threads unresolved and have not closed any issue; this is ready for your review of the requested bounded scope.

@congwang-mk congwang-mk 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.

Thanks, the scope matches what I asked for. Two things before this can land:

  1. Chroot+COW waives W_OK for unchanged files (chroot/dispatch.rs:1863). copied_on_write comes from cow.matches(), which is true for every workdir path, not only copied-up ones. So access(ro_lower_file, W_OK) returns 0 under chroot+COW but EACCES under plain COW. Please check for an actual copy-up (as plain COW does) so both modes agree.

  2. needs_read_intercept change affects open too (cow/seccomp.rs:981). Switching from exists() to upper_has() changes handle_cow_open's fast path: dangling upper symlinks are now intercepted, upper symlinks pointing outside the upper dir no longer are. Is that intended? If yes, please call it out in the commit message and add an open test; otherwise move it out of this PR.

Follow-ups, not blocking: rename the test_review274_* tests after the behavior they check, and once #277 lands, rebase onto main and squash the superseded commits.

This branch has not been deployed

No deployments
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.

Sandbox escape by racing seccomp notifications?

2 participants