Skip to content

Keep rlimits in guest state and enforce them - #435

Open
xalestar wants to merge 17 commits into
sysprog21:mainfrom
xalestar:guest-rlimits
Open

xalestar wants to merge 17 commits into
sysprog21:mainfrom
xalestar:guest-rlimits

Conversation

@xalestar

@xalestar xalestar commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

A guest setrlimit reached host setrlimit for every resource but NOFILE, so a guest limit constrained elfuse itself and nothing was enforced the way Linux enforces it: a finite RLIMIT_CPU stopped the vCPU from making progress until the watchdog fired, RSS and AS shared one macOS slot, and only NOFILE crossed fork. This series keeps all sixteen Linux resources in a guest table with no host call behind it, carries the table through fork and execve, and makes prlimit64 reach another fork-family pid with the kernel's credential check.

The limits are then enforced where Linux enforces them: FSIZE on write, truncate, fallocate, sendfile, copy_file_range and splice, with SIGXFSZ sent to the thread that wrote; CPU from sampled process CPU time; AS and DATA on brk, mmap, mremap and mprotect; STACK by growing the stack on demand, which also sizes what execve accepts as arguments. /proc/self/limits reads the same table.

Each behavior was measured on the reference kernel first (Linux 6.18 in the qemu lane), and the tests assert what it returned, so the same test binaries pass in both lanes.

What a reviewer may want to know before the diff:

  • The fork IPC header carries the table, so the protocol magic moves from ELFQ to ELFR.
  • Each process publishes its table as elfuse-rlim-<hostpid> in the per-user temp directory, which is how prlimit64 reaches another process.
  • The stack is no longer an 8 MiB region mapped whole. The [stack-guard] record is gone and the window between the break and the stack is unmapped at execve, so tests/test-brk-stack.c changes with it.
  • ELFUSE_HOST_RLIMIT_CHECK is a test hook in the binary: with it set, a process exits 86 when a host limit has moved. test-rlimit-unchanged-host runs a guest under it.
  • Signal delivery, rt_sigaction and rt_sigprocmask copy to and from the guest with sig_lock released, in the order Linux uses, because such a copy can grow the stack and so wait for mmap_lock. A bad old-value pointer is then EFAULT with the new action or mask already in place, as in Linux.
  • RLIMIT_AS, RLIMIT_DATA and VmSize leave out [shim] and [shim-data], which a Linux process does not have. ELF segments are tracked at page granularity, as elf_map maps them, and the vDSO page is listed as [vdso], so VmSize equals what /proc/self/maps lists for the guest.

Left out: MEMLOCK, NPROC, CORE and the six resources macOS has no counterpart for (LOCKS through RTTIME) are stored and reported but not enforced. The stack cannot grow past the heap below it, about 110 MiB in the default layout, where Linux has no such bound.

Verified on c7e4572, rebased onto 8612033: make check-format, make check, make check-locks and make verify pass; make test-matrix-elfuse-aarch64 gives 302 passed, 0 failed, 10 skipped; make test-matrix-qemu-aarch64 gives 278 passed, 1 failed, 33 skipped, the failure a test-simd-clone timeout on the reference kernel that passed on three reruns.


Summary by cubic

Keeps all sixteen Linux resource limits as guest state in a single table, so a guest setrlimit no longer constrains the host process: previously every limit but NOFILE hit host setrlimit, and LOCKS through RTTIME returned EINVAL where Linux accepts all sixteen resources. The table is carried through fork and execve, prlimit64 reaches another fork-family pid with the kernel's credential check and EPERM on a mismatch, and /proc/self/limits reads the same table. A host at macOS's MAXSSIZ cap seeds the guest hard stack limit as unlimited, since the cap bounds host threads, not guest memory.

Enforcement

  • FSIZE at write, truncate, fallocate, sendfile, copy_file_range and splice, with SIGXFSZ to the thread that wrote.
  • CPU from sampled process CPU time; AS and DATA on brk, mmap, mremap and mprotect, held to DATA with a soft limit of 0 bounding data by the hard limit the way Linux does.
  • STACK grows on demand, which also sizes what execve accepts as arguments.
  • Each behavior was measured on the reference kernel (Linux 6.18 in the qemu lane) and the tests assert what it returned, so the same binaries pass in both lanes.
  • MEMLOCK, NPROC, CORE, and the six resources macOS has no counterpart for (LOCKS through RTTIME) are stored and reported but not enforced. The stack cannot grow past the heap below it, about 110 MiB in the default layout, where Linux has no such bound.

Note for reviewers

  • Fork IPC protocol magic moves from ELFQ to ELFR; the header now carries the rlimit table.
  • The stack is no longer an 8 MiB region mapped whole: the [stack-guard] record is gone and the break-to-stack gap is unmapped at execve.
  • A host read or write under the stack takes mmap_lock to grow it; callers holding a lower-ranked lock bracket the access with mmap_lock_nowait_begin/end, and make check-locks asserts none is held where a thread waits on mmap_lock.
  • A read-only ftruncate is refused before the FSIZE check; truncate reads permission and size through one descriptor, closing a rename race, and falls back to the stat size when no descriptor can be opened.
  • RLIMIT_AS and DATA refuse a request when the region table is stale; a failed stack growth unmaps the block it mapped and refuses pages a lost mapping still holds, and a fixed mapping above guest_size checks its descriptor before the limits.
  • Execve argument sizing counts the resolved path and drops the shebang argv[0]; the stack-limit test execs itself with no environment.
  • prlimit64 with a thread id of another process is ESRCH, where Linux reaches the process, and a remote table is refused unless the publisher's pid and start time match so a reused pid cannot read a stale one.
  • The DC ZVA emulation now retraps an EL1 abort taken by one of its stores, so the guest takes the fault it would have taken at EL0 instead of the VM halting.
  • ELF segments are tracked from the page holding each p_vaddr and the vDSO page is listed as [vdso], so /proc/self/maps prints page-aligned offsets and a VmSize that equals the guest mappings.
  • ELFUSE_HOST_RLIMIT_CHECK is a test hook that makes a process exit 86 when a host limit has moved, proving guest limits never touch the host.

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

View guided diff

cubic-dev-ai[bot]

This comment was marked as resolved.

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.

Fold similar changes and rework commits.

@xalestar
xalestar force-pushed the guest-rlimits branch 2 times, most recently from 0d06303 to 509647a Compare October 12, 2026 10:29
cubic-dev-ai[bot]

This comment was marked as resolved.

cubic-dev-ai[bot]

This comment was marked as resolved.

Comment thread src/syscall/io.c
* Returns false when no limit applies, true with @room set otherwise, 0 at or
* past the limit.
*/
static bool fsize_room(int host_fd,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The access-mode ordering cubic raised for ftruncate was fixed in 68e134d, but the write family has the same problem. fsize_room() does not check the access mode, so write, pwrite64, writev, pwritev and pwritev2 on an O_RDONLY descriptor at or past the limit return EFBIG and raise SIGXFSZ instead of EBADF. Linux checks FMODE_WRITE before generic_write_checks.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in fe2d66d. fsize_room reads F_GETFL once and applies no limit to a descriptor that is not open for writing, so the host call answers EBADF and no signal is sent. That covers write, pwrite64, writev, pwritev and pwritev2, and the sendfile and splice output side, which go through the same helper. test-rlimit-enforce step 9 now tries write, writev, pwrite and pwritev on an O_RDONLY descriptor at and past the limit; it fails without the change and passes on Linux 6.18.

Comment thread src/syscall/mem.c
return grown;
}

void mem_stack_init_extent(guest_t *g, uint64_t sp)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

With [stack-guard] removed, the window below [stack] is no longer in the region table, and find_free_gap_inner has no stack-gap rule. A non-fixed hinted mmap can therefore land just under the stack, after which the stack cannot grow and the guest gets SIGSEGV. Linux rejects such hints through vm_start_gap(), and main never placed anything there because the stack records covered the whole window.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 09932ef. find_free_gap_inner now ends the usable space 1 MiB in front of a [stack] region, after vm_start_gap, so a hinted or placed mapping cannot land on the next stack pages. A hint further down in the window is still honored, as on Linux, and bounds the stack there. test-rlimit-stack has a case that hints 64 KiB under the stack and then touches that address; it fails without the change and passes on Linux 6.18.

Comment thread src/syscall/io.c
return false;

struct stat st;
if (fstat(host_fd, &st) < 0 || !S_ISREG(st.st_mode))

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.

fsize_room never checks the access mode. With RLIMIT_FSIZE at or below the file position, write(fd, buf, 1) on an O_RDONLY regular file queues SIGXFSZ and returns EFBIG. Linux returns EBADF from vfs_write before it looks at any size, and here the default SIGXFSZ action terminates the guest. truncate_fsize_check already returns early for O_RDONLY. Do the same here (one F_GETFL serves both this and the O_APPEND test).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in fe2d66d as suggested: one F_GETFL serves the access-mode test and the O_APPEND test, and an O_RDONLY descriptor returns early the way truncate_fsize_check does. Step 9 of test-rlimit-enforce covers it.

Comment thread src/syscall/io.c
for (int i = 0; i < *iovcnt; i++)
total += iov[i].iov_len;
uint64_t allowed = total;
int64_t err = fsize_limit_write(host_fd, pos, append_aware, &allowed);

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 every iovec has iov_len == 0, total is 0, but a position at or past the limit still yields room == 0 and SIGXFSZ. sys_pwritev with iovcnt >= 2, and sys_pwritev_append, reach this with no zero-payload short-circuit. Linux returns 0 from vfs_writev before any limit check.

Suggested change
int64_t err = fsize_limit_write(host_fd, pos, append_aware, &allowed);
if (total == 0)
return 0;
int64_t err = fsize_limit_write(host_fd, pos, append_aware, &allowed);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in fe2d66d. The zero-count return is in fsize_limit_write rather than in the vector wrapper, so pwrite64 and the RWF_APPEND path get it too. Step 12 of test-rlimit-enforce runs writev, pwritev and pwritev2 with two empty entries at and past the limit and expects 0 with no signal; it fails without the change.

Comment thread src/syscall/mem.c Outdated
Comment thread src/syscall/mem.c Outdated
replace_start = addr >= g->guest_size ? addr : addr - g->ipa_base;
replace_end = replace_start + length;
}
if (!mem_rlimit_allows(g, length, prot, flags, replace_start, replace_end))

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 runs before the fd and remaining argument checks in sys_mmap. Under an RLIMIT_AS the request exceeds, a bad fd answers ENOMEM where Linux answers EBADF (fget happens before do_mmap). Moving the check to just before the region table is extended keeps the errno order.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in e6b4e0f. The check now runs after the descriptor is opened, and for a fixed request after the address checks and the MAP_FIXED_NOREPLACE overlap test. test-rlimit-enforce asks for a mapping over the limit with fd -1, placed and fixed, and expects EBADF. The Rosetta high-VA fixed path still checks the limit before sys_mmap_high_va looks at the descriptor.

Comment thread src/syscall/proc.c
* stack grows to it when RLIMIT_STACK and its neighbors allow, and the
* fault stays a SIGSEGV when they do not.
*/
if (mat != 0 && mem_stack_grow(g, fault_off))

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 hook covers EL0 faults only. Three shim accesses that store to guest memory at EL1 never come here, and the 8 MiB premapped window used to make that safe:

  • DC ZVA emulation (stp xzr in handle_sysreg_trap) on a not-yet-grown stack page takes an EL1 data abort outside the recover ranges and halts the VM through restore_and_bad.
  • The urandom strb and futex ldtr fast paths answer EFAULT for a valid stack buffer instead of growing.

Either route those aborts to mem_stack_grow and retry, or have the fast paths fall back to HVC #5 when the target is below the stack region.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

DC ZVA: fixed in 09932ef. The shim probes the block with AT S1E0W before the stores and forwards a failed probe on HVC #11; the host takes it as a write data abort at the block address with the fault status from PAR_EL1, so the stack grows and the instruction runs again, or the guest gets SIGSEGV. DC ZVA does not trap on my host, so I checked this with a build that clears SCTLR.DZE: before, an ungrown stack page ends in the crash report; after, it is zeroed, and an unmapped address is SIGSEGV/MAPERR.

urandom: no change. The AT S1E0W probe ahead of the copy already sends an unmapped buffer to the host, which grows the stack; the strb recovery is only for a page revoked between probe and store. test-rlimit-stack now reads from /dev/urandom and calls getrandom into an ungrown page.

futex: the timeout load now forwards to the host instead of answering EFAULT, since copy_from_user grows the stack on Linux. The word load keeps EFAULT: Linux 6.18 answers EFAULT for FUTEX_WAIT on a word under the stack and leaves [stack] where it was.

Comment thread src/syscall/sys.c
* at the limit again.
*/
if (!err && new_lim && resource == LINUX_RLIMIT_CPU)
kill(host_pid, SIGUSR2);

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 liveness checks only run when the map fails. A table file survives a host SIGKILL of the target (the atexit unlink never runs), and the registry can still resolve that guest pid to a host pid after the host child was collected. Once the pid is reused, this maps the stale file, writes into it, and sends raw SIGUSR2 to whatever process now holds the pid; a non-elfuse process dies from the default action. Recheck proc_child_exited and kill(host_pid, 0) after a successful map. Longer term, stamp the file with something that identifies the process incarnation, so a stale table is rejected outright.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in c8b6f6c. proc_child_exited and kill(host_pid, 0) now run on every pass, after a successful map as well, and a mapped table is dropped with ESRCH. With a child whose host process was killed and then collected by waitid(WNOWAIT), prlimit64 wrote the stale table and returned 0 before; it returns ESRCH now. The namespace path was already safe against reuse, because registry_parse_cb drops a record whose start time differs from the live process. No incarnation stamp in this PR: what is left is the window between the liveness check and the kill.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Update: the stamp is in after all, in 609c76e. The table carries its owner's start time and the exchange is refused with ESRCH when the process under the host pid started at another time.

Comment thread src/core/guest.c
Comment on lines +1649 to 1652
if (gva_translate_perm(g, gva, required_perms, &first) < 0 &&
(!mem_stack_grow_for_host((guest_t *) g, gva) ||
gva_translate_perm(g, gva, required_perms, &first) < 0))
return NULL;

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 returns NULL in two cases where the access should succeed.

  • A sibling thread grows the stack over gva between the first gva_translate_perm and taking mmap_lock. Then mem_stack_grow sees off >= r->start, returns false, and the second translation is skipped.
  • mmap_lock_try_acquire gives up after 1 s while another thread holds mmap_lock (a long pread fallback in sys_mmap). Then read() into a valid stack buffer gets EFAULT. Worse, deliver_signal_locked writing a frame below the extent kills the guest instead of running the handler.

Always retry the translation after the grow attempt. Treat lock contention as "wait", not "refuse", at least on paths that hold no lock ranked below mmap_lock. This hook also fires for gdbstub and crash-report reads. Those grow the guest stack (Linux 6.5+ GUP does not expand stacks for remote access) and queue any TLBI from stack_unmap_approach into a non-vCPU thread's cpu_tlbi_req, where nothing drains it.

Suggested change
if (gva_translate_perm(g, gva, required_perms, &first) < 0 &&
(!mem_stack_grow_for_host((guest_t *) g, gva) ||
gva_translate_perm(g, gva, required_perms, &first) < 0))
return NULL;
if (gva_translate_perm(g, gva, required_perms, &first) < 0) {
(void) mem_stack_grow_for_host((guest_t *) g, gva);
if (gva_translate_perm(g, gva, required_perms, &first) < 0)
return NULL;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The sibling-growth case is fixed in 09932ef: mem_stack_grow_for_host tells the caller to translate again whenever the address is in stack range, whoever did the growing.

Contention is changed in 2ba2fb0. The wait is chosen by the caller instead of being a 1 s trylock everywhere. The lock order in internal.h ranks a host access to guest memory with mmap_lock, ahead of sig_lock and the rest. A path that touches guest memory with a lower lock held brackets the access with mmap_lock_nowait_begin / mmap_lock_nowait_end, in the way of pagefault_disable, and only there does stack growth fall back to trylock; every other access waits, so a held lock no longer turns into EFAULT. make check-locks builds with -DELFUSE_LOCK_ASSERT, which counts the locks each thread holds and aborts when a thread waits on mmap_lock, or reaches guest memory outside a scope, with one held. The count is used for checking only. make check passes under that build, and removing the scope in rt_sigprocmask makes test-signal abort there. It is a held-lock count, not a pairwise order validator.

prlimit64 forwarded every limit but NOFILE to host setrlimit, so a guest
RLIMIT_CPU or RLIMIT_FSIZE landed on elfuse itself and RLIMIT_RSS
overwrote RLIMIT_AS, which macOS defines as one resource. The pid
argument was discarded, only NOFILE crossed fork, and RLIMIT_LOCKS
through RLIMIT_RTTIME were EINVAL where Linux accepts all sixteen
resource numbers.

The sixteen limits live in a guest table. Ten are seeded from host
getrlimit at boot; macOS has no counterpart for the other six, so they
start from the Linux defaults: INIT_RLIMITS, and for SIGPENDING the
NPROC value, which is what fork_init assigns. Each process maps its
table from a file named by host pid, so prlimit64 on another
fork-family member changes the limits that member reads, and a pid
outside the family is ESRCH. A thread id of another member is ESRCH
too, where Linux would reach its process, and so is a child that has
exited, answered at once. The table rides the fork IPC header in place
of the two NOFILE fields, which moves the protocol magic to ELFR.
execve keeps the table because it keeps the process.

A site that enforces a limit reads one slot with no lock, so the
pointer to the table is atomic and is switched to the mapped file under
rlimit_lock, which the seeding holds from its first slot until the
table is published.

/proc/self/limits prints the table from one snapshot, so RSS and AS
print separately, nothing printed is a host value, and every row is
from the same instant.

Raising a hard limit takes guest euid 0. Linux also compares the
caller's credentials with the target's; the guest ids of another member
are not visible to the caller, so that comparison is not made.
No other process can read the rlimits of an elfuse process, so a guest
limit that reached host setrlimit is invisible from outside. With
ELFUSE_HOST_RLIMIT_CHECK set, each elfuse process records its host
limits at start and, when one has moved by exit, writes a line to stderr
and exits 86.

test-rlimit-unchanged-host runs test-proc-limits under the check, which
sets every tracked limit in a parent and in fork children. A fork child
publishes its exit status before its atexit handlers run, so the test
reads stderr as well as the exit status.
A guest RLIMIT_FSIZE was stored and never consulted. Each call that can
grow a regular file now does what the reference kernel does, measured
on it:

- write, pwrite64, writev, pwritev and pwritev2 with RWF_APPEND follow
  generic_write_check_limits: a write that starts at or past the soft
  limit fails with EFBIG and SIGXFSZ, and one that crosses it is cut.
  An O_APPEND descriptor is judged at end of file, pwrite included.
- ftruncate, truncate and fallocate follow inode_newsize_ok and refuse
  only to grow a file past the limit. A descriptor not open for writing
  is refused for that first (do_sys_ftruncate, vfs_fallocate). truncate
  checks and truncates through one descriptor, so a rename cannot put a
  different file under the second call.
- copy_file_range checks the whole request first
  (generic_copy_file_checks): EFBIG and SIGXFSZ at or past the limit
  even for a zero length, otherwise the length is cut with no signal.
- sendfile and splice are cut at the limit and also raise SIGXFSZ,
  because the kernel offers the rest of the input to the file's write
  path, which refuses it. No signal is sent when the input ends at the
  limit, or has nothing to give yet: an empty pipe that still has a
  writer waits, or answers EAGAIN when it does not block. The read is
  sized to what the limit leaves, so a pipe keeps the bytes that did
  not fit.

SIGXFSZ is queued for the thread that made the request, not for the
process: Linux sends it with send_sig to current, so it stays pending
on a writer that has it blocked.

The inline write path in syscall_dispatch calls host write directly, so
a write takes the slow path while a limit is set. With no limit each
site costs one load of the soft value.
A thread started when RLIMIT_CPU first becomes finite sleeps in wall
time and samples CLOCK_PROCESS_CPUTIME_ID, which counts a vCPU thread's
guest time as user time. It follows check_process_timers: SIGKILL at
the hard limit, SIGXCPU at the soft limit, and the soft limit then moves
up a second so the signal repeats. A guest with no CPU limit has no such
thread and pays one load per SIGUSR2.

A host CPU-time timer would need no thread, but with
setitimer(ITIMER_PROF) armed hv_vcpu_run makes no guest progress: a
probe that completes 557034 syscalls in 0.5 s completes none, and the
vCPU thread sits in hv_trap. Host RLIMIT_CPU stalls it the same way,
which is what a guest setrlimit(RLIMIT_CPU) did before the limits moved
into guest state.

prlimit64 from another process sends the target SIGUSR2 so its
preemption thread has the limit looked at again.
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv requested a review from Max042004 October 13, 2026 12:55
mmap, mremap and brk refuse a request that would take the mapped total
past RLIMIT_AS, or the private writable total past RLIMIT_DATA, the way
may_expand_vm does. Both totals are summed from the region array under
mmap_lock on each call, so they cannot drift from the table, and a
MAP_FIXED request is not charged for what it maps over. With neither
limit set the check is two loads. A table that has lost entries is
summed as it stands, so a limit is then short by the lost records;
refusing instead would fail every later request of the guest.
The shim and its data are left out of both totals: they sit in the
runtime infra reserve, which a Linux process has no mapping for.

mprotect is held to RLIMIT_DATA too. A private mapping made writable
becomes data, and the check follows mprotect_fixup mapping by mapping:
one fails when may_expand_vm refuses it as data and would have allowed
it as what it was, and the mappings below it in the range have been
changed by then.

Two details of may_expand_vm come with it. A soft limit of 0 bounds
data mappings by the hard limit, the kernel's allowance for Valgrind.
brk is exempt from that allowance, because check_data_rlimit holds the
break to the soft limit itself.

RLIMIT_STACK is stored and not enforced. The stack is a STACK_SIZE
region made at bootstrap and execve that never grows: its extent is
8188 KiB after execve under a soft limit of 1 MiB, 8 MiB and 48 MiB
alike, and 4 MiB of it is usable under the 1 MiB limit.
prlimit64 let any member of a fork family read and set the limits of
any other. Linux compares the caller's real uid and gid with all three
uids and gids of the target and returns EPERM on a mismatch unless the
caller holds CAP_SYS_RESOURCE (check_prlimit_permission).

The target's ids were not visible across processes, which is why the
check was left out. A process now keeps them in its published rlimit
table and republishes them where the shim's identity cache is
republished: after a successful set*id call and after an execve that
changes them. The check reads them under the same file lock as the
limits.

The tests need two sets of ids, which only root can make, so the
matrix reruns test-proc-limits under --fakeroot; the reference lane
already runs it as root.
The guest rlimit table is seeded from host getrlimit, and macOS caps
the hard stack limit at MAXSSIZ, 64 MiB less 16 KiB. A guest therefore
could not raise RLIMIT_STACK past that, where a Linux process starts
with the hard limit unlimited and ulimit -s unlimited is routine.

The cap describes host thread stacks. The guest stack is guest memory
and the limit is guest state, so the cap bounds nothing there. A host
that sits at the cap now seeds the guest hard limit as unlimited; a
host whose user lowered it below the cap still passes the lower value
through.
execve accepted 2 MiB of argument and environment strings whatever the
stack limit was. That is what Linux allows under the default 8 MiB
limit, but the kernel derives it: a quarter of the soft RLIMIT_STACK,
at least ARG_MAX (128 KiB) and at most 6 MiB, with the argv and envp
pointers counted in (bprm_stack_limits). A guest that raised its stack
limit to pass a larger command line got E2BIG here, and one with a
small limit was allowed more than Linux gives it.

sys_execve now computes the same bound, counts the filename and the
pointer arrays against it, and refuses strings the limit itself could
not hold, which is how Linux answers E2BIG under a 64 KiB limit. The
filename is sized as the path that is copied, after /proc/self/exe is
resolved, and a script exec stops counting the argv[0] it drops
(remove_arg_zero). The 6 MiB ceiling still fits the 8 MiB window the
stack is built in.

Measured on the reference kernel at limits of 64 KiB, 1 MiB, 8 MiB,
64 MiB and unlimited; the new test checks three of them, with an empty
environment so the result depends on the arguments alone.

test-exec-limits expects E2BIG at 2.2 MB, which holds only under an
8 MiB limit, and re-executes itself forever when that exec succeeds.
It now sets the limit it assumes: the make that ships with macOS
raises the soft stack limit of its children to 64 MiB.
The stack was a fixed 8 MiB region mapped whole at execve, so
RLIMIT_STACK bounded nothing: 4 MiB of stack was usable under a 1 MiB
limit, and no limit let it pass 8 MiB.

It now behaves as on Linux. execve maps the argument block plus
128 KiB and leaves the rest without page-table entries. An access to a
page under the stack region grows the region down to that page when
the grown size fits RLIMIT_STACK and RLIMIT_AS and keeps 1 MiB clear of
an accessible mapping below (expand_downwards, acct_stack_growth);
otherwise the fault is a SIGSEGV. A lowered limit takes nothing away
and stops the next growth. The region's backing address moves down
with its start, so madvise and mprotect on the grown part act on the
pages they name.

Linux grows the stack for a kernel access too, so read(2) into a stack
buffer the guest has not touched succeeds, and so does writing a
signal frame under the stack pointer. Host code reaches guest memory
with locks held that rank below mmap_lock, so that path takes the lock
with trylock only and gives up after one second.

The window between the break and the stack was premapped, which left
no fault to grow on. It is unmapped at execve, a shrinking break gives
its blocks back, and a break or stack that comes within two blocks of
the other cuts the block mapping between them down to pages. The
break still never enters the 8 MiB window, so a default stack keeps
its room; past the window the stack is bounded by the heap below it,
about 110 MiB in the default layout, where Linux has no such bound.

The stack-guard record is gone, and test-brk-stack, which unmapped it
to show the break stops at an address and not at a record, now checks
that the break leaves the window free and that the stack can grow
into it afterwards.
A host read or write of guest memory that lands under the stack takes
mmap_lock to grow it. It only ever tried for the lock, for up to one
second, because some callers hold a lock ranked below mmap_lock, and
exec_handoff_reset takes sig_lock under it. So a caller that held
nothing still got EFAULT when another thread kept mmap_lock that long.

The caller now says which case it is in. The lock order in internal.h
ranks a guest access with mmap_lock. A caller that touches guest
memory under a lower lock brackets the access with
mmap_lock_nowait_begin and mmap_lock_nowait_end, and only there does
the growth fall back to trying. Every other access waits. The scopes
are in the futex word reads under a bucket lock, timerfd_settime,
getdents64, waitid, the FUSE device read and the usbdevfs entry lock.

Signal code needs no scope, because it copies to and from the guest
with sig_lock released, in the order Linux uses. get_signal copies the
action and applies SA_RESETHAND under siglock, and setup_rt_frame,
with the rseq abort, runs after siglock is dropped; rt_sigaction and
rt_sigprocmask read the new value first and write the old one last.
As in Linux, a bad old-value pointer is then EFAULT with the new
action or mask already in place.

Nothing at run time decides whether to wait. make check-locks builds
with -DELFUSE_LOCK_ASSERT, which force-includes debug/lock-assert.h to
count the mutexes and rwlocks each thread holds, and asserts that none
is held where a thread waits on mmap_lock or reaches guest memory
outside a scope.

Verified: make check and make check-locks pass. With the rseq abort
moved back under sig_lock, test-rseq aborts on the assertion under
make check-locks.
The DC ZVA emulation probes the block with AT S1E0W and then zeroes it
with four stores at EL1. A sibling vCPU can unmap or protect the page
between the probe and a store. That abort was outside the recovery
ranges, so it reached restore_and_bad and halted the VM.

The emulation now keeps the trap's ELR and SPSR in a slot under its
frame, and handle_el1_data_abort_recover takes an abort inside the
stores back to the DC ZVA itself. It traps again, the probe fails, and
the guest gets the fault it would have taken at EL0.

Verified: DC ZVA is not trapped on the development host, so this ran
on a build that clears SCTLR.DZE. A thread issuing DC ZVA on a page
that a sibling flips between PROT_NONE and read-write halted the VM in
3 of 3 runs before, and ran 4 seconds each in 3 of 3 runs after.
With a full region table, unmapping the middle of a mapping drops the
record of its tail while the pages stay mapped. If that mapping sits
in the window under the stack, the neighbor scan in mem_stack_grow no
longer sees it, and growth zeroed the tail and made it stack.

When the table is stale, growth now also asks the page tables, through
guest_range_any_mapped, whether any page of the range it would take is
mapped, and refuses if one is. A mapping with no access has no pages
to find and is still not seen.

Verified: test-rlimit-stack puts a mapping in the window, fills the
table, unmaps all but its first and last page, and expects EFAULT for
getrandom into the page under the last one with that page's contents
kept. It fails without the check and passes on Linux 6.18.
A fixed mapping above guest_size was held to RLIMIT_AS and RLIMIT_DATA
before sys_mmap_high_va looked at its descriptor, so a request with a
bad descriptor that was also over the limit got ENOMEM. Linux answers
EBADF, as the paths below guest_size already do.

Not run: the path needs a Rosetta guest, and no test here reaches it.
prlimit64 on another pid took kill(host_pid, 0) as proof that the
table file belongs to the process under that pid. A process that was
killed leaves its file, and once the pid is reused the call wrote the
stale table, returned 0, and for RLIMIT_CPU sent SIGUSR2 to whatever
holds the pid now.

The table now records the start time of the process that published
it, and the exchange is refused with ESRCH unless the process under
the host pid started at that time. The namespace registry already
tells a reused pid apart the same way.

Not run against a reused pid, which cannot be arranged from a test.
test-proc-limits, whose remote cases need the stamp to match, passes.
The text said make check-locks aborts when a thread waits on mmap_lock
with a lower lock held. The assertion is in mmap_lock_acquire, before
any wait, and it also refuses a caller inside a nowait scope; a guest
access is allowed while mmap_lock is owned. The lock order comment
said nothing decides at run time, though mem_stack_grow_for_host does
follow the scope the caller declared.
A PT_LOAD segment was tracked from p_vaddr to p_vaddr + p_memsz, so
/proc/self/maps printed an unaligned file offset (0x7f880 where Linux
prints 0x7f000) and the region totals behind VmSize, RLIMIT_AS and
RLIMIT_DATA fell short of the pages Linux charges. elf_map maps a
segment from the page holding p_vaddr to the page past its end, with
the offset moved back by the same amount, and a page two segments
share goes to the later mapping. guest_region_add_elf_segment does the
same for both the bootstrap and the execve path.

VmSize also counted [shim] and [shim-data], which RLIMIT_AS leaves out
because a Linux process has neither. It now sums what RLIMIT_AS bounds,
so it equals the guest mappings /proc/self/maps lists: 828 kB for a
static binary where it read 2879 kB.
The vDSO page at VDSO_BASE was mapped in the page tables at bootstrap
and execve but had no region record, so /proc/self/maps had no [vdso]
line where every Linux process has one, and the mmap allocator did not
see the page as taken. It is now tracked as [vdso], r-x, on both paths.

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

1 issue found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/core/bootstrap.c">

<violation number="1" location="src/core/bootstrap.c:128">
P2: Bootstrap adds `[vdso]` after PT_LOAD regions without resolving overlap, so a low-linked image covering `VDSO_BASE` leaves overlapping region records; exec handles the same case in the opposite order. Reserve or reject the fixed vDSO range before registering ELF segments, and keep both paths consistent.</violation>
</file>

Tip: Get a Slack DM when cubic finishes reviewing your PRs. Connect Slack

View guided diff | Re-trigger cubic

Comment thread src/core/bootstrap.c
/* The vDSO page, which Linux lists as [vdso]. Tracking it also keeps a
* hinted mmap from being placed over it.
*/
guest_region_add(g, VDSO_BASE, VDSO_BASE + VDSO_SIZE,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Bootstrap adds [vdso] after PT_LOAD regions without resolving overlap, so a low-linked image covering VDSO_BASE leaves overlapping region records; exec handles the same case in the opposite order. Reserve or reject the fixed vDSO range before registering ELF segments, and keep both paths consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/core/bootstrap.c, line 128:

<comment>Bootstrap adds `[vdso]` after PT_LOAD regions without resolving overlap, so a low-linked image covering `VDSO_BASE` leaves overlapping region records; exec handles the same case in the opposite order. Reserve or reject the fixed vDSO range before registering ELF segments, and keep both paths consistent.</comment>

<file context>
@@ -123,6 +122,13 @@ static void register_runtime_regions(guest_t *g, size_t shim_bin_len)
+    /* The vDSO page, which Linux lists as [vdso]. Tracking it also keeps a
+     * hinted mmap from being placed over it.
+     */
+    guest_region_add(g, VDSO_BASE, VDSO_BASE + VDSO_SIZE,
+                     LINUX_PROT_READ | LINUX_PROT_EXEC, LINUX_MAP_PRIVATE, 0,
+                     "[vdso]");
</file context>

@xalestar xalestar Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The overlap needs a PT_LOAD that covers VDSO_BASE (0xF000), and such an image does not run under elfuse before or after this change: vdso_build writes the vDSO over the image’s text at VDSO_BASE, and if the image starts at 0x0, the unmapped null page also removes its ELF header. I checked with a static binary linked at 0x0. It faults at 0x40 on this branch and on ba4cc0f, while Linux runs it when run as root. Rejecting or reserving the range here would only change how that image fails. The underlying gap is the fixed VDSO_BASE; running low-linked images is tracked in #440.

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.

3 participants