Files
one_gadget/spec/emulators/aarch64_spec.rb
T
david942jandClaude Opus 4.8 4732208300 Conditional-branch-aware gadget search (#321)
* fetcher: promote disasm index to Base

Move disasm_lines/disasm_index (full objdump disassembly + address→index
map) from the ARM fetcher into Base so any arch can walk the disassembly,
and extract the terminal-call regexp into a helper. Groundwork for
control-flow-aware candidate discovery; no behavior change.

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

* Add conditional-branch-aware gadget search (aarch64)

Gadgets in newer glibc are often split across a conditional branch (e.g.
2.43's execvp materialises "/bin/sh" before `cmp x2, #1; b.ne`), which the
straight-line-only search could not find.

- fetcher: enumerate candidates by walking the CFG *backward* from each
  terminal call along its predecessors (fall-through + branch targets),
  forking at conditional edges up to MAX_FORKS(4). Gated per-arch by
  follow_branches? (aarch64 on; others keep slice_prefix for now).
- emulators: shared Conditional module models cmp/cmn/tst + b.<cond>/cbz/
  cbnz/tbz/tbnz, turning each crossed edge into a constraint via one-line-
  ahead resolution (full relational: ==,!=,signed/unsigned </>/<=/>=, bit
  tests). Sound: abort a path we can't express (e.g. no preceding compare).
- aarch64: inst_stp now handles non-sp stores like inst_str (needed to reach
  the argv-building block); resolve argv/envp fall back when a stack register
  has no tracked stack (frame pointer).
- gadget: score branch conditions.

2.43 now yields execve("/bin/sh", x4, x6) guarded by `x2 == 0x1`. Existing
aarch64 gadgets are unchanged or added to (fixtures updated); other arches
untouched.

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

* emulators: conditional-branch-aware search for arm

Enable follow_branches? for arm: model cmp/cmn/tst (previously nop-aliased)
plus bne/beq/... condition-suffix branches and Thumb cbz/cbnz, reusing the
shared Conditional module. Add arm branch classification (path_ends? covers
bx / pop{..pc} / mov pc returns).

Fixes surfaced by the new paths: guard inst_ldr/inst_str write-back against a
non-register base, and skip posix_spawn candidates whose pid/file_actions/attrp
is a concrete integer (a fixed address we can't constrain).

arm gadgets are unchanged or added to (2.27, 2.39 each gain one; fixtures
updated); e.g. 2.27 0x73f2c is now found guarded by `[r1] == 0`.

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

* Extend conditional-branch-aware search to x86 (amd64/i386)

Model cmp/test + the full jcc set (and jcxz) in the x86 emulator, reusing
the shared Conditional module; retire amd64's jmp_case_candidates (the CFG
walker subsumes it). With every arch now branch-aware, drop the dead
straight-line candidates path, slice_prefix and branch?.

Soundness/robustness fixes exposed by the richer x86 paths:
- drop gadgets whose branch condition is unsatisfiable (X != X) and strip
  always-true ones (X == X);
- dedup gadgets by offset (a branch can reach the call either way), keeping
  the easiest variant;
- treat a resolve-time eval error as "not a gadget";
- Lambda.parse: model absolute [addr]; keep xmm arrays / fall back only for a
  call-target string base;
- guard the SSE handlers, inst_sub, check_envp against non-Lambda operands.

amd64/i386 now find a superset of the previous gadgets (fixtures updated for
the force_file paths; prebuilt-DB assertions unchanged).

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

* docs: note branch-aware gadget search in README

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

* perf: speed up branch-aware gadget search

The backward CFG walk produces many overlapping candidate paths, and find()
re-emulates every start line in each; the extra work made the specs notably
slower. All behaviour-preserving (gadget output is unchanged):

- find(): memoize by candidate suffix so shared tails are emulated once
  (~57% of starts were duplicates).
- Processor#parse: build the instruction set (and a mnemonic index) once
  instead of re-allocating it per line.
- CFG walk: precompute each line's branch classification and address once
  (line_kinds/disasm_addrs/predecessors_map) rather than re-running the
  per-arch regexes on every visit; walk with a backtracking Set instead of
  copying visited/path each step; precompile the terminal-call regex.
- ARM emulator: cache the libc bytes instead of re-reading the file for every
  emulator instance.

~37% faster on the integration specs.

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

* perf: cut branch-aware setup cost on large libcs

The previous pass eagerly classified every disassembly line (line_kinds /
predecessors_map) — costly on big libcs (aarch64 2.43 has 310k lines). Rework
so the whole-binary scan is cheap and the per-line classification only runs
where the walk actually goes. Behaviour-preserving.

- predecessors: computed lazily and memoized (the walk only visits lines near
  terminal calls), instead of precomputing for all lines.
- branch_pred_map: reject the ~99% non-branch lines with one precompiled
  per-arch regex (branch_lead_regex) before the full test, and drop the
  mnemonic memoization that was allocating an entry per line during this scan.
- disasm_cache: cache objdump output by command so re-analysing the same file
  (the specs load some libcs several times; the CLI reads a file once) skips
  the disassembly.

~45% faster on the full suite (60.7s -> 33.6s here).

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

* perf: windowed disassembly for arm (byte-scan the calls)

A full objdump of a Thumb-2 arm libc is ~4x slower than amd64/aarch64
(~1.1s vs ~0.3s), yet the branch-aware search only ever touches ~0.35% of
the disassembly — everything it needs sits within ~0x1246 bytes of an
exec*/posix_spawn* call (measured max: 0x98b backward span + 0x8bb branch-in
across real amd64/aarch64/arm libcs).

So for arm we locate the calls *without* disassembling: take the
exec*/posix_spawn* addresses from the symbol table and scan .text for
A32/Thumb `BL` instructions branching to them, then objdump only a 0x2000
window around each (merging overlaps). The scan is exact for the direct
`bl <exec*>` calls the search cares about — validated to miss none of the
objdump-grep sites on the 2.23/2.27/2.39 fixtures (a rare false positive just
adds an empty window); if it finds nothing we fall back to a full disassembly.

Behaviour-preserving: gadget output is identical (the arm integration specs
assert exact lists). Single-libc time drops ~52-57% (2.39: 2.84s -> 1.32s;
2.23: 1.70s -> 0.73s). Only arm opts in — the fast-to-objdump arches keep the
exhaustive full scan.

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

* refactor: one branch_kind per arch instead of four predicates

A new architecture had to implement four overlapping methods to classify a
line for the CFG walk (conditional_branch?, unconditional_branch?, path_ends?,
branch_lead_regex). Collapse them into one intuitive classifier:

  branch_kind(line) -> :conditional | :unconditional | :terminator | nil

Base derives the predicates it needs from it and builds branch_lead_regex from
a tiny branch_lead_chars hint. No behavior change.

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

* refactor: share compare/operand helpers across emulators

handle_compare, operand_str and mnemonic were byte-for-byte identical in the
aarch64/arm/x86 emulators (x86's `test ? :test : :cmp` is just `mnem.to_sym`).
Move them into the shared Conditional module so a new arch only supplies the
genuinely arch-specific parts: which mnemonics are compares/branches
(branch_mnem?), how to split operands (operands), and how a branch maps to a
queued decision (handle_branch). No behavior change.

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

* docs: guide for adding a new architecture

The arch surface is spread over a Fetcher and an Emulator subclass; collect the
full contract in one place (docs/adding-an-architecture.md) and point the two
base classes at it, so it's clear what a new arch must implement.

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

* docs: add ruby lang hint to code block

* style: keep lib comments ASCII, enforce via rubocop

* docs: add @example usage to Conditional methods

* docs: prefer example/param tags over inline e.g.

* refactor: name branch conditions by predicate, adapters per arch

* refactor: share ARM-family emulator logic via ArmFamily module

* emulators: model each compare's ALU op, not just cmp

Keys compares by ALU op (:sub/:add/:and) via each arch's COMPARES, dropping the
:cmp special case so a new arch just declares its compare mnemonics. Fixes the
test/tst mis-rendering this exposed: test r,r; jne rendered r != r (a false
contradiction that dropped valid gadgets), test r,r; jz an unsound r == r
tautology, and cmn now allows ordered conditions. Recovers gadgets on amd64
2.19/2.31.

* emulators: tighten Conditional per review

Rename COMPARE -> COMPARE_OPS so it no longer collides with the arch COMPARES
mnemonic map; co-locate each op's renderer in the table and dispatch via fetch,
so an unknown op raises loudly instead of yielding a nil constraint. Document
Conditional's required host interface and branch_lead_chars' regex-safety contract.

* docs: add class + pipeline diagrams to the arch guide

* refactor: rename queue_* branch helpers to branch_on_*

queue_cond_branch -> branch_on_compare, queue_cbz -> branch_on_zero,
queue_tbz -> branch_on_bit. The names now describe what the branch tests
(a recorded compare / a register being zero / a single bit) instead of the
ARM mnemonic, and drop the misleading 'queue' (there is only one pending
branch, not a queue).

* docs: keep Conditional statements arch-neutral

Move arch mnemonics (cmp/cbz/b.ne/jcc/tst/...) out of the descriptive prose,
param and return docs into the @example blocks; the statements now say what each
method does and why in arch-independent terms, and the render_* / COMPARE_OPS
comments are labelled by ALU op rather than mnemonic.

---------

Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-25 10:20:47 +08:00

129 lines
5.2 KiB
Ruby

# frozen_string_literal: true
require 'one_gadget/emulators/aarch64'
require 'one_gadget/error'
describe OneGadget::Emulators::AArch64 do
before(:each) do
@processor = described_class.new
end
describe 'conditional branches' do
# Emit the branch decision by feeding cmp, the branch, then a follow-up line
# whose address decides taken (== target) vs not-taken (!= target).
def branch_constraint(compare, branch, target, follow_addr)
@processor.process("1000: #{compare}") if compare
@processor.process("1004: #{branch} #{format('%x', target)} <x>")
@processor.process("#{format('%x', follow_addr)}: nop")
@processor.constraints
end
it 'renders cmp + b.ne / b.eq' do
expect(branch_constraint('cmp x2, #1', 'b.ne', 0x2000, 0x1008)).to eq ['x2 == 0x1'] # not taken
@processor = described_class.new
expect(branch_constraint('cmp x2, #1', 'b.ne', 0x2000, 0x2000)).to eq ['x2 != 0x1'] # taken
@processor = described_class.new
expect(branch_constraint('cmp x0, x1', 'b.eq', 0x2000, 0x2000)).to eq ['x0 == x1'] # taken
end
it 'falls back for an unparseable compare operand' do
expect(@processor.send(:operand_str, 'somelabel')).to eq 'somelabel'
end
it 'renders signed and unsigned comparisons' do
expect(branch_constraint('cmp x0, #0x400', 'b.cs', 0x2000, 0x2000)).to eq ['(u64)x0 >= 0x400'] # taken
@processor = described_class.new
expect(branch_constraint('cmp x0, #0x400', 'b.lt', 0x2000, 0x1008)).to eq ['(s64)x0 >= 0x400'] # not taken
end
it 'renders cbz / cbnz' do
expect(branch_constraint(nil, 'cbz x0,', 0x2000, 0x1008)).to eq ['x0 != 0'] # not taken
@processor = described_class.new
expect(branch_constraint(nil, 'cbnz x1,', 0x2000, 0x2000)).to eq ['x1 != 0'] # taken
end
it 'renders tbz / tbnz bit tests' do
expect(branch_constraint(nil, 'tbz w3, #4,', 0x2000, 0x2000)).to eq ['(w3 & 0x10) == 0'] # taken
@processor = described_class.new
expect(branch_constraint(nil, 'tbnz w3, #0,', 0x2000, 0x1008)).to eq ['(w3 & 0x1) == 0'] # not taken
end
it 'aborts a cmp-based branch with no preceding compare' do
expect(@processor.process('1004: b.ne 2000 <x>')).to be false
end
it 'uses eq/ne but rejects magnitude conditions after tst' do
# tst x0,x0 sets ZF = (x0 == 0); b.eq taken means x0 == 0.
expect(branch_constraint('tst x0, x0', 'b.eq', 0x2000, 0x2000)).to eq ['x0 == 0'] # taken
# tst with distinct operands is a real bitmask test.
@processor = described_class.new
expect(branch_constraint('tst x0, x1', 'b.ne', 0x2000, 0x2000)).to eq ['(x0 & x1) != 0'] # taken
@processor = described_class.new
expect(@processor.process('1000: tst x0, x0')).to be true
expect(@processor.process('1004: b.lt 2000 <x>')).to be false # magnitude after tst: unsound
end
end
describe 'process' do
it 'libc-2.23 gadget' do
gadget = <<-EOS
3d718: adrp x2, 140000 <h_errlist@@GLIBC_2.17+0x8b0>
3d71c: adrp x0, 117000 <in6addr_any@@GLIBC_2.17+0x2680>
3d720: mov x1, x22
3d724: add x0, x0, #0x9a0
3d728: str wzr, [x20, #4]
3d72c: ldr x2, [x2, #3728]
3d730: ldr x2, [x2]
3d734: bl 9b1b0 <execve@@GLIBC_2.17>
EOS
gadget.each_line { |s| @processor.process(s) }
expect(@processor.registers['x0'].to_s).to eq '$base+0x1179a0'
expect(@processor.registers['x1'].to_s).to eq 'x22'
expect(@processor.registers['x2'].to_s).to eq '[[$base+0x140e90]]'
expect(@processor.registers['pc'].to_s).to eq '9b1b0 <execve@@GLIBC_2.17>'
end
it 'ldr' do
@processor.process('ldr x0, [x1, #8]!')
expect(@processor.registers['x0'].to_s).to eq '[x1+0x8]'
expect(@processor.registers['x1'].to_s).to eq 'x1+0x8'
@processor.process('ldr x2, [x3], #8')
expect(@processor.registers['x2'].to_s).to eq '[x3]'
expect(@processor.registers['x3'].to_s).to eq 'x3+0x8'
end
it 'stp' do
@processor.process('stp x2, x3, [sp, #16]')
expect(@processor.registers['sp'].to_s).to eq 'sp'
expect(@processor.sp_based_stack[16].to_s).to eq 'x2'
expect(@processor.sp_based_stack[24].to_s).to eq 'x3'
# equivalent to 'push x1; push x0'
@processor.process('stp x0, x1, [sp, #-16]!')
expect(@processor.registers['sp'].to_s).to eq 'sp-0x10'
expect(@processor.sp_based_stack[-8].to_s).to eq 'x1'
expect(@processor.sp_based_stack[-16].to_s).to eq 'x0'
end
it 'str' do
# post-index mode
@processor.process('mov x1, sp')
@processor.process('str x0, [x1], #-8')
expect(@processor.registers['sp'].to_s).to eq 'sp'
expect(@processor.registers['x1'].to_s).to eq 'sp-0x8'
expect(@processor.sp_based_stack[0].to_s).to eq 'x0'
@processor.process('str xzr, [sp, #200]')
expect(@processor.sp_based_stack[200]).to be_zero
@processor.process('str x2, [sp, #0x100]!')
expect(@processor.sp_based_stack[0x100].to_s).to eq 'x2'
expect(@processor.registers['sp'].to_s).to eq 'sp+0x100'
@processor.process('str x3, [x4]')
expect(@processor.constraints).to eq ['writable: x4']
end
end
end