Fix process state leaks and harden memory protections - #1
mnalmahmud wants to merge 1 commit into
Conversation
- loader: apply mprotect after relocations to enforce W^X on loaded segments. The previous PROT_WRITE was left on all mappings, which the original TODO comments noted as unfixed. - stack: patch the original kernel auxv in make_stack so that tools reading /proc/self/maps [stack] (e.g. frida-gum) see correct AT_BASE, AT_PHDR, AT_PHNUM, AT_PHENT, and AT_ENTRY values for the loaded binary instead of the loader's own values. - exec: close inherited file descriptors (O_CLOEXEC emulation), reset all signal handlers to SIG_DFL and unblock the signal mask, and set the process name via prctl(PR_SET_NAME). - run: self-unmap the loader's file-backed memory regions before jumping to the entry point, using a position-independent assembly trampoline that issues munmap syscalls from an isolated page. Trampolines are provided for all five supported architectures (x86_64, aarch64, riscv64, loongarch64, powerpc64).
|
keep whatever you deem necessary and drop others. The details about the PR were generated with the help of AI. |
|
thanks @mnalmahmud will review later as I just noticed this by accident, I didn't get the PR email because I wasn't watching the repo |
|
Thanks for the thorough write-up. I read the full patch, applied it to a scratch tree, cross-built it for every supported architecture, and ran the loader against real binaries to check each claim. The mprotect work is a genuine improvement and the reasoning about relocation ordering is exactly right. That said, I found four confirmed correctness bugs, and two of the changes conflict with the reason this fork exists. Details and reproductions below. Context that shapes this reviewThis fork is used by Anylinux-sharun. sharun runs dynamic binaries through a bundled dynamic linker. The reason it uses sharun is deployed hardlinked under each application's name, so What I verified works
Blocking: correctness1.
|
| baseline | patched | |
|---|---|---|
/proc/self/comm |
mycoolapp |
mycoolapp |
A pure no-op.
Real binary named differently, for example shared/bin/.mycoolapp-real:
| baseline | patched | |
|---|---|---|
/proc/self/comm |
mycoolapp |
.mycoolapp-real |
This is a regression, and specifically against the motivation given in the PR description. An internal implementation filename now leaks into ps, htop and pkill in place of the name the user invoked. Any layout with a versioned or dot-prefixed real binary hits this.
No upside for the primary consumer, two downsides, and it is the source of the panic in item 4. I would remove it rather than gate it.
6. Self-unmap breaks the /proc/self/exe to /proc/self/maps correspondence (src/run.rs:155-218)
Running a probe binary that reports its own identity, through a loader hardlinked as bin/mycoolapp:
real execve (control) exe=/tmp/probe mappings matching exe = 5
baseline loader exe=/tmp/sh2/b/mycoolapp mappings matching exe = 4
patched loader exe=/tmp/sh2/bin/mycoolapp mappings matching exe = 0 <-- none
To be precise about what does and does not survive:
/proc/self/exestill resolves.mm->exe_fileis a file reference set atexecvetime and is not cleared bymunmap, so the readlink still returns the hardlink path.$ORIGINexpansion, which glibc derives from/proc/self/exe, is unaffected. The core trick this fork exists for survives.- What breaks is the correspondence. After the self-unmap,
/proc/self/exenames a file with zero regions in/proc/self/maps. Realexecvenever produces that state. The common pattern of "readlink/proc/self/exe, then locate that path in/proc/self/mapsto get the main module's load base" now returns nothing. That pattern is used by crash reporters, profilers and module enumerators, which is the same class of tooling this PR aims to help.
There is a tension worth calling out: change 2 adds the auxv patch so that frida-gum can find things by walking /proc/self/maps, and change 6 then removes the executable from /proc/self/maps. I have not run frida against this, so treat that as something to test rather than a confirmed break, but the two changes pull against each other for the same consumer.
I am not claiming the baseline is correct either. It reports 4 matching mappings, but those are the loader's own text rather than the application's, so a consumer doing that lookup gets a wrong-but-present answer today and an empty answer after this patch. The reviewable point is that this PR changes that invariant by default, with no opt-out, and without validation against the bundled applications that motivated the fork.
Please put this behind an ExecOptions flag that defaults to off.
Should fix
7. The trampoline leaves the rtld_fini register dirty
On x86_64 the ABI specifies that %rdx at process entry is the rtld_fini pointer and must be NULL. The trampoline copies rdx into r14 but never clears it, so rdx is now deterministically the new stack pointer, a guaranteed-nonzero garbage function pointer. glibc's _start forwards it to __libc_start_main, which registers it with atexit.
This is pre-existing rather than introduced here, but the new trampoline is the natural place to fix it and instead makes the bad value deterministic. Confirmed by adding xor edx, edx at label 2:, using a static-pie glibc target:
# before
atexit-ran
Segmentation fault (exit=139)
# after
atexit-ran
static-pie main ok argc=1 (exit=0)
The same applies to x0 on aarch64 and a0 on riscv64/loongarch64. Those happen to hold 0 from the last munmap return, but hold the regions pointer when the region list is empty.
8. Loses the rax = 0 guarantee from commit 0ddcb88
The old x86_64 path had inout("rax") 0 => _ specifically to fix a segfault. The trampoline only ends with rax == 0 incidentally, via the last munmap return value. On the jz 2f path (empty region list) rax is whatever run() left behind. Add an explicit xor eax, eax.
9. Trampoline page is mapped RWX (src/run.rs:194)
PROT_READ | PROT_WRITE | PROT_EXEC, then immediately mprotected down to R|X. In a PR about W^X hardening, map it RW, copy, then mprotect to R|X. Costs nothing and avoids SELinux execmem and hardened-allocator denials.
Related: I tested the loader under prctl(PR_SET_MDWE, PR_MDWE_REFUSE_EXEC_GAIN) and it fails at loader.rs:117 with EACCES both before and after this PR, because segments are still mapped prot | PROT_WRITE with PROT_EXEC set. The hardening claim does not extend to MDWE.
10. for sig in 1..64 misses signal 64 (src/exec.rs:44)
SIGRTMAX is 64 on Linux. Use 1..=64.
11. Unmangled global symbols exported from a library crate
$ nm libuserland_execve.rlib | grep tramp
0000000000000032 T tramp_end
0000000000000000 T tramp_start
userland-execve is a library. Two T symbols named tramp_start/tramp_end in the global namespace will collide with any downstream object using those names. Mark them .hidden and prefix them, or use sym operands with local labels.
12. No bounds checks on the trampoline page layout (src/run.rs:202-206)
The trampoline is copied to offset 0 and the regions array to offset 2048, with no assertion that tramp_size <= 2048 or that regions.len() * 16 <= 2048. Both hold today (50 bytes, roughly 5 regions) but corrupt silently if either grows. Two assert!s would cover it.
13. Hardcoded 4096 (src/run.rs:193,210)
The rest of the crate uses sysconf(PAGE_SIZE). It is safe here because the kernel rounds up, but aarch64 with 64K pages makes the constant meaningless. Inconsistent with loader.rs:76.
14. line.ends_with(exe_str) is a fragile match (src/run.rs:162)
Matching the whole maps line by suffix can match an unrelated file whose path ends with the exe path. I checked the deleted-binary case and it does work, since readlink /proc/self/exe also returns the " (deleted)" suffix, so that concern does not apply. Splitting off the pathname field and comparing exactly is still clearer.
15. Gate the remaining behaviour changes behind ExecOptions
ExecOptions already exists as the extension point. Closing descriptors, resetting signal dispositions and unmapping the loader are policy decisions a consumer might want individually. As written, exec() silently changes semantics for every existing caller.
Ordering note: close_fds() and reset_signals() run at exec.rs:82-83, but run() afterwards does file IO and allocation. Resetting SIGCANCEL (32) and SIGSETXID (33) to SIG_DFL breaks glibc internals for the remainder of loader execution. Move these as late as possible.
Minor
loader.rs:67: the// TODO: read only fixcomment was removed, but the base reservation still staysPROT_READ|PROT_WRITEand inter-segment gaps remain writable. Nothing was fixed there.- The PR description has the
ReadDirdrop order backwards. It claims the dirfd is closed after the loop and returnsEBADFharmlessly. The iterator chain is consumed bycollect(), soReadDirdrops at the semicolon onexec.rs:26, before the loop. Harmless either way, but the stated reasoning is inverted. std::primitive::usize(exec.rs:72) is an odd spelling; plainusizeworks.- Segment page sharing.
mprotectatloader.rs:141roundssizeup to a page. If twoPT_LOADsegments share a page (older linkers, no-z separate-code), the later segment's protection wins because the loop follows ascending vaddr order. Probably fine, but worth an explicit comment.
Verdict
Request changes.
Four confirmed defects: a wrong syscall number that makes the ppc64 trampoline a no-op, execve semantics broken for inherited descriptors, out-of-bounds heap access in the auxv walk, and a reproducible panic on non-ASCII binary names. Separately, two changes work against what this fork is for: PR_SET_NAME is a no-op at best and leaks internal filenames at worst, and the self-unmap removes the executable from /proc/self/maps while /proc/self/exe still names it.
* Patch original-stack auxv and restore segment protections greetings from Port Edwards. This supersedes #1, keeping the two changes that survived review and dropping the rest. This loader is load-bearing for sharun, so anything that might regress it is worse than the leak it fixes. Kept: - stack: patch the kernel auxv on the original [stack] so tools that read it there, frida-gum in particular, see the loaded binary's AT_BASE, AT_PHDR, AT_PHNUM, AT_PHENT and AT_ENTRY rather than the loader's. The original patch walked from `environ`, which glibc moves to the heap on setenv, so it could read past a heap block and then silently stop patching. This takes the auxv from `start_stack` in /proc/self/stat, the kernel's own record of the initial stack pointer, which setenv does not move. Checked end to end after 64 setenv() calls. - loader: restore each segment's ELF-declared protection with mprotect once relocations are done, instead of leaving every segment writable and executable for the life of the process. Running it after the relocation loop keeps writes into not-yet-protected segments working. The result matches a real execve's maps, including ld.so's RELRO handling. Dropped from #1, all of which the review flagged: - close_fds() closed every descriptor instead of only FD_CLOEXEC ones, which breaks inherited descriptors (shell redirections, systemd socket activation). - reset_signals() changes dispositions before the loader has finished its own allocation and file IO, which can break glibc internals. - PR_SET_NAME is a no-op under sharun's hardlinked layout and leaks an internal filename into comm otherwise. - the loader self-unmap drops the executable from /proc/self/maps while /proc/self/exe still names it, breaking the correspondence tools rely on, and its ppc64 trampoline used the wrong syscall number. Built for x86_64, aarch64, riscv64, powerpc64 (both endians) and loongarch64, and exercised against dynamic glibc binaries and after setenv. We aim to provide the software that shapes the world of tomorrow. * stack: carry the vDSO over as AT_SYSINFO_EHDR make_stack enumerates the auxv entries the loaded program needs, and the list never included AT_SYSINFO_EHDR. The kernel maps the vDSO in every process and userland-execve leaves it mapped, but without the entry the dynamic linker cannot find it, so glibc falls back to real syscalls for time(), clock_gettime() and gettimeofday(). A seccomp policy that only allows the vDSO path (e.g. Ladybird's) then fails where a normal execve would not. All five supported architectures emit AT_SYSINFO_EHDR from ARCH_DLINFO, including big-endian powerpc64, where VDSO_AUX_ENT is a plain NEW_AUX_ENT. The address is unchanged by userland-execve, so passing getauxval(AT_SYSINFO_EHDR) reproduces execve's own value; it goes through push_usize, so big-endian keeps the native byte order. --------- Co-authored-by: Nemo <328747105+Nemo-010@users.noreply.github.com>
|
@Samueru-sama hey! sorry had a very busy schedule last 2 weeks, what i missed? |
Apparently there is no easy fix for this problem. Some of the improvements were integrated into #2 But fixing it completly so that Frida doesn't need to use the preload hack is no simple fix and has a high chance of regressing existing apps. |
okay, i checked, but for some reason the commit wasn't attached with my account. |
* Patch original-stack auxv and restore segment protections greetings from Port Edwards. This supersedes #1, keeping the two changes that survived review and dropping the rest. This loader is load-bearing for sharun, so anything that might regress it is worse than the leak it fixes. Kept: - stack: patch the kernel auxv on the original [stack] so tools that read it there, frida-gum in particular, see the loaded binary's AT_BASE, AT_PHDR, AT_PHNUM, AT_PHENT and AT_ENTRY rather than the loader's. The original patch walked from `environ`, which glibc moves to the heap on setenv, so it could read past a heap block and then silently stop patching. This takes the auxv from `start_stack` in /proc/self/stat, the kernel's own record of the initial stack pointer, which setenv does not move. Checked end to end after 64 setenv() calls. - loader: restore each segment's ELF-declared protection with mprotect once relocations are done, instead of leaving every segment writable and executable for the life of the process. Running it after the relocation loop keeps writes into not-yet-protected segments working. The result matches a real execve's maps, including ld.so's RELRO handling. Dropped from #1, all of which the review flagged: - close_fds() closed every descriptor instead of only FD_CLOEXEC ones, which breaks inherited descriptors (shell redirections, systemd socket activation). - reset_signals() changes dispositions before the loader has finished its own allocation and file IO, which can break glibc internals. - PR_SET_NAME is a no-op under sharun's hardlinked layout and leaks an internal filename into comm otherwise. - the loader self-unmap drops the executable from /proc/self/maps while /proc/self/exe still names it, breaking the correspondence tools rely on, and its ppc64 trampoline used the wrong syscall number. Built for x86_64, aarch64, riscv64, powerpc64 (both endians) and loongarch64, and exercised against dynamic glibc binaries and after setenv. We aim to provide the software that shapes the world of tomorrow. * stack: carry the vDSO over as AT_SYSINFO_EHDR make_stack enumerates the auxv entries the loaded program needs, and the list never included AT_SYSINFO_EHDR. The kernel maps the vDSO in every process and userland-execve leaves it mapped, but without the entry the dynamic linker cannot find it, so glibc falls back to real syscalls for time(), clock_gettime() and gettimeofday(). A seccomp policy that only allows the vDSO path (e.g. Ladybird's) then fails where a normal execve would not. All five supported architectures emit AT_SYSINFO_EHDR from ARCH_DLINFO, including big-endian powerpc64, where VDSO_AUX_ENT is a plain NEW_AUX_ENT. The address is unchanged by userland-execve, so passing getauxval(AT_SYSINFO_EHDR) reproduces execve's own value; it goes through push_usize, so big-endian keeps the native byte order. --------- Co-authored-by: Nemo <328747105+Nemo-010@users.noreply.github.com> Co-authored-by: Muhtasham Nawr al-Mahmud <muhtaseem2005@gmail.com>
|
Fix process state leaks, harden memory protections, and self-unmap the loader
This PR addresses several issues where
userland-execveleaves behind process state that a real kernelexecvewould clean up. These leaks cause real-world crashes in applications that inspect their own process (e.g. Frida, Chromium's sandbox, debuggers) and leave unnecessary attack surface.Changes
1. Enforce W^X memory protections (
loader.rs)What: After mapping each
PT_LOADsegment withPROT_WRITE(needed to zero.bsspadding), we now callmprotectto restore the segment's intended permissions (e.g. read-only for.rodata, read+exec for.text).Why: The original code left every mapped segment writable and executable for the entire lifetime of the process. This is a
W^Xviolation — any code injection vulnerability in the loaded application could trivially overwrite executable code in-place.Edge case: The
mprotectpass runs after the relocation loop, not inside the segment loop. Relocations (needed for static-PIE musl binaries) may write into read-only segments. If we locked permissions before relocations, the relocation writes would SEGFAULT.2. Patch the original kernel auxv (
stack.rs)What: At the end of
make_stack, we walk backward fromlibc::environto find the original auxiliary vector on the kernel-provided[stack]and overwriteAT_BASE,AT_PHDR,AT_PHNUM,AT_PHENT, andAT_ENTRYwith the loaded binary's values.Why:
userland-execvebuilds a new stack with correct auxv and pivots to it. But the original kernel stack remains mapped and is listed as[stack]in/proc/self/maps. Some tools — notablyfrida-gum— don't usegetauxval()or the new stack. Instead, they parse/proc/self/mapsto find the[stack]region, then read the auxv directly from that memory. Since the original auxv still describes the loader (whereAT_BASEis 0 because the loader has no interpreter), Frida seesAT_BASE=0, concludes there is no dynamic linker, fails to resolve module paths, and crashes with a null assertion.Edge case:
environcan theoretically be NULL (though practically never is on Linux). We guard against that. The patching also usesas usizecasts instead of.try_into().unwrap()sinceAT_*constants arec_ulongwhich is always the same width asusizeon the architectures this crate supports.3. Close leaked file descriptors (
exec.rs)What: Before jumping to the loaded binary, we iterate
/proc/self/fdand close every fd > 2 (preserving stdin/stdout/stderr).Why: A real
execveautomatically closes all file descriptors markedO_CLOEXEC. Since no realexecvehappens here, any fds opened by the Rust runtime, the loader, or the caller leak into the target application. This can cause resource exhaustion or, in security-sensitive applications, leak handles to files the target shouldn't have access to.Edge case: We collect the fd list into a
Vecbefore closing, so the directory fd used byread_diritself doesn't interfere with iteration. TheReadDirhandle's drop will attempt to close an already-closed fd, which harmlessly returnsEBADF.4. Reset signal handlers and mask (
exec.rs)What: We reset all signal handlers (1–63) to
SIG_DFLand clear the signal mask viasigprocmask(SIG_SETMASK, empty).Why: A real
execveresets all caught signal handlers to their defaults and unblocks all signals. If the caller or the Rust runtime installed any custom handlers (e.g. forSIGSEGVbacktraces) or masked any signals, the loaded application inherits that dirty state. This can cause the application to silently ignore signals it expects to receive, or fail to generate core dumps on crashes.Edge case:
SIGKILLandSIGSTOPcannot have their handlers changed;sigactionreturns an error for these, which we intentionally ignore.5. Set process name (
exec.rs)What: We call
prctl(PR_SET_NAME)with the loaded binary's filename (truncated to 15 chars per kernel limit).Why: A real
execveupdates/proc/self/commto the new binary's name. Without this, the process shows up asuserland-execve(orsharun) inps,top,htop, and system monitors, which is confusing for users and breaks process management scripts that match by name.6. Self-unmap the loader from memory (
run.rs)What: Before jumping to the entry point, we parse
/proc/self/mapsto find all file-backed memory regions belonging to the loader binary, allocate a single isolated executable page, copy a position-independent assembly trampoline to it,mprotectit tor-x(removing the temporary write permission), and jump to the trampoline. The trampoline loops through the regions issuing rawmunmapsyscalls, then pivots the stack pointer and jumps to the loaded binary's entry point.Why: A real
execvecompletely replaces the address space. Without self-unmapping, the loader's.text,.data, and.rodatasegments remain permanently mapped in the target process. This wastes memory and, more importantly, pollutes/proc/self/maps— tools that walk the memory map (debuggers, profilers, crash reporters) will see unexpected regions belonging to a binary that isn't supposed to be running.Why a trampoline: You cannot
munmapcode you're currently executing — the CPU would immediately page-fault on the next instruction fetch. The trampoline lives on a separate page that is not part of the loader, so it can safely unmap everything underneath the loader and then jump away.Architecture support: Trampolines are provided for all five architectures the crate supports:
munmapsyscall nrrax=nr,rdi=addr,rsi=lenx8=nr,x0=addr,x1=lena7=nr,a0=addr,a1=lena7=nr,a0=addr,a1=lenr0=nr,r3=addr,r4=lenEdge case: The trampoline page itself (4KB) remains mapped after the jump — this is unavoidable since the CPU is executing from it at the moment of the final
jmp/br/jr. Anonymous heap allocations from the Rust runtime also remain, since we cannot distinguish them from the target binary's own anonymous mappings in/proc/self/maps. Both are benign.Testing
Built a C test payload that verifies all six fixes from inside the loaded process: