Files
one_gadget/aletheia/spec/satisfier_spec.rb
T
david942jandClaude Opus 4.8 b45c1786ba Project Aletheia - All gadgets are real (#399)
* Add Aletheia: empirical one_gadget verification harness

Verifies aarch64 gadgets by running them on real libc under gdb and
checking a live shell runs 'ls /'. Dev-only; excluded from the gem.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Aletheia findings: mark Finding 1 fixed, add Finding 2 (__sigaction act)

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Aletheia findings: note sigprocmask fix generalized across all arches

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Aletheia findings: Finding 2 (__sigaction act) fixed via deferred check

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Aletheia findings: fix landed as PR #323

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Aletheia: level-1 sweep — verify every gadget, layered L0/L2 oracle

Add --level so the harness checks every gadget one_gadget finds, not just
the top-scoring few. Extend the satisfier to handle branch relations
(reg==imm, !=, comparisons, &-mask), the argv/envp builder (array-literal
and pointer forms, with the -c command pointed at 'ls /'), and single
deref == NULL, with cost-ordered branch retry so interdependent
disjunctions pick a consistent branch; parse failures SKIP rather than crash.

Add a layered oracle: L0 confirms execve("/bin/sh") with a valid argv/envp
(syscall entry for execve, /proc/pid/exe for posix_spawn's vforked child),
L2 drives 'ls /'. New EXEC outcome = reached a shell but not L2-drivable,
so working-but-hard-to-drive gadgets aren't mislabelled FAIL. Stream results
and kill wedged gdb process groups so a sweep can't hang or lose output.

Full aarch64 level-1 sweep: 47/47 gadgets, zero FAILs (29 PASS, 18 EXEC).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Aletheia: promote EXEC gadgets, fix posix_spawn pty race + exec detection

Hold the posix_spawn parent stopped (detach-on-fork off) so it can't race
the child on the shared pty, and detect the exec deterministically with a
"catch exec" (the child may run a fixed -c command and exit before a
/proc/pid/exe check). Together these turn most posix_spawn EXEC results
into real L2 PASSes.

Add EXEC promotion: retry an EXEC gadget with uncontrolled registers nulled.
When the code writes an uncontrolled register as argv[1], the benign fill
makes it a garbage string sh treats as a script; nulling it terminates argv
so the shell is interactive and runs 'ls /'. A promoted gadget (EXEC*) is
genuinely usable given that attacker-controlled input.

aarch64 level-1 sweep: 47 gadgets, 0 FAIL -- 42 PASS, 3 EXEC* (promotable),
2 EXEC (sh -c "--", a fixed libc command that can't be driven).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Aletheia: drive `sh -c -- <cmd>` gadgets, all level-1 gadgets now usable

The do_system/posix_spawn argv can be {"sh","-c",<libc "--">,x21,...}. The
"--" only ends sh's option parsing, so the first controllable operand after
it (x21) is the actual command. Point the first register operand after -c
(skipping the "--"/global separator) at "ls /" instead of the immediate
next element. `sh -c -- "ls /"` then runs it, so the two gadgets that
previously ran a fixed `sh -c "--"` now PASS directly.

aarch64 level-1 sweep: 47 gadgets, all usable -- 44 PASS, 3 EXEC*
(promotable), 0 EXEC, 0 FAIL.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: cross-arch verification via qemu-user (amd64)

Separate transport (native gdb vs qemu-user gdbstub) from the arch/ABI
model, make park_stub self-describing (pre-allocated scratch + prints its
own libc base), and parameterize driver.py off the plan's arch block. The
Runner auto-detects the backend from the target ELF, so an amd64 libc on
an aarch64 host runs under qemu-user with no flag.

Satisfier gains stack-alignment (`rsp & 0xf == 0`) and arch-level register
normalization (amd64 `eax->rax`, `r8d->r8`). posix_spawn under qemu stays
with the parent and lets the forked child run free on the guest tty.

Validated on this aarch64 host: straight-line execve and posix_spawn both
PASS L2, a redirected-offset negative control FAILs, native aarch64 stays
green; libc-2.23 level-1 sweep 10/12 PASS, 0 FAIL.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: set XMM plan registers via 64-bit lanes

gdb-multiarch rejects `set $xmm0 = <int>` ("Invalid cast" -- an XMM register is a
vector union, not a scalar), so the driver crashed mid-setup for any plan pinning
an XMM lane (the satisfier's `(u64)xmm* == NULL` branch), and the gadget never ran.
Write the lanes directly instead. Fixes 5 spurious amd64 libc-2.31 FAILs
(0x51e45, 0x51e55, 0x8418c, 0x84192, 0x84199); that sweep is now 35 PASS, 1 SKIP.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: recognise a writable scratch slot already reads NULL

The satisfier processed `writable: rbp-0x78` (pinning rbp to a scratch write-area)
and then `[rbp-0x78] == NULL` as separate assignments, tried to re-point rbp to a
different scratch region, and skipped on the false conflict -- even though the
first assignment already lands [rbp-0x78] in zero-filled scratch (== NULL).

Teach `satisfied?` that a `== NULL`/`<= 0` deref off a scratch-pointing register
already holds when its target is in the zeroed region (clear of the seeded L2
command). Fixes the last amd64 libc-2.31 SKIP (0xe3d88); that sweep is now
36 PASS / 0 SKIP, native aarch64 unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: add i386 backend (qemu-user)

Arch::I386 (eax..ebp, esp/eip, qemu-i386, /usr/i686-linux-gnu sysroot, i386
syscall ABI for L0: eax/execve=11, ebx/ecx/edx). Satisfier gains i386's
"<reg> is the GOT address of libc" (base + PLTGOT offset, resolved by the
driver against the load base) and a dereferenced "[X] == 0" (== [X] == NULL).
build_stubs.sh cross-builds park_stub_i386 via i686-linux-gnu-gcc.

Verified end to end on a version-matched fixture (Debian glibc 2.43 i386):
6/6 level-1 gadgets PASS. Older i386 fixtures need a matching-version ld.so
(their init crashes under the 2.43 loader -- i386 is stricter than amd64 here).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: per-version sysroots for older foreign libcs

An older i386 libc can't be dlopened under the default cross sysroot: its init
crashes under the newer ld.so, and a stub built with the modern toolchain needs
symbol versions the old libc lacks. build_sysroot.sh fetches the fixture's
matching libc6 + libc6-dev .debs (ubuntu), extracts a sysroot with the matching
ld.so, and cross-builds park_stub against that libc (older symbols only). The
transport auto-uses sysroots/<arch>-<major.minor>/ when present (matched on the
fixture's glibc version), with ALETHEIA_LD_PREFIX / ALETHEIA_STUB manual overrides.

All four older i386 fixtures (2.19, 2.23, 2.26, 2.27) now verify end to end.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: document per-version sysroots for older foreign libcs

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: build per-version sysroots on demand

The transport now rebuilds a missing sysroots/<arch>-<ver>/ when it needs one,
from the fixture's own Ubuntu package version -- gated on Arch#version_strict?
(i386 yes, amd64/aarch64 no) so amd64/aarch64 never trigger a needless build.
So the git-ignored sysroots/ can be deleted anytime to reclaim disk; the next
verify of an older i386 fixture rebuilds just what it needs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: fix sysroot gating + hex deref-zero

- Only build a per-version sysroot when the fixture's glibc differs from the
  default cross sysroot's version (its ld_prefix libc); a toolchain-matched
  fixture (e.g. Ubuntu 2.43 under the 2.43 cross sysroot) uses the default, and
  a half-built sysroot (no park_stub) is no longer accepted.
- Recognise "[X] == 0x0" (hex zero) as a dereferenced-zero, like "[X] == 0".

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* aletheia: add arm (armhf/Thumb) backend

Adds Arch::Arm, cross-built park_stub_arm, and armhf per-version
sysroot support. execve gadgets verify end-to-end; posix_spawn L2
under investigation.

* aletheia: arm self-injection transport + emulator-syscall SKIP

arm runs via stub self-injection (no gdbstub): the stub applies the
plan and jumps to the gadget itself, keeping the run under plain
qemu-user. This fixes arm execve verification and was the diagnostic
that isolated the real posix_spawn blocker -- qemu-arm doesn't
implement clone3 (EABI 435), which glibc 2.43 posix_spawn forks with,
so no child ever spawns.

The oracle now reads qemu's -strace log and reports SKIP (harness
limitation) instead of FAIL when the emulator hits an unimplemented
syscall. arm: execve PASS, posix_spawn SKIP/blocked; amd64/i386/aarch64
unchanged.

* aletheia: temporary clone3->clone hack so qemu-arm can fork posix_spawn

qemu-arm doesn't implement clone3 (EABI 435), which glibc 2.43
posix_spawn forks with, so posix_spawn gadgets couldn't spawn a shell.
The stub redirects the libc __clone3 wrapper to a shim that translates
struct clone_args into the legacy clone (syscall 120) via glibc __clone.
Env-gated (ALETHEIA_CLONE3_HACK) and signature-guarded; wired into the
arm SelfInject transport. arm 0x3affc posix_spawn now verifies L2.

Remove once qemu-arm gains 32-bit clone3.

* aletheia: Finding 3 - arm missing GOT-base constraint (environ gadgets)

arm PIC gadgets that reach environ via the GOT (posix_spawn(...,environ))
require a GOT-base register = $base+got, but one_gadget's arm fetcher
seeds that register to resolve environ without emitting the constraint
(unlike i386's 'reg is the GOT address of libc'). Verified: 0x3afdc (r6)
and 0x543d2 (r7) both PASS once the register is pinned.

* aletheia: host-arch-independent transport selection

Pick Native vs Qemu/SelfInject from the host arch (uname -m vs each
backend's native_on?) instead of a per-arch flag, so every backend runs
natively on its own host and under qemu-user on any other. Give aarch64
a qemu config (qemu-aarch64) so it verifies under qemu on a non-aarch64
host; a native arch forced under qemu resolves QEMU_LD_PREFIX to the
host root. ALETHEIA_FORCE_QEMU exercises the qemu path for a native arch.

aarch64 2.43: 8/8 PASS both native and forced-qemu; amd64/i386/arm
routing unchanged.

* aletheia: per-version sysroots for aarch64 under qemu

Enables older aarch64 fixtures (that SIGILL under the host loader) to be
verified via ALETHEIA_FORCE_QEMU with a matched, on-demand sysroot:
- aarch64 is version_strict; build_sysroot.sh grows an aarch64 (arm64) case
- sysroot building is gated on the qemu transport (native runs load directly)
- default_libc_version reads the actual runtime prefix (host root vs cross sysroot)
- build_sysroot mirrors runtime libs under /usr/lib/<triplet> so a native arch's
  ld.so under qemu can't fall through to the host's newer libc

aarch64-2.27 (unloadable natively) now verifies: 12 PASS / 4 EXEC. Native
2.43 and foreign i386/arm unchanged.

* aletheia: Finding 4 - aarch64 execvpe frame-argv hides argv[1] constraint

aarch64-2.27 execvpe gadgets (0xa3234 family) build argv in the x29
frame: stp x1,x0,[x29,#64] => {"/bin/sh", x0, NULL}. argv[1] is the
incoming x0; the benign fill makes it a non-NULL empty string so sh
exits (EXEC, not promotable). Pinning x0=NULL yields {"/bin/sh",NULL}
and the gadget PASSes. one_gadget emits only the opaque
'x29+0x40 is a valid argv' (it tracks sp-relative stacks, not the frame
pointer), losing the x0==NULL requirement. Documented; fix needs
frame-relative argv tracking.

* aletheia: mark Findings 1, 3, 4 as fixed (docs were stale)

Findings 3 (arm GOT-base, #331) and 4 (aarch64 frame-argv, #333) had
merged fixes but still read 'not yet fixed'/'candidate'. Finding 1's
own 'Suggested fix' subsection also still said 'not yet applied' despite
the top-line Status already saying fixed (PR #323). Updated all three
to reflect the actual, verified, merged state.

* aletheia: fix three harness reliability bugs found via the aarch64-2.23 qemu discrepancy

Chasing a flaky execl gadget under forced-qemu aarch64 (PASS once,
then FAIL consistently) turned up three compounding harness bugs, not
a qemu or one_gadget issue:

1. catch exec corrupted qemu-aarch64 execution mid-function (SIGSEGV
   inside execl/execve). Only useful natively (the qemu transport
   never follows a posix_spawn child, so the catchpoint can't
   usefully fire there); now armed only when connect == "run".

2. Scratch headroom below sp (SP_OFFSET=0x2000) was too tight for a
   gadget landing inside a function whose own prologue allocates a
   large frame (aarch64 execl's ~8.4KB sub sp,sp,...). Silently
   absorbed by an adjacent mapping under native ASLR, faulted
   reliably (and ASLR-flakily) under qemu. Widened SCRATCH_SIZE/
   SP_OFFSET/WRITABLE_BASE (park_stub.c and discover.py's mmap kept
   in sync).

3. Per-version sysroot stubs went stale silently: stub_built? only
   checked existence, so editing park_stub.c (as in fix 2) left
   cached sysroot stubs running the OLD memory layout while the
   satisfier computed offsets against the NEW one -- a silent
   mismatch producing the exact flaky SIGSEGVs that motivated this
   investigation. Now rebuilds when park_stub.c is newer than the
   cached binary.

Verified clean after all three: native aarch64 8/8; forced-qemu
aarch64 2.23 9 PASS/2 EXEC (was flaky, now reliable across repeated
runs), 2.24 8/8, 2.27 13/13, 2.43 27 PASS/1 EXEC; amd64/i386 2.43
4/4 and 3/3; i386 2.19 (fresh per-version sysroot) 9/9; arm execve +
clone3-hack posix_spawn both PASS. Documented in docs/DESIGN.md.

* aletheia: satisfier accepts hex-zero constraint forms (== 0x0, <= 0x0)

one_gadget#334 (pointers use NULL, values use hex 0x0) changed how
zero-value comparisons render, but the satisfier's evaluate/satisfied?/
apply still only matched a bare '<= 0'. Any constraint using that form
(e.g. arm posix_spawn's file_actions check, (s32)[..] <= 0x0) silently
stopped being recognized as the easy zero-branch it always was and fell
through to the unsupported/relational path -- a satisfier regression
this surfaced as arm 0x543d2 flipping from PASS to SKIP.

Introduced a shared ZERO pattern (0 or 0x0+) and used it everywhere a
literal zero suffix was matched, including deref_zero's already-correct
but now-duplicated regex. Verified clean across every arch: aarch64
native 27 PASS/1 EXEC* (unchanged), amd64 11/11, i386 6/6, arm 0x543d2
back to PASS.

* aletheia: satisfier builds chained-dereference (deep-null) scratch state

Closes the 'harness needs a deeper argv/envp/arg0 builder' gap flagged
under Finding 3: a posix_spawn argv/envp passed as a pointer-to-pointer
(e.g. argv=[sp], needing [[sp]]==NULL i.e. argv[0]=NULL) isn't free like
a single sp-relative deref -- [sp] itself is zero-filled, so dereferencing
it AGAIN reads through NULL and faults. null_cost/apply's stack_reg? fast
path wrongly treated ANY deref depth as free; this was silently masked by
Finding 3's investigation using a hand-crafted plan, not the real
satisfier, until the full arm sweep exposed the actual FAILs.

Adds a Plan#mem field (scratch-relative pointer writes) and
Satisfier#apply_deep_null: builds a real pointer chain -- one write per
extra indirection level -- terminating in the always-zero STRING_POOL,
for both sp-relative and settable-register operands. Costed above a
single deref so it's only chosen when no cheaper branch exists (verified:
zero behavior change on amd64/i386/aarch64, where a cheaper branch always
already existed).

Wired through both transports: driver.py writes plan['mem'] via
write_memory (word_size added to each arch's driver_model for the 4 vs
8-byte write); park_stub.c's self-injection gets a new 'mem <off> <val>'
directive (arm, 4-byte). Validated the driver.py path directly by forcing
the deep-null branch on a real aarch64 gadget (PASS, full L2) before
tackling arm's harder C-stub path.

Result: arm libc-2.43's remaining 6 FAILs (0x3b000/2/4, 0xa42c8/4334/4338)
all PASS -- 10/10 gadgets, reliable across repeated runs. Full regression
clean: aarch64 native + qemu (all 4 fixture versions), amd64 11/11, i386
6/6, all unchanged.

* aletheia: cross-arch strict sweep + Finding 5 (i386 envp-via-stack)

First strict sweep across all four arches (previously only run on
aarch64 2.24/2.23). Near-clean: 62/64 level-1 gadgets pass -- amd64
11/11, i386-2.19 9/9, arm 10/10, aarch64 27 PASS + 1 known-promotable
EXEC.

Finding 5 (i386 2.43, 0xed234, not yet fixed): the gadget's first
instruction stores the uncontrolled register ecx into a stack slot a
later envp constraint requires to read NULL. check_envp only inspects
tracked stack content when the envp pointer itself is a bare stack
address (deref_count==0); here envp is [ebp-0x30] (deref_count==1, a
value READ from the stack), so it falls through to the generic opaque
fallback instead of surfacing that ecx==NULL is the real precondition.
Verified: ecx=0 makes the gadget PASS with full L2.

* aletheia: Finding 6 - untracked non-sp/bp register write hides argv content

The 3 known EXEC* (promotable) gadgets on aarch64 (0xc7a40 libc-2.43;
0x9b9e4, 0x9bc5c libc-2.23) all share one mechanism: the argv pointer
register (x4/x1/x0) is set outside the analyzed candidate window, so
within the window it's an opaque "incoming register" the emulator
can't resolve. A later stp writes an uncontrolled sibling register
(x0 for 0xc7a40) into argv[1] through it, but write-tracking is keyed
to exactly two special-cased registers (sp, and since Finding 4 a
named frame pointer) -- any other register's write falls into the
generic writable-only path, discarding what was actually stored.

Verified precisely: for 0xc7a40, setting ONLY x0=NULL (nothing else)
flips the result from EXEC to PASS with full L2.

Different severity than Findings 1/3/5 (true false positives -- a
fully satisfied plan still crashed): here the gadget genuinely does
reach a shell under every listed constraint; it just isn't reliably
interactive, and Aletheia already flags that honestly as EXEC* rather
than PASS. Documented with a fix plan (generalizing write-tracking to
arbitrary registers, which needs a new invalidation mechanism sp/bp
never required) but not implemented -- lower urgency and larger,
cross-arch scope than Findings 1-5, flagged for review before any
implementation.

* aletheia: satisfy compound "writable: [base]+imm" constraints

The operand parser only handled a dereference as a matched [..] pair
wrapping the whole string, so "writable: [rbp-0x78]+0x10" (offset from
a *dereferenced* value, e.g. a store through a pointer read off the
stack) failed to parse at all -- SKIPping the whole gadget (0xe5887 on
libc-2.27) rather than verifying it.

Fix: defer a compound writable constraint until its base's own
argv/envp constraint has run (order_compound_writable_last), and block
that constraint's NULL branch for the same base (@null_unsafe_bases):
with base == NULL, "[base]+imm" is a fixed low address -- a real
unmapped-page write, not a merely-untracked one -- so the cheaper NULL
branch must not be picked here. Once base resolves to a scratch
pointer, "[base]+imm" is satisfied for free (STRING_POOL/WRITABLE_STRIDE
comfortably cover any observed imm).

This also needed pointer_form/apply_pointer to handle a deref-1 "X is
a valid argv/envp" (write the scratch pointer into X's memory, not a
register) -- previously only a bare register (deref 0) was handled.

0xe5887 now empirically PASSes (was SKIP). No regressions across
aarch64 (2.23/2.24/2.43), amd64 (2.24/2.27/2.31/2.43), and i386
(2.26/2.27/2.43) -- amd64-2.19/2.26's pre-existing broken-harness FAILs
are unaffected (confirmed identical without this change).

* aletheia: fix native transport dlopen crash on a version-mismatched libc

Native aarch64-libc-2.27 crashed with SIGILL inside the HOST's own
ld.so (glibc 2.43) while calling the fixture's init: dlopen-ing a full
libc.so.6 as a secondary shared object binds glibc-private,
ld.so-internal symbols (_dl_exception_create, __tunable_get_val, ...)
that aren't ABI-stable across a 16-version gap. Confirmed with a plain
ctypes dlopen, no gdb/Aletheia involved.

The version-matched per-version sysroot (sysroots/<arch>-<ver>/) this
project already builds for cross-arch qemu runs is the fix -- it just
wasn't applied to native runs (the sysroot's own park_stub kept a
generic interpreter path that only qemu-user's QEMU_LD_PREFIX
redirects; a real execve has no such redirection, so it silently fell
through to the host's ld.so regardless). Two changes:

* transport.rb: `sysroot`'s need no longer gates on qemu_transport? --
  a native run is just as version-sensitive as qemu-user.
* build_sysroot.sh now also builds park_stub_native, a second copy
  whose interpreter is the sysroot's own ld.so (--dynamic-linker) with
  a matching runtime rpath, so the kernel loads it directly -- no
  hand-invoked interpreter, so GDB still treats it as a normal primary
  executable with immediately-resolvable symbols (no pending-breakpoint
  dance). Transport::Native picks it whenever sysroot is needed.

driver.py: `set breakpoint pending on` (defensive, for the above) and
`set sysroot` on a remote (qemu-user gdbstub) connection -- gdb's own
hint that reading a remote target's symbols over the wire instead of
from the local sysroot copy is slow; didn't fix the separate
amd64-2.19/2.26 crash it was aimed at, but is a genuine improvement
with no observed regression, so kept.

Aletheia: aarch64-2.27 is now 13/13 PASS (was 0/13), reproduced with a
fully wiped sysroots/ cache. No regression on aarch64-2.23/2.24/2.43,
amd64-2.43, or i386-2.27 (all re-swept after this change).

* aletheia: satisfy dereferenced equality to a nonzero literal

evaluate/satisfied?/apply's final fallback only handled a dereferenced
value required to be zero (deref_zero -> null_cost/apply_deref_null)
or a bare-register relation (parse_relation, LHS must be \w+, so a
bracketed "[base+imm]" never matches). "[base] == <nonzero>" fell
through both and SKIPped: libc-2.23's 0xcc610 reads a mode flag off
the stack (`mov esi, [rbp-0x50]`, never written within the window)
that a later branch requires to be exactly 1, rendering as
"[rbp-0x50] == 0x1" -- not covered by either path.

Added deref_literal (a single-deref operand plus its target integer,
tried after deref_zero so the zero case is untouched) and its
evaluate/satisfied?/apply legs. Unlike zero, a nonzero target has no
pre-existing zeroed region to point at, so apply_literal must actually
write it -- and since the base (rbp here) isn't constrained by
anything else in the gadget, it also has to pin the register to a
fresh scratch slot first (mirroring apply_deep_null's settable-register
branch), then set_mem the literal there.

0xcc610 now empirically PASSes (was SKIP). No regressions: amd64-2.23
is otherwise byte-for-byte identical to before (same single pre-existing
FAIL at 0xcc52f, unrelated), and aarch64-2.43/amd64-2.43/i386-2.27
re-swept clean (27/27, 11/11, 14/14).

* aletheia: fix arm-2.39 sysroot build + dlopen of a modern libc

Two bugs stacked to make every arm-2.39 gadget FAIL:

1. build_sysroot.sh assumed a split-usr .deb layout (libs in /lib/$triplet).
   glibc 2.39's Ubuntu package is merged-usr -- libs live only in
   /usr/lib/$triplet, no /lib/$triplet -- so the `ln .. $root/lib/$ldso`
   step hit "No such file or directory", `set -e` aborted, and park_stub
   never built. The transport then silently fell back to the generic
   pre-built park_stub_arm (linked against a DIFFERENT libc version).
   Fix: detect the layout and point /lib at usr/lib (as a real merged-usr
   root does) when /lib/$triplet is absent.

2. Even with the right stub, dlopen(target) failed: glibc >= 2.35's
   libc.so.6 imports an ld.so-private symbol (__nptl_change_stack_perm@
   GLIBC_PRIVATE) that only resolves for the PRIMARY libc, never a dlopen'd
   secondary. But a version-matched sysroot links the stub against a libc
   byte-identical to the target, so the target is already mapped as our
   primary libc. Fix: on dlopen failure, if the target file is identical to
   the loaded libc.so.6, use that mapping's base instead of erroring.
   (Older arm libcs lacked the import, so their dlopen happened to work --
   which is why 2.23/2.27 passed and only 2.39 broke.)

arm-2.39 execve gadget 0x88a48 now PASSes (was FAIL). The 6 posix_spawn
gadgets go FAIL -> SKIP: the dlopen bug had masked the separate, already
-known qemu-arm clone3 (syscall 435) limitation, now correctly surfaced.
No regressions: arm-2.23 (2/2), arm-2.27 (5/5), i386-2.27 (14/14),
native aarch64-2.27 (13/13) all rebuilt-sysroot-and-reswept clean, plus
aarch64-2.43 (27/27) and amd64-2.43 (11/11).

* amd64: fix version-matched sysroot for dlopen-of-secondary-libc crash

A big version gap between the runtime ld.so and a dlopen'd secondary
libc crashes amd64 just like the other arches (cf. arm-2.39): the
default cross sysroot's ld.so is far newer than fixtures like
2.19/2.26, and an old ifunc resolver invoked by a much-newer host
ld.so jumps to garbage (observed: SIGSEGV at a bogus 0x500000000,
reproducible via a raw qemu-x86_64 run with no gdb involved at all).

Flips amd64's version_strict? to true and teaches build_sysroot.sh
about the amd64 deb/triplet/ldso, plus symlinks the interpreter at
/lib, /lib64, and /usr/lib since observed PT_INTERP paths vary
(standard amd64 debs use /lib64, one fixture uses /usr/lib directly).

Also fixes transport.rb's Ubuntu-package-version parsing: a couple of
fixtures are plain glibc builds with no "Ubuntu GLIBC" banner to parse
a package version from, so build_sysroot silently never ran and the
mismatched default sysroot was used instead -- looked like a flaky
build failure until traced to the missing match. Falls back to a
small hand-recorded table (from a same-version sibling fixture that
does carry the banner) for the one affected fixture.

* satisfier: handle "reg is a valid pointer" constraint

one_gadget now emits "<reg> is a valid pointer" for a register a libc
call dereferences unconditionally (posix_spawnattr_setsigmask/
setsigdefault). Satisfy it like the bare-pointer argv/envp form: point
the register at readable scratch. Closes the amd64-2.31 0x51df8
strict-mode gap (FAIL -> PASS; sweep now 36/36 strict).

* satisfier: apply register-pinning writable before bounding relations

A "writable: reg+imm" and a "reg != 0" on the same register (libc-2.19
0xc1ca7 / libc-2.23 0xcc52f, which build argv in place at rax and also
branch on rax != 0) conflicted: applying the relation first pinned rax
to a bare literal 1, leaving the writable unsatisfiable (SKIP). Order
plain writable constraints first -- the scratch pointer they pin already
satisfies the != 0 (scratch_satisfies_relation?). Verdict diff across
all fixtures x levels x strict: 0xc1ca7 and 0xcc52f SKIP -> PASS, no
regressions.

Pairs with the one_gadget fix that emits "writable: rax" for these
in-place-argv gadgets.

* satisfier: handle the "readable: reg" constraint form

one_gadget now renders the readable-pointer constraint as "readable:
<reg>" (was "<reg> is a valid pointer"). Add a readable: case that
points the register at readable scratch (STRING_POOL), mirroring the old
pointer-form handling, and drop "pointer" from the argv/envp alternation.
0x51df8 still PASSes strict end-to-end.

* aarch64 arch: treat x29 as a general register, not a stack reg

Pairs with the one_gadget change that emits "writable: x29+imm" for
frame-pointer stores. x29 is not invariantly a valid stack address (like
amd64 rbp), so drop it from stack_regs: the satisfier now pins x29 to
writable scratch when a "writable: x29" constraint requires it, and
strict mode poisons an unconstrained x29. Resolves the frame-pointer
strict FAILs (2.27 0xa32e8/0xa32ec, 2.43 0xc7b00) -> full aarch64 sweep
now PASSes in both benign and strict.

* aletheia: scan for the clone3 site instead of a hardcoded offset

park_stub.c patch_clone3 hardcoded the __clone3 syscall-site offset
(0xb695e, one specific arm fixture), so it only fired for that build --
arm-2.39 (offset 0xb1abe) got "signature mismatch; not applied" and its
6 posix_spawn gadgets SKIPped on syscall 435. Scan the loaded target
libc at its base (bounded by the mapping end) for the unique
"push {r7}; movw r7,#435; svc 0" signature instead. Scanning the loaded
mapping (not the primary libc.so.6 file) is correct whether the fixture
is the primary libc or a dlopen'd secondary.

arm-2.39 posix_spawn gadgets now PASS (6 SKIP -> PASS); arm-2.43
(8c7af7f2, the old hardcoded target) still 10 PASS.

* satisfier: satisfy a libc-global ($base-relative) memory constraint

A branch condition on a libc global, e.g. libc-2.19 0xe654ds
"(s64)[$base+0x3c3e88] <= 0", used to SKIP: the satisfier tried to pin
$base as a register (it is not settable). Instead emit a base-relative
memory write (Plan#base_mem) and have driver.py write 0 to base+off in
the loaded libc before jumping. amd64-2.19s 10 such gadgets go
SKIP -> EXEC (they now reach execve; EXEC not PASS only because their
fixed argv is not ls-/-drivable).

Pairs with the one_gadget change that concretizes rip in compares so the
constraint reads $base rather than a raw rip.

* Model readable-on-load for uncontrolled dereferences

A load through a register the candidate never set up (an incoming register,
e.g. after entering mid-prologue) faults unless that register is a readable
pointer, but the emulator only modeled the loaded value, never the pointer.
Record `readable:` for such a load, deferred like a safe call's :deref so a
later store proving the base writable still discharges it.

Only genuinely uncontrolled loads are constrained: a pointer rooted at mapped
memory (stack, libc base, libc global) is skipped, since a value read from
there is reliably valid. i386's GOT base isn't seeded to $base during
emulation, so its GOT-relative readable is pruned in the fetcher, mirroring
the existing writable pruning.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: apply readables shallowest-deref-first

A nested readable (readable: [base+imm]) resolves its base through
mem_addr_off, which needs base already pointed at scratch by a shallower
readable/writable. Ordering readables shallow-first lets the aarch64 canary
gadgets (readable: x19+off then readable: [x19+off]) satisfy instead of skip.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Model readable-on-load for uncontrolled dereferences

A load through a register the candidate never set up (an incoming register,
e.g. after entering mid-prologue) faults unless that register is a readable
pointer, but the emulator only modeled the loaded value, never the pointer.
Record `readable:` for such a load, deferred like a safe call's :deref so a
later store proving the base writable still discharges it.

Only genuinely uncontrolled loads are constrained: a pointer rooted at mapped
memory (stack, libc base, libc global) is skipped, since a value read from
there is reliably valid. i386's GOT base isn't seeded to $base during
emulation, so its GOT-relative readable is pruned in the fetcher, mirroring
the existing writable pruning.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Disallow posix_spawn_file_actions_* calls in gadgets

A candidate that routes through a file-actions helper is now aborted rather
than accepted. Modeling these soundly is out of reach: the helper returns 0
(so the caller doesn't branch to an error path skipping posix_spawn) only for
a valid fd argument, its success also depends on a realloc that can fail or
fault on a bogus object, the file_actions struct is a glibc-internal
version-dependent layout, and a successful call injects a close/open/dup2 into
the child whose harmlessness depends on the fd. Drops 9 level-2-only gadgets
(2 FAIL, 7 SKIP under empirical verification; no shipped gadget, no PASS lost).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: solve relations on memory operands

parse_relation only matches a bare register, so a comparison against a memory
operand ([$base+off] != 0, [rsp+0x28] != 0, [r0+8] != 0) had no satisfiable
disjunct and skipped the gadget. Generalize the == literal case to any
comparison operator, deriving the value to store from relation_witness, and
teach the memory read/write helpers about libc globals ($base+off), which the
driver already writes at base + off.

Also fixes apply_deref_null missing the $base case, which made
[$base+off] == 0x0 unsatisfiable while [$base+off] == NULL worked.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: solve register-to-register equality, and pick benign bound witnesses

Two independent causes of skips on settable registers:

* `x3 == x0` / `r3 != r1` compare two registers, which parse_relation (a
  register against an immediate) can't express. Pin whichever side is still
  free, matching or differing from the other as the operator requires.

* An upper bound pinned its register to the bound itself, so a co-occurring
  constraint on the same register conflicted and skipped the gadget --
  `(u64)rax <= 0xfffffffffffff000` alongside `eax == 0x0`. Any value in range
  works, so pick zero, which satisfies both.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: solve register-against-memory comparisons

`x3 == [x1+0x8]` / `r13d == [r14+0x70]` compare a register with a value read
from memory, which neither parse_relation (register against an immediate) nor
deref_relation (memory against an immediate) covers.

Whichever side is already settled drives the other: when the memory contents
are known -- a zero-filled scratch slot, or a value an earlier constraint
stored -- the register is pinned to match, instead of overwriting memory a
co-occurring constraint depends on (`[$base+off] != 0` pins the global, then
`x3 == [$base+off]` must take its value).

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: solve field reads off a pointer, and arm register role names

Constraints like `[[$base+0x1c2438]+0xe8] == 0x0` follow a pointer and read a
field off it. The operand parser had no room for the displacement applied
between the two dereferences (it raised), so every such gadget skipped; model
it as Operand#inner_imm and point the stored pointer at fresh scratch, where
the field lands in the zero fill.

Along the way:
* "must be nonzero" now stores a scratch pointer rather than the literal 1, so
  a location can satisfy `!= 0` and still be dereferenced by another constraint.
* a register compared against memory may carry a displacement (`r3-0x4e0 ==
  [r0+0x8]`); the register holds the matched value less that displacement.
* arm's `ip`/`fp`/`sl` role names resolve to r12/r11/r10, so constraints
  written that way are recognised as assignable registers.
* the self-inject plan grammar gained literal and libc-global writes, which it
  previously rejected outright.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: solve displaced operands and GOT-based argv elements

* A parenthesised sum is an operand, not a dereference: `(u64)(x0 + 0x1) <= 0x0`
  raised in the parser. Strip the grouping parentheses, and let a relation's
  left side carry a displacement -- the register then holds the witness less it.
* At one dereference a displacement adds to the value read (`[sp+0xc8]+0x1`),
  where at two it addresses a field; each is now applied accordingly.
* An i386 argv element off the GOT base is a fixed libc address (the real
  "sh"/"-c" string), so leave it be instead of re-pointing that register at
  scratch and conflicting with the GOT constraint that pinned it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* docs: record the EXEC backlog and its categories

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Fix cmn-based compares: shifted immediates and unsigned conditions

Two problems made `cmn`-guarded gadgets carry constraints no attacker could
meet, so they looked unusable:

* `cmn x0, #0x1, lsl #12` compares against 0x1000, but the shift operand was
  dropped, leaving 0x1. Fold a constant shift into the operand, and abort the
  candidate on any modifier not modelled rather than silently dropping it.

* An unsigned condition after an add-based compare reads the carry out of the
  addition, not the sum's value: the carry is set exactly when lhs + rhs wraps,
  so the condition bounds lhs by -rhs instead of forcing the sum to zero. It
  now renders `(u64)x0 <= 0xfffffffffffff000` -- the same form amd64 already
  emits for this glibc "syscall didn't return -errno" check -- where before it
  said `(u64)(x0 + 0x1) <= 0x0`, pinning x0 to one value that contradicted the
  gadget's own `w0 == 0x0`. Signed and equality conditions do read the sum and
  keep the additive form.

Three aarch64 gadgets (2.23 0x3d3d8, 2.24 0x3c630, 2.27 0x3eee4) go from
unsatisfiable to verified PASS under Aletheia; shipped level 0/1 output is
unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: store the smallest "nonzero" witness, promoting only on demand

Satisfying `[rsp+0x2c] != 0` with a scratch pointer wrote a full word where the
code reads a narrower field, clobbering the neighbouring one: two libc-2.23
gadgets stopped reaching execve (FAIL) that otherwise get there.

Store 1 instead, and record the location so a constraint that actually
dereferences it can promote it to a pointer -- still nonzero, so the constraint
that pinned it holds either way. Keeps the nested `[[$base+off]+0xe8] == 0`
cluster passing while the narrow-field gadgets reach execve again.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: satisfy a writable target reached through a pointer

`writable: [rbp-0x48]` asks that the pointer *stored* at rbp-0x48 reference
writable memory -- it carries no trailing displacement, so it isn't the
compound form, and the plain path only accepted a bare register. Allocate the
slot if its base isn't placed yet and store a scratch pointer in it.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* x86: don't model segment-prefixed operands

A segment-prefixed operand reads thread-local storage. A store or load through
one already aborted (the operand parser rejects it), but a compare slipped
through as opaque text, emitting constraints like `fs:[r14] == 0x8` that name a
register the caller cannot set -- it is loaded from a libc global holding
errno's TLS offset.

Every gadget behind one tests errno == ENOEXEC on glibc's execvpe path, which
the caller would have to have arranged beforehand, so they all but never apply.
Rejecting the operand also matches the other arches, whose thread-pointer reads
(aarch64 mrs tpidr_el0, arm mrc p15) are unsupported instructions and already
abort. Drops 6 level-2-only gadgets; shipped level 0/1 output is unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* satisfier: solve comparisons between two memory operands

`[sp+0x10] == [[$base+0x1c2438]+0x70]` has a register on neither side, so
neither the register-vs-memory nor the memory-vs-literal path applies. Match
the value in memory on both sides, letting whichever side already reads a
known value drive the other so an earlier constraint's pin is preserved.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* arm: seed a GOT base only on lines the candidate executes

Emulation stops at the terminal call, but the GOT-base scan looked at the whole
candidate window, including whatever trailed past that call. A `[r3, r4]` load
sitting there was enough to seed r3 as the GOT base at the window's entry, where
the code actually holds a loop counter -- so a plain `r3 == 0x1` was rendered as
`$base+0xf4004 == 0x1`, a constraint nothing can satisfy (arm-2.27 0x73f54).

Restrict the scan to the executed part of the window. Only a real bl/blx to an
exec* entry ends it: a conditional branch within the same function carries a
similar-looking target symbol but isn't a call.

Level 0/1 output is unchanged; 13 level-2 entries lose a GOT constraint they
never needed, their correctly-rendered counterparts having existed all along.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* arm: seed a GOT base only on lines the candidate executes

Emulation stops at the terminal call, but the GOT-base scan looked at the whole
candidate window, including whatever trailed past that call. A `[r3, r4]` load
sitting there was enough to seed r3 as the GOT base at the window's entry, where
the code actually holds a loop counter -- so a plain `r3 == 0x1` was rendered as
`$base+0xf4004 == 0x1`, a constraint nothing can satisfy (arm-2.27 0x73f54).

Restrict the scan to the executed part of the window. Only a real bl/blx to an
exec* entry ends it: a conditional branch within the same function carries a
similar-looking target symbol but isn't a call.

Level 0/1 output is unchanged; 13 level-2 entries lose a GOT constraint they
never needed, their correctly-rendered counterparts having existed all along.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Bound a candidate window where it is emulated, not where it is read

The previous commit taught the arm GOT scan to stop at the terminal call, which
put knowledge of where a path ends in the one place that happened to notice.
A window is bounded by where emulation stops, so bound it once, in find: every
reader -- emulate and its overrides, the dedup key -- can then treat each line
it is given as one that runs.

The boundary uses the same rule the emulator stops on rather than the looser
scan that locates call sites: that one also matches the posix_spawn setup
helpers, which emulation runs through, so cutting there would drop the call the
gadget is built around.

Output is byte-identical at every level.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Track the rewritten window-bounding change

The branch was squashed into a single commit that bounds the window in find and
leaves the arm fetcher untouched; take that shape here.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Forget caller-saved registers across a call

The emulator carried a call's argument and scratch registers through it, so a
branch on what the callee left behind read as a condition the caller could
arrange: `call __close` then `test eax, eax` became `eax == 0x0`, a precondition
no caller can meet, and the same shape appears after the posix_spawn setup
helpers.

Drop those registers at each accepted call, per the ABI's calling convention,
and refuse to render a branch that reads one -- there is nothing to say about a
value nobody chose. The return register is set to zero instead: these calls are
accepted on the basis that they succeed, so a branch on the result resolves, and
a path needing the failing side contradicts itself and drops.

Level 0/1 output is unchanged; 13 level-2 paths that existed only through such a
branch are gone, and every surviving constraint is byte-identical.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* Ask for the NULL a callee needs, instead of walking away

__sigaction leaves oldact alone only when it is NULL, and the emulator
demanded that zero already be there. A register or stack slot the caller
picks can be made NULL, so say so and keep the gadget; only a value that
can never be NULL still aborts.

Recovers 31 gadgets at level 1 and 125 at level 2; level 0 is unchanged
and nothing is lost.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Say a required zero once

A branch testing the pointer and the NULL requirement on it ask for the
same zero, so a gadget carrying both stated it twice.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Read a slot back as what the gadget put in it

A load took the dereference at face value even where the candidate had
just stored there, so a constraint on the slot named the value it held on
entry -- which the gadget has already replaced.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Let a NULL element end the argv it terminates

A register another constraint pins to NULL is a valid argv element -- the
one that ends the array -- but the builder tried to make every register
element a string and gave up on the conflict.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Name the mask a gadget applies, instead of giving up on it

An and was unsupported, so a candidate reaching one was abandoned. Model
it: fold the concrete case, name the symbolic one as the operation
itself, and record the flags a following branch reads.

Recovers 11 gadgets at level 1 and 39 at level 2; nothing is lost.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Plan the masks a gadget applies

one_gadget now names a mask it cannot fold, and four shapes of that had
no rule here: a masked value against a non-zero target, a negated one, a
masked read through a pointer, and an address rounded down for alignment.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Read a 32-bit alignment mask as one

Complementing the mask over 64 bits made i386's 0xfffffff0 look like a
field selector rather than the rounding it is. Judge it by the alignment
it rounds to instead, which is width-independent.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Resolve a rounded pointer wherever it names an address

A gadget aligning a pointer mentions the rounding in every constraint
about that address, not only the writable one. Resolve it to the pointer
in each address context, and leave it alone where the mask is a
requirement on bits instead.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Fill an argv the gadget takes from memory

Only register elements were seeded, so an array staged on the stack
stayed zero-filled and the shell got no command. Put the pointer where
each element is read from, fill an array the gadget supplies no strings
for as sh -c <cmd>, and prefer building one over the empty-argv branch:
an empty argv proves only that execve happened.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Leave the choice of argv shape alone

Preferring a built argv cost eight gadgets that a short one had made
observable, while preferring the short one costs those a built argv
catches. Which shape yields a witnessable shell is a property of the
gadget, so it belongs in a retry rather than in a cost.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Build the argv shapes a shell can be driven from

Every controllable element was given a string, so the shell read the one
after argv[0] as a script name and exited. Verified against a real
/bin/sh which shapes stay drivable, and build those: a string at argv[0],
the command where the gadget supplies -c, and the terminator otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Say which descriptors a gadget closes

A gadget that closes a descriptor on its way to the exec costs the caller
something its constraints never mention. Name the close as the code
performs it, and say what the descriptor must avoid so the spawned shell
keeps its I/O.

Only a descriptor the caller chooses is modelled: one fixed in the code
is nobody's to change, and no path that reaches a terminal call closes
one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Give a gadget a spare descriptor to close

A descriptor a gadget closes but does not constrain read as 0 out of the
zeroed scratch, so the gadget closed the shell's stdin and nothing could
be driven through it -- an accident of this harness, not a property of
the gadget. Put a spare one there instead, as an exploit would, and leave
alone any descriptor a constraint speaks for.

Write it as a 4-byte field: two descriptors sit 4 bytes apart, and a
word-sized write of one zeroed the other.

Converts 122 of the 142 EXEC verdicts to PASS.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Read the shell option however the gadget spells it

On i386 PIC the -c a gadget passes is a libc global off the GOT
register, not the literal string. Read as an ordinary element it
terminated the array exactly where the command goes, and the shell exited
with "-c requires an argument". Take a fixed string past argv[0] as the
option, whatever it looks like.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Show the string a gadget reaches through the GOT

i386 PIC reaches a libc global through the GOT register, so a fixed
string read as <reg>-<off> where every other arch resolves it and prints
its content. What that register holds is known -- the gadget's own
constraint states it is the PLTGOT -- so read the string from there too.

The -c a gadget passes the shell is now visible as such, rather than
looking like an argv element the caller supplies. 31 elements across the
corpus; no gadget gained or lost.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Stop inferring the shell option

one_gadget now shows the string a gadget reaches through the GOT, so the
-c it passes arrives as a literal like every other fixed string. The rule
that inferred it from position no longer has anything to infer.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* Track the argv a gadget builds through a derived pointer

A candidate that rounds a pointer down, or loads one from its frame, and
then builds argv in place had those stores go untracked: no register named
the base. Its argv read as opaque, offering a NULL disjunct its own store
to argv[0] overwrites.

* aletheia: close the EXEC backlog

Strict level-2 is 1163 PASS / 0 EXEC / 0 FAIL / 0 SKIP across all 21
fixtures. Record the three causes that emptied it.

* Keep one store for everything a candidate writes

sp_based_stack, bp_based_stack, reg_based_stack and value_based_stack were
the same Hash with the same default proc, differing only in how they were
keyed and invalidated. Measured across the fixtures, those differences did
nothing: get_corresponding_stack never sees an offset expression (53k calls,
zero), so the substring routing is dead, and a register's key already
follows its value, so the identity guard only ever discarded a history that
was still good -- fragmenting the writes of a post-increment store loop.

Key one store by how the base renders. i386 2.19 0xb58c6 gains the argv it
builds, in place of a NULL disjunct its own store to argv[0] overwrites.

* Model the data-processing instructions ARM shares

aarch64 modelled eight instructions and not nop; a candidate could end on a
no-op with execve still ahead of it. Add the bitwise and shift family both
ARM families spell alike -- and orr eor bic mvn lsl lsr -- in ArmFamily, on
the operator fold now shared from Processor and adopted by X86's and.

Two defects this surfaced, both of which let an invalid gadget through:
a compare rendered a register's folded value in decimal while immediates
were hex, so "0x1 == 0x1" read as "1 == 0x1" and escaped the trivial-relation
check; and that check only compared its two sides textually, so it could
not settle "r1 == r1+0x4" either.

* WIP: name ARM registers as objdump prints them (sl/fp/ip)

* aletheia: resolve a register's role name before addressing the stub

The stub addresses registers by number, and the plan writer pulled that
number straight out of the register's name. A name with no digits -- arm's
sl/fp/ip -- yielded nil and was silently skipped, so the gadget ran without
a value its constraints required and the miss was reported as the gadget's
fault. Normalise through the arch's alias map, and raise rather than skip.

* Name the ARM registers as objdump prints them

objdump never prints the numbered name of an ARM register that has a role
name: r10-r15 always appear as sl/fp/ip/sp/lr/pc. Only the last three were
listed, so any instruction touching sl/fp/ip aborted its candidate as
naming an invalid register -- across the fixtures, sl/fp/ip appear 19421
times and r10/r11/r12 never.

Recovers 37 gadgets at level 2 and 3 at level 1, all verified.

* Model ARM arithmetic on a register operand

add/sub aborted unless the second operand was an immediate, so a candidate
computing a pointer from two registers -- the commonest of all the remaining
blockers, 1225 on aarch64 and 1065 on arm -- ended there. A sum of two
unknowns is no offset from either, so name it as the operation it is, the
way the data-processing family already does.

The stack pointer is the exception: every tracked slot is keyed on it, and
a variable-size frame would put the array a gadget builds at an address only
a caller-supplied register decides, which no constraint states.

Both families had their own add/sub; they now share one in ArmFamily, which
also gives arm the shifted-operand form and aarch64 the 2-operand shorthand.

Recovers 44 gadgets at level 2, none lost.

* A value derived from a libc global is not that global

global_var? asked only whether a rendered value mentions the libc base, so
once arithmetic on a register could be named, "(r3 + $base+0x73fa2)" read as
a fixed global -- and its argv element was resolved to whatever string sits
at that address, hiding that the caller's r3 decides which byte it is. The
gadget then claimed an argv[0] of "/bin/sh" it does not have.

An address plus an unknown names no particular byte; only a bare offset from
the base does.

* Steer a store through (reg + $base+imm) into libc's spare writable data

The satisfier could not place an address only a register decides, so every
gadget carrying one SKIPped. Pin the register to the literal that lands the
sum in the tail of libc's own writable segment, past .bss -- mapped, zeroed,
and used by nothing.

14 gadgets across the four arm fixtures become plannable; 10 verify PASS at
strict level 2. Operands of that shape are refused everywhere else, so no
other constraint is planned as if the fixed address weren't there.

* Place an argv element built from a register and a fixed libc address

apply_argv_list could not place one and skipped it silently, leaving the
register the driver had poisoned as argv[0]: execve took an unmapped pointer
and the gadget reported FAIL for a plan that was never complete.

Steer it into libc's spare data like a store target -- the zero fill is the
valid empty string argv[0] has to be. An element that has to be NULL instead,
or one on a register the plan cannot set, is now refused rather than skipped,
so its gadget SKIPs.

arm-2.27 strict level 2: 38 PASS / 9 FAIL / 3 SKIP -> 44 PASS / 1 FAIL / 5 SKIP.

* Resolve a base sum by pinning its register, once, up front

A (reg + $base+imm) used as an address is the same address whatever reads it,
so pin the register in one pre-pass and let every operand built on it read as
the plain $base+off it has become. That replaces the per-constraint special
cases with one rewrite, and reaches shapes they never covered: a read through
the sum, a chained read, a readable target.

An address-shaped sum inside a disjunction is refused rather than pinned --
giving a poisoned register a valid value for a branch that is then rejected
would weaken the strict test.

Also: a register holding a resolved non-scratch address is no longer mistaken
for a scratch pointer, and the two-operand relation patterns no longer assume
an operand has no spaces in it.

6 more gadgets plan across arm-2.39 and libc-2.43 (arm); all 10 verify PASS.

* Name the value that cancels a libc address the gadget adds

A (reg + $base+imm) required to be NULL cannot be steered: only the negation
of the libc address makes it vanish, and that is not known until the library
is loaded. Add it as a third resolved plan value alongside a scratch and a
load-base offset, resolved by the stub ('reg <n> n <hex>') and by driver.py.

Ranked below the array builder: taking the NULL branch empties an argv, and
which shape yields an observable shell varies per gadget, so cost must not be
what decides it.

The last 8 SKIPs in the corpus become plans, and all 8 verify PASS. The
satisfier now plans every gadget one_gadget reports, on every fixture.

* Ask whether a value is a fixed offset from the libc base

The test for a libc global was "mentions the base, and is not an operation",
so the half that knew about the base lived at the four call sites and the
helper they shared decided only whether the value rendered as "(a op b)" --
a predicate named for a global it never looked for.

A global renders as the base plus an offset and nothing else, so match that
directly. Same answer on every value that reaches it across the 21 fixtures;
output is byte-identical at levels 0, 1 and 2.

* Don't claim the GOT base a candidate establishes itself

seed_got_registers replays the 'ldr rB, [pc]; add rB, pc' pair that set up a
GOT base register before the window. When the window OPENS with that add, the
pair was replayed and then emulated again: rB ended up holding the GOT plus
another whole load base, while resolve went on stating 'rB is the GOT address
of libc' about the register's entry value. arm-2.27's 0x73f2a faulted on the
very load the constraint was meant to protect.

Skip a register the window establishes before its own use, and state what the
load needs instead. The pc bias is settled from the whole candidate up front
too: it was only ever learned from a line already seen, so the first
instruction -- the one whose pc an 'add rX, pc' reads -- was judged with no
evidence. A32 shows neither a width suffix nor a 2-byte stride and keeps the
whole-word bias.

Found by Aletheia; 0x73f2a now verifies PASS at strict level 2. The corpus
changes by two lines at levels 0/1/2: the gadget's constraints, and its
reappearance at level 1, where it had been deduplicated against 0x73f2c for
having been given that same wrong constraint list.

* Drop constraints another constraint already settles

A gadget could ask for the same thing twice, and could offer an option its own
list rules out. A dereference already requires its address to be readable, so a
readable: beside it repeats it; and an address required to be mapped memory is
never NULL, so a "== NULL" option for it can never be taken -- the reader was
being offered a choice that isn't one.

Both rules need the constraint doing the settling to hold outright. Inside a
"||" it is one option among several: "[r8] == NULL || r8 == NULL || r8 is a
valid envp" can be met without reading r8 at all, so a readable: r8 beside it
is load-bearing.

Across the 21 fixtures this drops 97 repeated readable: constraints and 28
impossible NULL options over 105 gadgets, and loses no gadget. A readable:
costs a gadget 5x its score, so the trimmed set moves too: three gadgets that
were dominated by one which only looked like it asked for something else now
show up, and one whose neighbour is now visibly strictly easier drops out. The
default output is unchanged on every fixture.

* Establish a nested readable's own base

order_constraints relied on a shallower readable/writable to point the base
register at scratch before a "readable: [base+imm]" could place its pointer.
That shallower constraint is implied -- reading the pointer AT an address
requires the address readable -- so one_gadget no longer emits it, and
mem_addr_off returned nil: aarch64-2.27 0xa3228/0xa322c went to
"unsupported-or-unsat" with no gadget having changed.

Point the base at scratch when nothing else already has. The plan comes out
exactly as it did when the shallower constraint drove it (x19 -> scratch+0x100).

* Read a dereference as saying its address is mapped

The pass knew an address was mapped memory only when a readable:/writable:
said so. A constraint that dereferences an address says the same thing: the
access has to land somewhere, and address zero is never mapped. So a
"[$base+0x11052c] != 0x0" sitting beside "[[$base+0x11052c]+0xa4] == 0x0" was
asking for what the line under it already requires.

An access near an address settles it too, since a NULL one cannot reach out of
the first page -- bounded by the smallest page, so a far enough offset still
has to be asked for. Readability is not: a dereference nearby says the page it
lands on is mapped, not the one the address itself sits on.

Drops 51 more constraints across the 21 fixtures, taking the longest lists from
9 to 8. No gadget changes at any level.

* Window past the call, where predecessors also live

The window around a terminal call reached 0x2000 back and 0x80 forward, the
forward figure being "enough to cover the call instruction itself". But the walk
running backwards does not mean what it reaches lies behind: a branch
predecessor can sit after the call, in a loop or a later block that jumps back
into the region. arm is the only architecture windowed today and it was losing
28 gadgets on arm-2.39 for want of 0x400 forward.

Measure both directions the same way -- window every architecture against its
own full disassembly across the corpus -- and gadgets start being lost below
0x400 either way (105 at 0x80 forward). A window too small also invents gadgets,
since the line before the first of a window is the last of another and nothing
about it follows. 0x4000 each way leaves 16x, and still disassembles about a
fifth of a libc: terminal calls cluster into a handful of merged windows, so a
wider one costs little.

Recovers those 28 gadgets. Nothing else changes on the 21 fixtures, at any
level.

* Stop two harness slips from reporting a gadget's fault

Both would report against a gadget something that was never about it.

The qemu -strace log and the shell's own output were read as text, but a gadget
builds its argv out of whatever the libc holds at the address it uses, and the
strace prints it. One invalid byte and every later match?/include? raises, which
verify catches and files as "SKIP harness error" against a gadget that ran fine.
Read both as bytes.

And a plan assigned a register under whichever name the constraint spelled, so
arm's fp and r11 -- one register -- were two entries. set_reg exists to refuse a
second, conflicting value for a register; under two names it never saw the
conflict, and the stub would quietly apply whichever came last. Resolve the name
where the plan is read and written.

Every arm fixture still verifies whole under strict (37, 50, 87, 86), as does
aarch64-2.43 (143).

* Keep walking past instructions that decide nothing

A memory barrier orders accesses without changing one, and a landing pad marks
where an indirect branch may arrive: neither is anything to an emulator that
tracks values, yet both stopped a candidate dead. A byte load is nearly as
harmless -- the address is read like any other load, so what the caller must
arrange about it is recorded the same way, but one byte of a word is not a value
this emulator can name, so the register holds what a call would have left and a
path that goes on to depend on it is abandoned rather than described wrongly.

777 dmb, 263 bti, and ldrb the largest unsupported instruction left on either
arm arch (45 aarch64 candidates, 97 arm). Two gadgets follow, both on arm; the
rest of those paths now stop at a call instead.

* Hold the harness to the same style as the gem

Nothing had ever linted aletheia/, so it had drifted: multi-line endless method
definitions, a conditional where a guard clause reads better, a comparison
written as a hash iteration, OpenStruct standing in for a gadget in the specs.
Rubocop autocorrected most of it.

The rest needed deciding rather than correcting. A plan pinning a register to
the literal zero is now asked for by name, since the value there can as easily
be a pointer, a value only the driver can compute, or nothing at all -- which is
why it could not simply become .zero?. The operand parser was called Operand_ to
keep it out of the way of the Operand it produces; it is OperandParser now, and
says what it is. The gadget the specs hand the satisfier is a named struct: what
they pass is the constraint list under test, and building it through one_gadget
would let its own pruning rewrite it first.

* Build the harness with the gem

The harness has lived beside the gem without being part of its build: rubocop's
patterns and rspec's both stop at the top-level lib/ and spec/, so nothing on
this branch was linted or run by `rake`. That is fine for a branch nobody merges
and wrong for one that is going to land.

Lint aletheia/ with everything else, and run its own specs -- 55 of them, all
gdb-free -- with the suite: 215 examples becomes 270. Coverage stays a promise
about the gem, since most of the harness only runs under gdb and qemu against
real libcs, which CI has none of.

The gem package is unaffected: the gemspec globs lib/ and bin/ from the root, so
it picks up none of this.

* Reach the harness from the repository it verifies

Running Aletheia meant knowing where its binary lives and what flags it wants,
which is fine for whoever wrote it and a wall for everyone else. Give it a rake
task that takes the output level and what to run it against -- an architecture,
a path, a glob, or nothing for every libc the specs ship -- and reports what
came back per libc, with the offset and reason for anything that did not verify.

Verification is always strict there: whatever a gadget's constraints do not name
is poisoned, so an incomplete constraint list fails instead of passing by luck.
That is the only way to run it that answers the question worth asking.

* Say in the README that the gadgets are checked by running them

Constraints are only worth as much as they are complete, and nothing in the
README said how anyone knows they are. Point at Aletheia, say what it does to a
gadget -- arrange what the constraints ask for, poison everything they don't,
require a real shell to come back and list the root -- and show the two commands
that run it.

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-08-23 16:25:42 +08:00

481 lines
23 KiB
Ruby

# frozen_string_literal: true
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
require 'aletheia/arch/aarch64'
require 'aletheia/satisfier'
# gdb-free unit tests for the constraint satisfier: they assert the plan it emits
# (register/memory assignments and chosen disjunction branch) for representative
# constraints of each category.
# The three things the satisfier reads off a gadget. A struct rather than the real
# one: these constraint lists are what is under test, and building them through
# one_gadget would let its own pruning rewrite them first.
StubGadget = Struct.new(:offset, :effect, :constraints, keyword_init: true)
RSpec.describe Aletheia::Satisfier do
subject(:satisfier) { described_class.new(Aletheia::Arch::AArch64) }
def gadget(constraints, offset: 0x1000, effect: 'execve("/bin/sh", x0, environ)')
StubGadget.new(offset: offset, effect: effect, constraints: constraints)
end
it 'sets a bare register to zero for "reg == NULL"' do
plan = satisfier.satisfy(gadget(['x1 == NULL']))
expect(plan.status).to eq('ok')
expect(plan.regs['x1']).to eq(0)
expect(plan.branches['c0']).to eq('x1 == NULL')
end
it 'satisfies a stack-relative dereference-NULL for free (no register set)' do
plan = satisfier.satisfy(gadget(['[sp+0x50] == NULL']))
expect(plan.status).to eq('ok')
expect(plan.regs).to be_empty
end
it 'rejects the unsatisfiable "sp+imm == NULL" branch and takes the deref branch' do
plan = satisfier.satisfy(gadget(['sp+0x218 == NULL || (u16)[sp+0x218] == NULL']))
expect(plan.status).to eq('ok')
expect(plan.branches['c0']).to eq('(u16)[sp+0x218] == NULL')
end
it 'points a register into scratch to satisfy "writable: reg+imm"' do
plan = satisfier.satisfy(gadget(['writable: x19+0x258']))
expect(plan.status).to eq('ok')
off = plan.regs['x19']['scratch_off']
expect(off + 0x258).to eq(Aletheia::Satisfier::WRITABLE_BASE)
end
it 'gives distinct scratch write-areas to multiple writable constraints' do
plan = satisfier.satisfy(gadget(['writable: x19+0x258', 'writable: x20+0x4']))
a = plan.regs['x19']['scratch_off'] + 0x258
b = plan.regs['x20']['scratch_off'] + 0x4
expect(a).not_to eq(b)
end
it 'prefers the cheap NULL branch over building a valid argv' do
plan = satisfier.satisfy(gadget(['x4 == NULL || {x4, x3, x23, NULL} is a valid argv']))
expect(plan.regs['x4']).to eq(0)
expect(plan.branches['c0']).to eq('x4 == NULL')
end
it 'ends the array after argv[0], the shape a shell can be driven from' do
# Giving x3 a string too would make the shell read it as a script name and
# exit; {<str>, NULL} leaves it reading stdin. Verified against a real
# /bin/sh -- see the shape table on #apply_argv_list.
plan = satisfier.satisfy(gadget(['{x4, x3, x23, NULL} is a valid argv']))
expect(plan.regs['x4']).to eq('scratch_off' => Aletheia::Satisfier::STRING_POOL)
expect(plan.regs['x3']).to eq(0)
expect(plan.regs).not_to have_key('x23')
end
it 'gives the command slot of a "-c" array the command, not a terminator' do
plan = satisfier.satisfy(gadget(['{"sh", "-c", x23, NULL} is a valid argv']))
expect(plan.regs['x23']).to eq('scratch_off' => Aletheia::Satisfier::COMMAND_POOL)
end
it 'terminates after a fixed argv[0] the gadget supplies' do
plan = satisfier.satisfy(gadget(['{"/bin/sh", x0, NULL} is a valid argv']))
expect(plan.regs['x0']).to eq(0)
end
it 'sets a register to the immediate for a "reg == imm" branch condition' do
plan = satisfier.satisfy(gadget(['x2 == 0x1']))
expect(plan.status).to eq('ok')
expect(plan.regs['x2']).to eq(1)
end
it 'normalises a 32-bit view (w21) to its 64-bit register' do
plan = satisfier.satisfy(gadget(['w21 == 0x1']))
expect(plan.regs['x21']).to eq(1)
end
it 'treats a scratch pointer as already satisfying "!= 0"' do
plan = satisfier.satisfy(gadget(['writable: x0', 'x0 != 0']))
expect(plan.status).to eq('ok')
expect(plan.regs['x0']).to be_a(Hash) # points into scratch, hence nonzero
end
it 'builds a valid argv by pointing register elements at scratch, -c at the command pool' do
plan = satisfier.satisfy(gadget(['{"sh", "-c", x23, NULL} is a valid argv']))
expect(plan.status).to eq('ok')
expect(plan.regs['x23']).to eq('scratch_off' => Aletheia::Satisfier::COMMAND_POOL)
end
it 'points the first register operand after -c at the command pool, past a "--" separator' do
# `$base+0x100` here stands in for the libc "--" constant; the real command is
# the next operand register (x21), which `sh -c -- <x21>` runs.
plan = satisfier.satisfy(gadget(['{"sh", "-c", $base+0x100, x21, NULL} is a valid argv']))
expect(plan.status).to eq('ok')
expect(plan.regs['x21']).to eq('scratch_off' => Aletheia::Satisfier::COMMAND_POOL)
end
describe 'masks one_gadget could not fold' do
it 'gives a register a value whose masked bits meet the target' do
plan = satisfier.satisfy(gadget(['(x1 & 0xf000) == 0x2000']))
expect(plan.status).to eq('ok')
expect(plan.regs['x1']).to eq(0x2000)
end
it 'refuses a target the mask can never produce' do
# 0x1 lies outside the mask, so no value of x1 makes the masked read 0x1.
plan = satisfier.satisfy(gadget(['(x1 & 0xf000) == 0x1']))
expect(plan.status).to eq('skip')
end
it 'flips the masked bits for an inequality' do
plan = satisfier.satisfy(gadget(['(x2 & 0x10) != 0x0']))
expect(plan.status).to eq('ok')
expect(plan.regs['x2']).to eq(0x10)
end
it 'writes the value where the mask reads through a pointer' do
plan = satisfier.satisfy(gadget(['([sp+0x40] & 0x10) != 0x0']))
expect(plan.status).to eq('ok')
expect(plan.mem[Aletheia::Satisfier::SP_OFFSET + 0x40]).to eq(0x10)
end
it 'points a register rounded down for alignment at a scratch slot' do
# the gadget writes through (x3 & ~0xf); a scratch slot is 16-aligned, so
# the rounding leaves it untouched and the slot is the address written.
plan = satisfier.satisfy(gadget(['writable: (x3 & 0xfffffffffffffff0)']))
expect(plan.status).to eq('ok')
expect(plan.regs['x3']['scratch_off'] % 0x10).to eq(0)
end
it 'reads a 32-bit alignment mask as an alignment, not a field' do
# i386 renders the same rounding as 0xfffffff0; complementing that over 64
# bits would make it look like a field selector and skip the gadget.
plan = satisfier.satisfy(gadget(['writable: (x3 & 0xfffffff0)']))
expect(plan.status).to eq('ok')
expect(plan.regs['x3']['scratch_off'] % 0x10).to eq(0)
end
it 'reads a rounded pointer inside a larger expression as that pointer' do
# the whole disjunction is about the address x3 rounds to, so every mention
# of it resolves to x3 -- and the scratch it is pointed at is zeroed, which
# already satisfies the cheapest branch.
plan = satisfier.satisfy(gadget(['writable: (x3 & 0xfffffffffffffff0)',
'[(x3 & 0xfffffffffffffff0)] == NULL || ' \
'(x3 & 0xfffffffffffffff0) is a valid argv']))
expect(plan.status).to eq('ok')
expect(plan.regs['x3']).to be_a(Hash)
end
it 'refuses an alignment coarser than a scratch slot guarantees' do
# page alignment: WRITABLE_STRIDE is 0x800, so slots are not all 0x1000-aligned.
plan = satisfier.satisfy(gadget(['writable: (x3 & 0xfffffffffffff000)']))
expect(plan.status).to eq('skip')
end
end
it 'lets a register another constraint pins to NULL terminate the argv' do
# x3 is __sigaction's oldact -- it has to be NULL, and NULL is exactly what
# ends an argv, so the array is `sh -c <x6>` rather than unsatisfiable.
plan = satisfier.satisfy(gadget(['x3 == NULL', '{"sh", "-c", x6, x3, NULL} is a valid argv']))
expect(plan.status).to eq('ok')
expect(plan.regs['x3']).to eq(0)
expect(plan.regs['x6']).to eq('scratch_off' => Aletheia::Satisfier::COMMAND_POOL)
end
it 'builds no element past the argv terminator' do
plan = satisfier.satisfy(gadget(['{"sh", "-c", x6, NULL, x21} is a valid argv']))
expect(plan.status).to eq('ok')
expect(plan.regs['x6']).to eq('scratch_off' => Aletheia::Satisfier::COMMAND_POOL)
expect(plan.regs).not_to have_key('x21')
end
it 'zeroes memory for a single-deref "[reg] == NULL" by pointing the register at scratch' do
plan = satisfier.satisfy(gadget(['[x0] == NULL']))
expect(plan.status).to eq('ok')
expect(plan.regs['x0']).to eq('scratch_off' => Aletheia::Satisfier::STRING_POOL)
end
it 'restores and retries when the cheapest branch genuinely conflicts' do
# writable: x0 pins x0 to a (nonzero) scratch address; "x0 == NULL" would need
# x0 = 0 -- a conflict -- so the satisfier restores and takes "x0 is a valid argv".
plan = satisfier.satisfy(gadget(['writable: x0', 'x0 == NULL || x0 is a valid argv']))
expect(plan.status).to eq('ok')
expect(plan.branches['c1']).to eq('x0 is a valid argv')
end
it 'lets a writable slot also satisfy a "[reg] == NULL" on that same slot' do
# writable: x0 points x0 at zeroed scratch, so the deref [x0] already reads
# NULL -- the cheapest argv branch holds without re-assigning (and conflicting on) x0.
plan = satisfier.satisfy(gadget(['writable: x0', '[x0] == NULL || x0 == NULL || x0 is a valid argv']))
expect(plan.status).to eq('ok')
expect(plan.branches['c1']).to eq('[x0] == NULL')
expect(plan.regs['x0']).to be_a(Hash) # still the single writable scratch pointer
end
it 'SKIPs (does not crash) on an unparseable operand' do
# Deeper than the one modelled level of nesting (see Operand#inner_imm).
plan = satisfier.satisfy(gadget(['(s32)[[[sp+0x30]+0x4]+0x8] <= 0']))
expect(plan.status).to eq('skip')
end
it 'points a pointer read from memory at scratch, for a field read off it' do
plan = satisfier.satisfy(gadget(['(s32)[[sp+0x30]+0x4] <= 0']))
expect(plan.status).to eq('ok')
# [sp+0x30] holds a scratch pointer, so the field at +0x4 reads the zero fill.
expect(plan.mem[Aletheia::Satisfier::SP_OFFSET + 0x30]).to have_key('scratch_off')
end
it 'defaults to benign fill, and to poison fill in strict mode' do
expect(satisfier.satisfy(gadget(['x1 == NULL'])).benign_default).to be(true)
strict = described_class.new(Aletheia::Arch::AArch64, strict: true)
plan = strict.satisfy(gadget(['x1 == NULL']))
expect(plan.poison_default).to be(true)
expect(plan.benign_default).to be(false)
end
# A nested readable (aarch64-2.27 0xa3228) states the readability of its own
# base: reading the pointer AT x19+0xed8 requires that address readable. One
# gadget lists no shallower constraint saying so, so the satisfier has to give
# x19 somewhere to point before it can place the pointer.
it 'establishes its own base for "readable: [reg+imm]"' do
plan = satisfier.satisfy(gadget(['readable: [x19+0xed8]']))
expect(plan.status).to eq('ok')
pool = Aletheia::Satisfier::STRING_POOL
expect(plan.regs['x19']).to eq('scratch_off' => pool)
expect(plan.mem[pool + 0xed8]).to eq('scratch_off' => pool)
end
context 'amd64 backend' do
require 'aletheia/arch/amd64'
subject(:satisfier) { described_class.new(Aletheia::Arch::Amd64) }
def gadget(constraints, offset: 0x1000, effect: 'execve("/bin/sh", rsi, environ)')
StubGadget.new(offset: offset, effect: effect, constraints: constraints)
end
it 'satisfies a bare stack-alignment "rsp & 0xf == 0" by construction (no register set)' do
plan = satisfier.satisfy(gadget(['rsp & 0xf == 0']))
expect(plan.status).to eq('ok')
expect(plan.regs).to be_empty
expect(plan.branches['c0']).to eq('rsp & 0xf == 0')
end
it 'aligns a GPR by pointing it at an aligned scratch slot for "reg & 0xf == 0"' do
plan = satisfier.satisfy(gadget(['rbx & 0xf == 0']))
expect(plan.status).to eq('ok')
expect(plan.regs['rbx']).to eq('scratch_off' => Aletheia::Satisfier::STRING_POOL)
end
# A branch condition on a libc global, e.g. libc-2.19 0xe654d's
# `(s64)[$base+0x3c3e88] <= 0`: the driver writes 0 to base+off rather than the
# satisfier pinning a register (a $base address isn't settable).
it 'zeroes a libc global for "(s64)[$base+off] <= 0"' do
plan = satisfier.satisfy(gadget(['(s64)[$base+0x3c3e88] <= 0x0']))
expect(plan.status).to eq('ok')
expect(plan.base_mem[0x3c3e88]).to eq(0)
expect(plan.regs).to be_empty # $base is not pinned as a register
end
it 'normalises a 32-bit view (eax) to its 64-bit register (rax)' do
plan = satisfier.satisfy(gadget(['eax == 0']))
expect(plan.regs['rax']).to eq(0)
end
it 'points a register at zeroed scratch for a dereferenced "[reg] == 0"' do
plan = satisfier.satisfy(gadget(['[rbx] == 0']))
expect(plan.status).to eq('ok')
expect(plan.regs['rbx']).to eq('scratch_off' => Aletheia::Satisfier::STRING_POOL)
end
# "readable: reg" (a libc call dereferences reg, e.g. libc-2.31 0x51df8's
# posix_spawnattr_setsigmask/setsigdefault reading r12/r13): point the register
# at readable scratch, like the bare-pointer argv/envp form.
it 'points a register at readable scratch for "readable: reg"' do
plan = satisfier.satisfy(gadget(['readable: r12']))
expect(plan.status).to eq('ok')
expect(plan.regs['r12']).to eq('scratch_off' => Aletheia::Satisfier::STRING_POOL)
end
# A "writable: reg" and a "reg != 0" on the SAME register must not conflict
# (libc-2.19 0xc1ca7 builds argv in place at rax and also branches on rax != 0):
# the writable pins rax to a scratch pointer, which -- applied first -- already
# satisfies the != 0. Applying the relation first would pin rax to a bare 1 and
# leave the writable unsatisfiable.
it 'satisfies "writable: reg" and "reg != 0" together by pinning the pointer' do
plan = satisfier.satisfy(gadget(['rax != 0x0', 'writable: rax+0x8']))
expect(plan.status).to eq('ok')
expect(plan.regs['rax']).to be_a(Hash) # a scratch pointer, not the literal 1
end
# libc-2.23's 0xcc610 reads a mode flag off the stack (`mov esi,[rbp-0x50]`,
# never overwritten within the window) that a later branch requires to be
# exactly 1 -- unlike a zero target, there's no pre-zeroed region to point
# at, so this needs rbp pinned to a *fresh* scratch slot (rbp is otherwise
# unconstrained by this gadget) with the literal actually written there.
it 'pins an unconstrained register and writes a nonzero literal for "[reg+imm] == <imm>"' do
plan = satisfier.satisfy(gadget(['[rbp-0x50] == 0x1']))
expect(plan.status).to eq('ok')
rbp_off = plan.regs['rbp']['scratch_off']
expect(plan.mem[rbp_off - 0x50]).to eq(1)
end
# A "writable: [base]+imm" compound base (base is dereferenced, not a bare
# register -- a store through a pointer read off the stack, e.g. libc-2.27's
# 0xe5887) can't be resolved until `base`'s own argv/envp constraint has run,
# and that constraint must not pick base == NULL: with base == NULL,
# "[base]+imm" is a fixed low address (a real unmapped-page write), not
# merely untracked. The deferred pass (order_compound_writable_last) plus the
# NULL-branch block (@null_unsafe_bases) together force "[rbp-0x78] is a
# valid argv" instead of the cheaper "[rbp-0x78] == NULL".
it 'defers a compound "writable: [base]+imm" until base resolves to a real pointer' do
plan = satisfier.satisfy(gadget([
'writable: [rbp-0x78]+0x10',
'writable: rbp-0x80',
'[[rbp-0x78]] == NULL || [rbp-0x78] == NULL || [rbp-0x78] is a valid argv',
'[[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp'
], effect: 'execve("/bin/sh", [rbp-0x78], [rbp-0x70])'))
expect(plan.status).to eq('ok')
expect(plan.branches.values).to include('[rbp-0x78] is a valid argv')
expect(plan.branches.values).not_to include('[rbp-0x78] == NULL')
rbp_off = plan.regs['rbp']['scratch_off']
expect(plan.mem[rbp_off - 0x78]).to eq('scratch_off' => Aletheia::Satisfier::STRING_POOL)
end
end
# arm-2.27's 0x2d358 window runs `add r1, pc` and then stores through the sum,
# so one_gadget names the store target `(r1 + $base+0x2d364)`: a fixed libc
# address whose landing place only r1 decides. There is no pointer to hand out
# -- r1 has to carry the literal that puts the sum in libc's own spare
# writable data.
context 'a store through a register added to a fixed libc address' do
require 'aletheia/arch/arm'
subject(:satisfier) { described_class.new(Aletheia::Arch::Arm, spare_writable: spare) }
let(:spare) { 0xf7590...0xf8000 }
def gadget(constraints, offset: 0x2d358, effect: 'execve("/bin/sh", sp+0x20, environ)')
StubGadget.new(offset: offset, effect: effect, constraints: constraints)
end
it 'pins the register so the sum lands in the spare writable data' do
plan = satisfier.satisfy(gadget(['writable: (r1 + $base+0x2d364)']))
expect(plan.status).to eq('ok')
expect(spare).to cover(plan.regs['r1'] + 0x2d364)
end
# The register carries the whole displacement, so a trailing one only shifts
# where inside its area the sum lands -- it stays in the spare region.
it 'folds a trailing displacement into the literal' do
plan = satisfier.satisfy(gadget(['writable: (r1 + $base+0x2d364)+0x8']))
expect(spare).to cover(plan.regs['r1'] + 0x2d364 + 0x8)
end
it 'gives distinct areas to two such stores' do
plan = satisfier.satisfy(gadget(['writable: (r1 + $base+0x100)', 'writable: (r2 + $base+0x200)']))
expect(plan.status).to eq('ok')
expect(plan.regs['r1'] + 0x100).not_to eq(plan.regs['r2'] + 0x200)
end
# Once the register is pinned the sum is a fixed libc address, so a read
# through it is an ordinary libc-global read -- and lands on the zero fill.
it 'reads zero through a dereference of the sum' do
plan = satisfier.satisfy(gadget(['[(r1 + $base+0x50558)+0x8] == 0x0', 'writable: (r1 + $base+0x50558)']))
expect(plan.status).to eq('ok')
slot = plan.regs['r1'] + 0x50558
expect(spare).to cover(slot)
expect(plan.base_mem[slot + 0x8]).to eq(0) # written where the read lands
end
# A chained read needs a real pointer at the first level, which the driver
# writes into the libc global the sum resolved to.
it 'stages a pointer in libc for a chained dereference of the sum' do
plan = satisfier.satisfy(gadget(['[[(r2 + $base+0x50492)+0xc]+0xa4] == 0x0',
'r6 == [[(r2 + $base+0x50492)+0xc]+0x38]',
'readable: (r2 + $base+0x50492)+0xc']))
expect(plan.status).to eq('ok')
slot = plan.regs['r2'] + 0x50492 + 0xc
expect(plan.base_mem[slot]).to have_key('scratch_off')
expect(plan.regs['r6']).to eq(0)
end
# Pinning a register a rejected branch asked for would hand a poisoned
# register a valid value, so an address-shaped sum inside a disjunction is
# refused rather than steered (see Satisfier#steer_base_sums).
it 'refuses to steer a sum that only one branch of a disjunction needs' do
plan = satisfier.satisfy(gadget(['r4 == NULL || writable: (r1 + $base+0x100)']))
expect(plan.status).to eq('skip')
end
it 'SKIPs when the libc has no spare writable room to steer the store into' do
plan = described_class.new(Aletheia::Arch::Arm).satisfy(gadget(['writable: (r1 + $base+0x2d364)']))
expect(plan.status).to eq('skip')
end
it 'SKIPs when the register is not one the plan can set' do
plan = satisfier.satisfy(gadget(['writable: (lr + $base+0x2c5ee)']))
expect(plan.status).to eq('skip')
end
# A sum required to be NULL cannot be steered anywhere -- only the negation
# of the libc address makes it vanish, and only the driver knows that value.
it 'names the value that cancels the libc address for a NULL requirement' do
plan = satisfier.satisfy(gadget(['(r2 + $base+0x47a68) == NULL']))
expect(plan.status).to eq('ok')
expect(plan.regs['r2']).to eq('neg_base_off' => 0x47a68)
end
# The same steering serves an argv element: the sum lands on the zero fill,
# which reads as the valid empty string argv[0] only has to be. arm-2.27's
# 0x2d35e shape, where the gadget supplies the "-c" and the command follows.
it 'places an argv[0] built the same way, and still seeds the command' do
plan = satisfier.satisfy(gadget(['{(ip + $base+0x2d36a), "-c", fp, r3, ...} is a valid argv']))
expect(plan.status).to eq('ok')
# Both are named by their AAPCS role in the constraint and assigned under
# the register that role denotes.
expect(spare).to cover(plan.regs['r12'] + 0x2d36a)
expect(plan.regs['r11']).to eq('scratch_off' => Aletheia::Satisfier::COMMAND_POOL)
end
# An element that has to end the array is NULL, so it takes the cancelling
# value too, where argv[0] before it is merely steered at a readable string.
it 'cancels the address for such an element where the array has to end' do
plan = satisfier.satisfy(gadget(['{(ip + $base+0x100), (r7 + $base+0x200), fp, r3, ...} is a valid argv']))
expect(plan.status).to eq('ok')
expect(spare).to cover(plan.regs['r12'] + 0x100)
expect(plan.regs['r7']).to eq('neg_base_off' => 0x200)
end
# Emptying the array and building it are different shapes, and which one
# yields an observable shell varies per gadget -- so where both are open the
# builder still wins, exactly as before the cancelling value existed.
it 'still builds the array when a NULL branch is also open' do
plan = satisfier.satisfy(gadget(['(ip + $base+0x2d36a) == NULL || ' \
'{(ip + $base+0x2d36a), "-c", fp, r3, ...} is a valid argv']))
expect(plan.branches['c0']).to start_with('{')
expect(spare).to cover(plan.regs['r12'] + 0x2d36a)
end
it 'refuses such an element on a register the plan cannot set' do
plan = satisfier.satisfy(gadget(['{(lr + $base+0x2c5fe), "-c", r6, r3, ...} is a valid argv']))
expect(plan.status).to eq('skip')
end
end
context 'i386 backend' do
require 'aletheia/arch/i386'
subject(:satisfier) { described_class.new(Aletheia::Arch::I386, got_offset: 0x1d5000) }
def gadget(constraints, offset: 0x1000, effect: 'execve("/bin/sh", esp+0x34, environ)')
StubGadget.new(offset: offset, effect: effect, constraints: constraints)
end
it 'sets the base register to the libc GOT (base + PLTGOT offset)' do
plan = satisfier.satisfy(gadget(['esi is the GOT address of libc']))
expect(plan.status).to eq('ok')
expect(plan.regs['esi']).to eq('base_off' => 0x1d5000)
end
it 'SKIPs the GOT constraint when the PLTGOT offset is unknown' do
plan = described_class.new(Aletheia::Arch::I386).satisfy(gadget(['esi is the GOT address of libc']))
expect(plan.status).to eq('skip')
end
end
end