mirror of
https://github.com/david942j/one_gadget.git
synced 2026-10-11 22:09:54 +00:00
* 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]>
481 lines
23 KiB
Ruby
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
|