Skip to content

Ignore setuid from owners the guest cannot mean - #267

Merged
jserv merged 1 commit into
sysprog21:mainfrom
open-sources-port:setuid-exec-uid
Aug 4, 2026
Merged

Ignore setuid from owners the guest cannot mean#267
jserv merged 1 commit into
sysprog21:mainfrom
open-sources-port:setuid-exec-uid

Conversation

@doanbaotrung

@doanbaotrung doanbaotrung commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

execve took the new effective ID straight from a raw host fstat. A sysroot is an ordinary tree owned by whoever unpacked it and no host IDs are mapped into the guest, so a setuid binary left the process at an ID that exists nowhere in the guest -- neither root nor its own. Privilege checks against euid 0 failed, ownership comparisons against guest IDs failed, and the ID granted followed whoever owned the tree.

Read ownership through chown_overlay_apply, the way fs-stat.c reports it, so exec and stat cannot disagree about who owns a file. Honour the bit only for root or the caller's own ID and leave the ID untouched otherwise, matching what Linux does for a setuid binary on a nosuid mount rather than failing the exec.

Root stays reachable because a guest chown is recorded in the overlay, which is how an emulated-root guest marks a helper setuid-root.

Fix #264


Summary by cubic

Prevent exec from switching to unmapped host IDs by ignoring setuid/setgid owners the guest cannot mean and by checking exec permissions against guest-visible ownership for the main binary and any shebang interpreter. Set‑id only applies when physical and guest-visible owners agree and the owner is root or the caller; set‑id scripts and interpreters do not elevate; otherwise euid/egid stay unchanged.

  • Bug Fixes
    • Judge exec permission via chown_overlay_apply for the main binary and any shebang interpreter so exec and stat agree.
    • Grant setuid/setgid only if physical and guest-visible IDs match and are 0 or the caller’s uid/gid; ignore others (nosuid-like). Root is not pulled down by host-owned setuid binaries; interpreter elevation is disabled.
    • Add test-setuid-exec and test-setuid-exec-fakeroot; include in tests/test-matrix.sh and skip on QEMU; update elfuse-aarch64 baseline to 241.

Written for commit 52f882d. Summary will update on new commits.

Review in cubic

@doanbaotrung
doanbaotrung marked this pull request as draft August 3, 2026 15:10
cubic-dev-ai[bot]

This comment was marked as resolved.

@doanbaotrung
doanbaotrung marked this pull request as ready for review August 3, 2026 16:14
cubic-dev-ai[bot]

This comment was marked as resolved.

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

Built the branch and ran the new test both ways. Against pre-PR exec.c all three substantive cases fail (child: euid=501 expected=1000 twice, and the owner-only exec is refused with 127); after the patch all four pass. So the test is a real regression guard, not decoration. It also compiles clean under -Wall -Wextra, and no Makefile change is needed since mk/config.mk:40 picks it up through wildcard tests/*.c.

Other things I checked and found clean: exec_st comes from fstat(exec_fd, ...) on the already-opened image, so there is no TOCTOU between the check and the load; both interpreter sites apply the overlay before checking; chown_overlay_apply takes its own rwlock and neither new call site holds a conflicting one; exec_st is never overlay-applied in place.

Two notes that are not line-anchored.

The overlay is trusted for the permission check but not for the set-id owner, and the argument for distrusting it applies to both. chown_result (src/syscall/fs.c:2741) records on EPERM with no ownership test, so a guest can chown a file it does not own into its own name and gain exec permission the physical mode denies. There is no real capability gain, since the host open gates readability first, but the comment could say in one line that the asymmetry is a deliberate stat-consistency tradeoff rather than an oversight.

The mapping gap in #264 is untouched. src/syscall/fs-stat.c:52 passes st_uid through raw, so a sysroot file the guest genuinely owns still reports 501 while the guest is 1000, and the new overlay-applied check only helps files the guest explicitly chowned. That is a reasonable first step, but worth saying in the PR so #264 is not closed as fully solved.

One test suggestion beyond the inline comments: test-matrix.sh already runs test-credentials in both modes, and a --fakeroot lane for this test would cover the privileged-caller path that is currently unexercised.

Comment thread src/syscall/exec.c
Comment on lines +725 to 733
if ((exec_st.st_mode & S_ISUID) &&
exec_id_is_guest_meaningful((uint32_t) exec_st.st_uid,
proc_get_uid())) {
new_euid = (uint32_t) exec_st.st_uid;
}
if ((exec_st.st_mode & S_ISGID) && (exec_st.st_mode & S_IXGRP)) {
if ((exec_st.st_mode & S_ISGID) && (exec_st.st_mode & S_IXGRP) &&
exec_id_is_guest_meaningful((uint32_t) exec_st.st_gid,
proc_get_gid())) {
new_egid = (uint32_t) exec_st.st_gid;

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.

Three things about this block.

First, the commit message contradicts the code. Its closing paragraph says "Root stays reachable because a guest chown is recorded in the overlay, which is how an emulated-root guest marks a helper setuid-root." The set-id owner is read from the physical stat here, so an overlay-recorded owner of 0 never elevates. The comment at line 548 says exactly that, and test case 3 asserts it. Root is reachable only through a file the host really owns as root. This lands in git history, so worth fixing before merge.

Second, ignoring the bit is fail-open in the drop direction, and the commit message does not mention it. A caller already at euid 0 (--fakeroot, or after a legitimate setuid-root exec) that execs a setuid binary owned by the host user previously dropped to that owner and now keeps euid 0. That is what Linux does for a nosuid mount, so it is defensible, but the message frames the change only as "never an exec failure". Please state the other half. The tempting fix, widening the predicate when the caller is already root, would reintroduce what this PR fixes: a root guest would then take euid 501 from a sysroot file, which is the unmapped-host-uid outcome of #264.

Third, the !exec_is_script gate and the comment above it. The comment claims the current behavior matches bprm_fill_uid, which is half right. Linux ignores set-id on the script, but exec_binprm replaces bprm->file with the interpreter, and the final binfmt's begin_new_exec runs bprm_creds_from_file on that file, so a setuid interpreter named in a #! line does elevate on Linux. Either derive set-id from a physical fstat(exec_fd) taken after the shebang loop, or correct the comment to say the interpreter case is deliberately unsupported. Pre-existing, but this PR rewrites the block and the comment.

Comment thread tests/test-matrix.sh Outdated
Comment thread tests/test-setuid-exec.c Outdated
Comment thread tests/test-setuid-exec.c
Comment on lines +148 to +150
static int caller_is_privileged(void)
{
return geteuid() == 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.

With euid == 0 cases 2 and 3 skip, case 4 becomes trivial, and the test still prints all tests passed and exits 0. The qemu lane runs as genuine root, so it is a vacuous green there.

Either add test-setuid-exec to QEMU_SKIP in test-matrix.sh with a rationale comment (the file is overlay-specific, in the same spirit as the existing test-credentials entry), or count executed checks and refuse to report success when none of the interesting ones ran.

Comment thread tests/test-setuid-exec.c
Comment on lines +163 to +165
if (build_setuid_helper() != 0) {
printf("FAIL (could not build helper: %m)\n");
return 1;

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 path returns without the cleanup unlink, and every failure inside build_setuid_helper after mkstemp leaves the file behind in /tmp. Use goto cleanup.

@doanbaotrung
doanbaotrung force-pushed the setuid-exec-uid branch 2 times, most recently from 246be2e to 3932ee4 Compare August 4, 2026 12:19
@doanbaotrung

Copy link
Copy Markdown
Collaborator Author

Drop-direction fail-open — real, and it's now the assertion the privileged lane makes rather than just prose: under --fakeroot, a setuid file owned by host uid 502 leaves euid at 0. Stated in the message, including why widening the predicate for root callers would reintroduce the unmapped-host-ID outcome.

!exec_is_script comment — corrected at exec.c:715: the script's own set-id bit is ignored as on Linux, and elevating through a set-id interpreter is called out as unsupported rather than accidentally dropped. I did not implement it — deriving set-id from a post-shebang fstat adds an elevation path, which is not this PR's business.

Matrix regex"PASS|0 failed" matched step 1's unconditional PASS (owner=...), so it asserted nothing. Now "all tests passed".

Case 2 was detecting the wrong thing — right: the physical 502 is what the set-id path reads; 4242 is virtual and never reaches it. It no longer skips (the physical owner already stages a foreign owner whether or not the chown records), the message names both owners, and the header says which one does the work.

Vacuous green at euid 0 — took both remedies rather than either. test-setuid-exec and the new test-setuid-exec-fakeroot are in QEMU_SKIP with a rationale, and a checks_run counter makes the run fail if no check exercised an exec. Combined with the --fakeroot lane you suggested, the privileged path is now a real check instead of two skips.

Helper leakbuild_setuid_helper has one cleanup path; every failure after mkstemp unlinks.

Overlay asymmetry — the one-line justification you asked for is in the access comment: a guest can chown a file into its own name and pass a check the physical mode refuses, it gains no capability because the host open gates the physical file first, and the alternative is an execve contradicting the guest's own stat.

Verified both directions : unprivileged fails all three substantive checks (euid=502 expected=1000 twice, exec refused with 127), --fakeroot fails its one (euid=502 expected=0). Post-patch both lanes pass.

On #264 — agreed, and I changed the trailer to Addresses #264 so it doesn't auto-close, since fs-stat.c:52 still passes st_uid through raw and a genuinely guest-owned sysroot file still reports 502. Switch it back to Fix #264 if you'd rather close it and track the mapping separately.

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

Rebase latest main branch and resolve conflicts.

@doanbaotrung
doanbaotrung marked this pull request as draft August 4, 2026 13:31
@doanbaotrung
doanbaotrung marked this pull request as ready for review August 4, 2026 13:35
cubic-dev-ai[bot]

This comment was marked as resolved.

@doanbaotrung
doanbaotrung marked this pull request as draft August 4, 2026 13:53
execve took the new effective ID straight from a raw host fstat. A
sysroot is an ordinary tree owned by whoever unpacked it and no host
IDs are mapped into the guest, so a setuid binary left the process at
an ID that exists nowhere in the guest -- neither root nor its own.
Privilege checks against euid 0 failed, ownership comparisons against
guest IDs failed, and the ID granted followed whoever owned the tree.

Judge access through chown_overlay_apply, the way fs-stat.c reports
ownership, so exec and stat cannot disagree about who owns a file.
Trusting the overlay there is a trade rather than an oversight: any
guest can write it, but the host open still gates the physical file,
so the most it buys is a check the physical mode would have refused.

The set-id owner takes both views and grants only where they agree.
Reading it through the overlay alone would make root self-service,
since an overlay owner of 0 proves nothing about privilege: chown
your own file to 0, set the bit, exec. Reading it from the host
alone would elevate on ownership the guest's own stat denies, since
a physically root-owned file kept elevating after a guest chowned it
away. Agreement grants nothing new -- root still means a file the
host really owns as root -- and lets a guest chown withdraw an
elevation but never conjure one.

Honour the bit only for root or the caller's own ID and leave the ID
untouched otherwise, matching what Linux does for a setuid binary on
a nosuid mount rather than failing the exec. This cuts both ways: a
caller already at euid 0 that execs a setuid file owned by a host ID
now keeps root instead of dropping to an ID the guest cannot mean.
Widening the rule for privileged callers would reintroduce the
unmapped-host-ID outcome this fixes.

tests/test-setuid-exec.c pins both directions. Unprivileged it stages
a foreign owner and a recorded root owner and checks neither moves
the effective ID, and that an owner-only file the guest owns still
runs. Under --fakeroot it checks root is not pulled down to the host
owner. Against the previous exec.c the unprivileged lane fails all
three checks and the fakeroot lane fails its one, and a run refuses
to report success when no check exercised an exec. Both lanes reason
about elfuse-only state, so the qemu lane skips them.

Addresses sysprog21#264
@doanbaotrung
doanbaotrung marked this pull request as ready for review August 4, 2026 14:22
@doanbaotrung

Copy link
Copy Markdown
Collaborator Author

Updated

cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv merged commit c780bf7 into sysprog21:main Aug 4, 2026
10 checks passed
@doanbaotrung
doanbaotrung deleted the setuid-exec-uid branch August 4, 2026 14:44
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.

setuid-on-exec grants the host uid, which is outside the guest's uid namespace

2 participants