Skip to content

Patch original-stack auxv and restore segment protections - #2

Merged
Samueru-sama merged 2 commits into
pkgforge-dev:mainfrom
Nemo-010:fix-auxv-and-wx
Sep 15, 2026
Merged

Samueru-sama merged 2 commits into
pkgforge-dev:mainfrom
Nemo-010:fix-auxv-and-wx

Conversation

@Nemo-010

@Nemo-010 Nemo-010 commented Sep 15, 2026

Copy link
Copy Markdown

greetings from Port Edwards.

These are the two changes that survived review; the rest were dropped. 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 see the loaded binary's AT_BASE, AT_PHDR, AT_PHNUM, AT_PHENT and AT_ENTRY rather than the loader's. The earlier approach 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.
  • stack: carry the vDSO over as AT_SYSINFO_EHDR, which the earlier list omitted. Without it glibc cannot find the vDSO and falls back to real syscalls for time(), clock_gettime() and gettimeofday(), which a vDSO-only seccomp policy then denies.

Dropped, 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, after setenv, and under a seccomp filter that traps the time syscall.

We aim to provide the software that shapes the world of tomorrow.


Requested by Samueru via errand.
Conversation: https://discord.com/channels/1313385177703256064/1549286930515107880

greetings from Port Edwards.

This supersedes pkgforge-dev#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 pkgforge-dev#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.
@Nemo-010

Nemo-010 commented Sep 15, 2026

Copy link
Copy Markdown
Author

Correction to my earlier claim: this does not let a sharun-built AppImage drop ./AppDir/.patch.cpp, so the shim should stay.

sharun calls userland_execve::exec(interpreter, [interpreter, --library-path, …, program], env), so the executable handed to the loader is the bundled ld.so and the real program is only an argument. In that composition the loader's bin is the interpreter, and the original-[stack] patch makes the stack describe ld.so, not the final program:

loader -> ld.so -> probe
  getauxval / dl_iterate_phdr : the program's phdrs (phnum 15)
  [stack] scan                : ld.so's phdrs      (phnum 12)

gum_read_auxv_from_stack() reads that [stack] auxv, so frida still needs the shim to rewrite it to the final program.

The patch is correct for callers that pass the program as the executable; there the [stack] scan does match the program, which is what I measured earlier and then wrongly generalised to the sharun case.

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

This comment was marked as outdated.

@talaria0101

Copy link
Copy Markdown

Reviewed 228a8b0 (both commits) on x86_64, measuring main and this branch against real execve. Verdict: both changes do what they claim, and I could not construct a regression on any path that already works. Numbers below.

Setup. Probe program walks the original [stack] auxv the way frida-gum's gum_read_auxv_from_stack does (parse start_stack from /proc/self/stat field 28, hop argc/argv/envp, read the AT_ pairs from there) and compares it against getauxval and dl_iterate_phdr inside the loaded program. Targets: glibc dynamic PIE, glibc static-pie, C++ dynamic (libstdc++), musl dynamic, each with and without 64 setenv() calls in the target.

1. Original-stack auxv patch

  • main: the original stack still carries the loader's AT_PHDR/AT_PHNUM/AT_PHENT/AT_BASE/AT_ENTRY, mismatching getauxval in every dynamic run (stack_match=0, probe exits 1). Bug reproduced.
  • branch: original-stack values == getauxval == dl_iterate_phdr in every dynamic run, with and without target-side setenv, exit 0.
  • Also drove exec through a wrapper that calls setenv 64 times in the loader process before exec_with_options: the patch still lands and still matches. That is the exact condition that broke the environ-walking variant in Fix process state leaks and harden memory protections #1, so the start_stack approach is confirmed under it.
  • x86_64 only, measured. The relation this relies on (stat field 28 == initial sp == &argc) is set by generic binfmt_elf code (mm->start_stack is the final sp after create_elf_tables), so it should hold on the other five arches, but I did not run them.

2. mprotect segment restore

  • /proc/self/maps perms for the mapped binary under this branch: r--, r-x, r--, r--, rw- for glibc dyn, C++ and musl dyn. Real execve of the same binaries: identical sequence, including the RELRO split. main for comparison: rw-, rwx, rw-, r--, rw-.
  • The branch does not do RELRO itself; the extra r-- comes from ld.so running afterwards, which works because the mapping now looks like the kernel's. After restore, ld.so/ld-musl only write GOT/data (still RW) and apply RELRO last; probe confirms healthy heap/data/PLT use after all of it.

3. AT_SYSINFO_EHDR carry-over (commit 2)

  • main: getauxval(AT_SYSINFO_EHDR) == 0 in the loaded program, the vDSO is invisible to it.
  • branch: equals the real execve value, and the original-stack copy matches too (it was never wrong there). The commit message's reasoning checks out: vDSO-only seccomp policies, big-endian ppc64 keeps native order through push_usize, and pushing a 0 when the host lacks a vDSO is harmless since glibc treats it as absent.

Regression statement. Every case that runs on main runs on this branch with normalized-identical stdout and exit 0: glibc dyn, C++, musl dyn, each with and without setenv. The one failing case, glibc static-pie, fails identically before and after: SIGSEGV with no output under both main and this branch (rc=139 both) while real execve works. Pre-existing, untouched by this PR, but worth its own issue since it reproduces on main.

Non-blocking notes

  • original_auxv()'s envp walk (while *envp != 0) is unbounded. The kernel guarantees the layout, so it is theoretical, but a scan bound would turn a corrupt-stack scenario into a silent skip instead of a loader segfault.
  • If /proc/self/stat is unreadable the patch silently skips. Graceful, and fine for sharun which always has /proc, just noting the frida fix quietly disappears in such environments.
  • The original stack's AT_EXECFN keeps naming the loader. Outside the five patched tags and harmless for the stated consumers.
  • This branch has no CI runs attached ("no checks reported"), so the six-arch build claim rests on local builds; I verified x86_64 build and behavior only.
  • The PR body predates commit 2, so AT_SYSINFO_EHDR is described only in the commit message. Cosmetic.

@talaria0101

Copy link
Copy Markdown

Follow-up to the review: ran the same three-way comparison on the other five architectures. Same verdict everywhere: this branch matches real execve, main does not, and nothing that worked on main regressed.

Setup. Cross-built the loader from main and from this branch for aarch64, riscv64, powerpc64 (BE), powerpc64le and loongarch64 (bootlin glibc toolchains; zig cc as linker for loongarch64), built the probe per arch, and ran each under qemu-user: qemu-direct as the execve baseline, then main, then this branch. qemu's /proc/self/stat emulation proved faithful on all five (the baseline stack walk works, which is the precondition for the whole original-stack fix). Sysroot glibc is recent (bootlin 2026.08, glibc 2.4x) on every arch.

Probe results, per arch, each with and without 64 setenv() calls:

arch baseline main branch
aarch64 match, rc=0 stack auxv still describes loader (rc=1), no AT_SYSINFO_EHDR match, rc=0
riscv64 match, rc=0 same match, rc=0
powerpc64le match, rc=0 same match, rc=0
loongarch64 match, rc=0 same match, rc=0
powerpc64 (BE) works SIGSEGV SIGSEGV (pre-existing, see below)
  • On every arch where the loader could run the program, branch values equal the baseline exactly: original-stack AT_PHDR/AT_PHNUM/AT_PHENT/AT_BASE/AT_ENTRY == getauxval == dl_iterate_phdr, exit 0, sane heap/data afterwards.
  • AT_SYSINFO_EHDR: wherever the guest has a vDSO (loongarch64, ppc64 BE, plus all x86_64 native tests), main passes 0 and this branch passes exactly the baseline's value. Where qemu provides no vDSO (aarch64/riscv64 on qemu 7.2) both carry 0 and stay consistent with the baseline.
  • Segment protections via /proc/self/maps (qemu hides the initial exe mapping, so these are the loader's own mmaps of the target): main leaves rwx on text and rw- on read-only segments; this branch restores the ELF-declared protections per segment, e.g. loongarch64 main: rw-p rwxp rwxp r--p rw-p vs branch: r--p r-xp r--p rw-p; aarch64/riscv64/ppc64le text goes rwxp -> r-xp.

powerpc64 big-endian does not work on either branch, and it is not this PR. Pre-existing bug, identical SIGSEGV on main and this branch (rc=139 both): on ELFv1 (BE), e_entry is a function descriptor [addr, toc, env] holding link-time addresses; the kernel (and qemu) add the load bias to both words and enter at descriptor[0] with r2 = descriptor[1] + bias (see qemu linux-user elfload.c init_thread, which mirrors the kernel). src/run.rs jumps at the raw entry instead, so BE never starts, with or without this PR. I verified the PR's changes on BE by applying a local test-only kernel-mimicking fix to run.rs/exec.rs on top of each branch (not committed, not pushed): with it, this branch measures perfect on BE (rc=0, stack auxv == getauxval == dl_iterate_phdr, AT_SYSINFO_EHDR carried, maps restored to r-xp r--p rw-p), while main still shows the auxv mismatch and rwx text. So the fixes themselves are sound on BE too, but BE needs that entry-point fix as a separate change, same bucket as the pre-existing static-pie segfault I noted on x86_64.

Build claim: independently reproduced: the branch cross-builds clean for all six targets (aarch64, riscv64gc, powerpc64, powerpc64le, loongarch64 gnu + x86_64 native). Caveat as before: everything here is under qemu-user emulation, not real hardware.

@talaria0101

Copy link
Copy Markdown

Correction to my ppc64 BE note above: "BE does not work on either branch" was wrong for real-world AppImages, as the G5 result shows. What I measured was a flavor artifact, now pinned down properly.

What the ppc64 BE AppImages actually ship. The Galculator build runs in ghcr.io/pkgforge-dev/archlinux on linux/ppc64. That container's glibc is ELFv2 on big-endian: /usr/lib/ld64.so.2, e_flags 0x2 (abiv2), zero .opd sections, entry point in .text. For ELFv2 the kernel does a raw entry jump with r12 = entry (arch/powerpc/kernel/process.c start_thread, "Look ma, no function descriptors!"), which is byte-for-byte what src/run.rs does. So the shipped configuration is exactly the case run() handles, and the G5 result is consistent.

Where my earlier crash came from. I had tested against an ELFv1 glibc (Debian ports / bootlin flavor). There ld64.so.1's e_entry is a .opd function descriptor (verified: entry lands inside .opd, contents [entry_addr, toc, 0]), and for ELFv1 the kernel instead loads nip and r2 FROM the descriptor plus the load bias. The loader's raw jump can't start that flavor, identically on main and this branch. It is a real incompatibility with ELFv1-BE glibc distributions, but not one any of these AppImages exercise, and not something this PR touches.

Re-measurement in the real configuration (qemu 10, ArchPPC container rootfs, sharun-style invocation: loader -> ld64.so.2 -> program, both branches as shipped, no local patches):

  • The full chain runs end to end on both branches; identical exit behavior; sane heap/data.
  • AT_SYSINFO_EHDR end to end: on main the final program reports sysinfo=0 (vDSO invisible); on this branch it equals the kernel's value (0x7fb8...). Commit 2 proven through the intermediate-ld.so composition.
  • Segment protections of the loaded ld64.so.2: main maps it all rwxp; this branch maps r-xp / r--p / rw-p per segment. Commit 1 proven in this flow.
  • One structural note: in the sharun composition the original [stack] auxv ends up describing ld64.so.2 (this branch patches it correctly, phnum=7), while the final program's auxv is rebuilt by ld.so; so a stack-auxv scanner sees ld.so's values, not the final program's, on both branches. No regression; the original-stack patch matters for direct (loader->program) consumers, where it verified correct on every architecture tested.

One honest caveat for the future: if a ppc64 BE ELFv1 glibc (Debian-ports flavor) is ever the target, run() will need the ELFv1 descriptor entry (load nip/toc from e_entry plus load bias, mirroring kernel start_thread) as a pre-existing follow-up. I verified this branch's changes behave correctly on BE under that hypothetical too (locally patched run, full probe pass: rc=0, stack auxv == getauxval == dl_iterate_phdr, AT_SYSINFO_EHDR carried, maps restored).

The rest of the cross-arch table (aarch64, riscv64, powerpc64le, loongarch64) stands unchanged: this branch matches the execve baseline everywhere and main does not.

@Samueru-sama
Samueru-sama merged commit 56afadc into pkgforge-dev:main Sep 15, 2026
Samueru-sama pushed a commit that referenced this pull request Sep 21, 2026
* 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>
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