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]>
This commit is contained in:
david942j
2026-07-25 10:20:47 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 45b78efef8
commit 4732208300
28 changed files with 1277 additions and 134 deletions
+3
View File
@@ -60,6 +60,9 @@ Style/AccessorGrouping:
Style/ArrayIntersect:
Enabled: false
Style/AsciiComments:
Enabled: true
Style/FormatStringToken:
Enabled: false
+4
View File
@@ -38,6 +38,10 @@ Note: requires ruby version >= 2.1.0, you can use `ruby --version` to check.
OneGadget uses symbolic execution to find the constraints of gadgets to be successful.
Gadgets are found by walking the control-flow graph backward from each `exec`/`posix_spawn`
call, following conditional branches both ways. When a gadget is only reachable if a branch is
(not) taken, that decision shows up as an extra constraint, e.g. `x2 == 0x1`.
The article introducing how I develop this tool can be found [on my blog](https://david942j.blogspot.com/2017/02/project-one-gadget-in-glibc.html).
## Usage
+4
View File
@@ -38,6 +38,10 @@ Note: requires ruby version >= 2.1.0, you can use `ruby --version` to check.
OneGadget uses symbolic execution to find the constraints of gadgets to be successful.
Gadgets are found by walking the control-flow graph backward from each `exec`/`posix_spawn`
call, following conditional branches both ways. When a gadget is only reachable if a branch is
(not) taken, that decision shows up as an extra constraint, e.g. `x2 == 0x1`.
The article introducing how I develop this tool can be found [on my blog](https://david942j.blogspot.com/2017/02/project-one-gadget-in-glibc.html).
## Usage
+172
View File
@@ -0,0 +1,172 @@
# Adding a new architecture
Supporting an architecture means two classes plus a little wiring. The cleanest
existing example to copy is **aarch64** (`lib/one_gadget/fetchers/aarch64.rb` and
`lib/one_gadget/emulators/aarch64.rb`).
```ruby
OneGadget::Fetcher::<Arch> < OneGadget::Fetcher::Base # find candidates, describe constraints
OneGadget::Emulators::<Arch> < OneGadget::Emulators::Processor # symbolically execute a candidate
```
The engine (candidate discovery via a control-flow walk, constraint solving,
gadget trimming) lives in the base classes. Each architecture only fills in the
parts that differ.
## At a glance
**Who owns what.** Two parallel hierarchies (fetcher, emulator); the engine base
classes carry the shared logic, and each arch subclass fills in the boxes marked
_you implement_. `arm`/`aarch64` also mix in `ArmFamily` to share their common
pieces; `x86` keeps its own. The fetcher reaches its emulator through `emulator()`.
```mermaid
flowchart LR
classDef engine fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,font-family:monospace
classDef arch fill:#dcfce7,stroke:#16a34a,color:#14532d,font-family:monospace
classDef mixin fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,font-family:monospace
subgraph FE["Fetcher side: find candidate sequences"]
direction TB
FB["Fetcher::Base<br/>(engine)"]:::engine
FA["Fetcher::&lt;Arch&gt;<br/>(you implement)"]:::arch
FB -->|inherits| FA
end
subgraph EM["Emulator side: execute one candidate"]
direction TB
EP["Emulators::Processor<br/>(engine)"]:::engine
CO["module Conditional"]:::mixin
AF["module ArmFamily"]:::mixin
EA["Emulators::&lt;Arch&gt;<br/>(you implement)"]:::arch
EP -->|inherits| EA
CO -.->|mixed into| EP
AF -.->|"mixed into (arm, aarch64)"| EA
end
FA ==>|"emulator()"| EA
```
Legend: 🟦 engine (shared base) · 🟩 what you implement · 🟪 shared mixin.
What each box is for:
- **`Fetcher::Base`** — the engine: walks the CFG for candidate sequences, then solves and trims gadgets.
- **`Fetcher::<Arch>`** — the arch's disassembly, its string/branch recognition, and how to build its emulator.
- **`Emulators::Processor`** — the engine: parse an instruction, track register/stack state, collect constraints.
- **`module Conditional`** — shared compare/branch machinery; a crossed branch becomes a gadget constraint.
- **`module ArmFamily`** — logic shared by arm and aarch64 (calling convention, memory model, condition codes).
- **`Emulators::<Arch>`** — emulate each instruction, plus the calling convention and register model.
**Why each piece exists.** The runtime flow shows where your methods are called
and what they feed:
```mermaid
flowchart TD
classDef entry fill:#fef9c3,stroke:#ca8a04,color:#713f12,font-family:monospace
classDef engine fill:#dbeafe,stroke:#2563eb,color:#1e3a8a,font-family:monospace
classDef arch fill:#dcfce7,stroke:#16a34a,color:#14532d,font-family:monospace
classDef mixin fill:#ede9fe,stroke:#7c3aed,color:#4c1d95,font-family:monospace
classDef out fill:#ffe4e6,stroke:#e11d48,color:#881337,font-family:monospace
A["OneGadget.gadgets(file)"]:::entry --> B["Fetcher::&lt;Arch&gt;#find"]:::arch
B --> C["Base#candidates<br/>(backward CFG walk)"]:::engine
C --> D{"for each start instruction"}
D --> E["Base#emulate<br/>(process! per instruction)"]:::engine
E --> F["module Conditional<br/>(per instruction)"]:::mixin
E --> G["arch inst_* handlers"]:::arch
F --> H["branch → constraint"]:::mixin
D --> I["Base#resolve<br/>(argv / envp checks)"]:::engine
H --> J["Gadget<br/>(offset + constraints)"]:::out
I --> J
```
Reading the flow:
- **`candidates`** walks the CFG backward from each `exec*`/`posix_spawn*` call to build candidate sequences.
- **`find`** emulates every start instruction of each candidate, one instruction at a time.
- **`Conditional`** runs first on each instruction (`resolve_pending_branch` → `handle_compare` → `handle_branch` → `branch_on_*`), turning a crossed branch into a constraint.
- the arch's **`inst_*`** handlers then update the register/stack state.
- **`resolve`** reads the call's arguments (`argument`, `str_bin_sh?`, `global_var?`) and builds the argv/envp constraints.
- finally, a contradiction drops the gadget and a tautology is stripped.
## The fetcher — `Fetcher::<Arch> < Fetcher::Base`
| Method | What it returns |
| --- | --- |
| `emulator` | a fresh `Emulators::<Arch>` instance |
| `call_str` | the call mnemonic that reaches a function — `'bl'` (arm/aarch64), `'call'` (x86) |
| `str_bin_sh?(operand)` | does this operand reference the `"/bin/sh"` string? |
| `str_sh?(operand)` | does it reference the standalone `"sh"` string? |
| `global_var?(operand)` | does it reference a libc global (i.e. is it `$base`-relative)? |
| `branch_kind(line)` | classify an instruction: `:conditional`, `:unconditional`, `:terminator`, or `nil` (not a branch) |
| `branch_lead_chars` | the leading character(s) of this arch's branch mnemonics, e.g. `'bct'` or `'j'` (a cheap filter) |
`branch_kind` is the one method that drives the control-flow walk:
* `:conditional` — may or may not be taken; both edges are explored and the
decision becomes a gadget constraint (e.g. `x2 == 0x1`).
* `:unconditional` — always taken, to a determined target.
* `:terminator` — ends the path (a return or an indirect/computed jump).
* `nil` — not a branch; execution falls through to the next instruction.
Optional hooks (sensible defaults in `Base`):
| Method | Default | Purpose |
| --- | --- | --- |
| `objdump_options` | `[]` | extra objdump flags, e.g. x86 returns `%w[-M intel]` |
| `terminal_call_sites` | `nil` | if the calls can be located cheaply *without* disassembling everything, return their addresses for windowed disassembly (a big win when a full objdump is slow, as on Thumb-2 arm). `nil` = disassemble the whole file. See `Fetcher::Arm`. |
## The emulator — `Emulators::<Arch> < Emulators::Processor`
| Method | What it does |
| --- | --- |
| `initialize` | `super(OneGadget::ABI.<arch>, sp_name)`, set `@pc`, define constant registers |
| `instructions` | the `Instruction`s this arch models (anything else aborts the path) |
| `process!(cmd)` | emulate one instruction (see the pattern below) |
| `argument(idx)` | the value of the idx-th call argument (the calling convention) |
| `get_corresponding_stack(obj)` | the sp/bp-based stack hash `obj` addresses, or `nil` |
| `self.bits` | `32` or `64` |
| `operands(cmd)` | split an instruction into its operand strings |
| `branch_mnem?(mnem)` | is `mnem` a branch this emulator handles? |
| `handle_branch(mnem, cmd)` | turn a branch into a pending decision (via the `branch_on_*` helpers below) |
| `inst_<name>(...)` | one handler per supported instruction |
`process!` follows a fixed shape — resolve any pending branch, handle a
compare/branch, otherwise parse and dispatch:
```ruby
def process!(cmd)
resolve_pending_branch(cmd) # from Conditional
mnem = mnemonic(cmd) # from Conditional
return handle_compare(COMPARES[mnem], cmd) if COMPARES.key?(mnem) # from Conditional
return handle_branch(mnem, cmd) != :fail if branch_mnem?(mnem)
inst, args = parse(cmd)
__send__(:"inst_#{inst.inst}", *args) != :fail
end
```
The **`Emulators::Conditional`** module (already included in `Processor`) provides
the branch machinery so you don't reimplement it: `record_compare`,
`handle_compare`, `branch_on_compare`, `branch_on_zero`, `branch_on_bit`,
`resolve_pending_branch`, `operand_str`, `mnemonic`. You supply two small adapter
tables that map your arch's mnemonics onto the shared vocabulary (directly, or via
a shared base — arm/aarch64 share theirs through `ArmFamily`):
* **`COMPARES`** — compare mnemonic → ALU op (a `Conditional::COMPARE_OPS` key:
`:sub` for `cmp`, `:add` for `cmn`, `:and` for `tst`/`test`); `handle_compare`
reads it.
* **`COND`** (x86 calls it `JCC`) — branch mnemonic → predicate (a
`Conditional::RELATION` key, e.g. `:ne`, `:uge`). Your `handle_branch` parses the
instruction and calls the right `branch_on_*`, passing this predicate to `branch_on_compare`.
## Wiring
* `OneGadget::Helper.architecture` — map the ELF machine string to your arch symbol.
* `OneGadget::Fetcher.from_file` — add the arch symbol → `Fetcher::<Arch>` entry.
* `OneGadget::Helper.arch_specific_objdump` — the cross objdump binary name.
## Tests
Add a `spec/one_gadget_<arch>_spec.rb` that runs `OneGadget.gadgets` against a
real libc fixture and asserts the exact gadget offsets, plus emulator unit tests
in `spec/emulators/<arch>_spec.rb`.
+36 -6
View File
@@ -21,7 +21,13 @@ module OneGadget
# @see OneGadget::Emulators::X86#process!
def process!(cmd)
inst, args = parse(cmd.gsub(/#-?(0x)?[0-9a-f]+/) { |v| v[1..] })
resolve_pending_branch(cmd)
cmd = cmd.gsub(/#-?(0x)?[0-9a-f]+/) { |v| v[1..] }
mnem = mnemonic(cmd)
return handle_compare(COMPARES[mnem], cmd) if COMPARES.key?(mnem)
return handle_branch(mnem, cmd) != :fail if branch_mnem?(mnem)
inst, args = parse(cmd)
sym = :"inst_#{inst.inst}"
__send__(sym, *args) != :fail
end
@@ -49,6 +55,27 @@ module OneGadget
private
def branch_mnem?(mnem)
mnem == 'b' || mnem.start_with?('b.') || %w[cbz cbnz tbz tbnz].include?(mnem)
end
# Operands of +cmd+ (mnemonic dropped), each stripped of a trailing +<symbol>+.
def operands(cmd)
cmd.sub(/\A[0-9a-f]+:\s*\S+\s*/, '').split(',').map { |o| o.strip.sub(/\s*<.*>\z/, '') }
end
def handle_branch(mnem, cmd)
ops = operands(cmd)
case mnem
when 'b', 'b.al' then true # unconditional: control handled by the stitched path
when 'cbz' then branch_on_zero(ops[1].to_i(16), operand_str(ops[0]), negate: false)
when 'cbnz' then branch_on_zero(ops[1].to_i(16), operand_str(ops[0]), negate: true)
when 'tbz' then branch_on_bit(ops[2].to_i(16), operand_str(ops[0]), Integer(ops[1]), negate: false)
when 'tbnz' then branch_on_bit(ops[2].to_i(16), operand_str(ops[0]), Integer(ops[1]), negate: true)
else branch_on_compare(COND[mnem[2..]], ops[0].to_i(16))
end
end
def inst_add(dst, src, op2, mode = 'sxtw')
check_register!(dst)
@@ -92,11 +119,14 @@ module OneGadget
raise_unsupported('stp', reg1, reg2, dst) unless reg64?(reg1) && reg64?(reg2)
dst_l = arg_to_lambda(dst).ref!
raise_unsupported('stp', reg1, reg2, dst) unless dst_l.obj == sp && dst_l.deref_count.zero?
cur_top = dst_l.evaluate(eval_dict)
sp_based_stack[cur_top] = registers[reg1]
sp_based_stack[cur_top + size_t] = registers[reg2]
if dst_l.obj == sp && dst_l.deref_count.zero?
cur_top = dst_l.evaluate(eval_dict)
sp_based_stack[cur_top] = registers[reg1]
sp_based_stack[cur_top + size_t] = registers[reg2]
else
# Don't know where it points; just require it be writable (as inst_str does).
add_writable(dst_l)
end
registers[sp] += arg_to_lambda(dst).immi if dst.end_with?('!')
end
+42 -10
View File
@@ -21,20 +21,30 @@ module OneGadget
def initialize(file = nil)
super(OneGadget::ABI.arm, 'sp')
@pc = 'pc'
@data = file && File.binread(file)
# find() builds a fresh emulator per candidate; cache the file's bytes so
# the literal pool isn't re-read from disk thousands of times.
@data = file && self.class.file_data(file)
@prev_addr = nil
# A32 until proven Thumb by a +.w+/+.n+ suffix or a 2-byte instruction stride.
@thumb = false
end
# Memoized bytes of +file+ (the target libc), shared across emulator instances.
def self.file_data(file)
(@file_data ||= {})[file] ||= File.binread(file)
end
# @see OneGadget::Emulators::AArch64#process!
def process!(cmd)
resolve_pending_branch(cmd)
line = cmd.strip
track_mode(line)
body, @literal = split_line(line)
body = normalize(body)
# push/pop take a {reg-list} whose commas would confuse the generic parser.
mnem, rest = body.split(/\s+/, 2)
return handle_compare(COMPARES[mnem], rest) if COMPARES.key?(mnem)
return handle_branch(mnem, rest) != :fail if branch_mnem?(mnem)
# push/pop take a {reg-list} whose commas would confuse the generic parser.
return __send__(:"inst_#{mnem}", rest) != :fail if %w[push pop].include?(mnem)
inst, args = parse(body)
@@ -105,7 +115,7 @@ module OneGadget
# Rewrite one instruction into the plain form the generic parser expects:
# drop the +.w+/+.n+ width suffix, map the flag-setting aliases we support
# (+movs+/+adds+/+subs+) back to their base mnemonic, and strip the +#+ that
# prefixes ARM immediates. Other conditional/flag variants (e.g. +moveq+) are
# prefixes ARM immediates. Other conditional/flag variants (+moveq+, +addne+, ...) are
# left intact so they fall through to "unsupported".
# @example
# normalize('movs r0, #0')
@@ -159,8 +169,7 @@ module OneGadget
return unless src.end_with?('!') || index.nonzero?
# pre-index ([reg, imm]!) or post-index ([reg], imm) write-back.
lmda = OneGadget::Emulators::Lambda.parse(resolve_int_regs(src.delete('!')))
registers[lmda.obj] += lmda.immi + index
writeback(src, index)
end
def inst_str(src, dst, index = 0)
@@ -177,8 +186,13 @@ module OneGadget
index = Integer(index)
return unless dst.end_with?('!') || index.nonzero?
lmda = OneGadget::Emulators::Lambda.parse(resolve_int_regs(dst.delete('!')))
registers[lmda.obj] += lmda.immi + index
writeback(dst, index)
end
# Pre-/post-index write-back of a +[reg, imm]+ memory operand to +reg+.
def writeback(mem, index)
lmda = OneGadget::Emulators::Lambda.parse(resolve_int_regs(mem.delete('!')))
registers[lmda.obj] += lmda.immi + index if register?(lmda.obj)
end
# push {r4, r5, lr}: registers are stored with the lowest-numbered at the
@@ -204,11 +218,29 @@ module OneGadget
# Flag-only / no-effect instructions: keep emulating without changing state.
def inst_nop(*); end
alias inst_cmp inst_nop
alias inst_cmn inst_nop
alias inst_tst inst_nop
alias inst_svc inst_nop
def branch_mnem?(mnem)
return true if mnem == 'b' || %w[cbz cbnz].include?(mnem)
# +vs+/+vc+ aren't in COND but are still branches (they just abort the path).
mnem.start_with?('b') && (COND.key?(mnem[1..]) || %w[vs vc].include?(mnem[1..]))
end
def operands(rest)
rest.to_s.split(',').map { |o| o.strip.sub(/\s*<.*>\z/, '') }
end
def handle_branch(mnem, rest)
ops = operands(rest)
case mnem
when 'b' then true # unconditional: control handled by the stitched path
when 'cbz' then branch_on_zero(ops[1].to_i(16), operand_str(ops[0]), negate: false)
when 'cbnz' then branch_on_zero(ops[1].to_i(16), operand_str(ops[0]), negate: true)
else branch_on_compare(COND[mnem[1..]], ops[0].to_i(16))
end
end
# Read the little-endian word the current PC-relative +ldr+ points at.
def literal_value
raise_unsupported('ldr', 'pc') if @literal.nil? || @data.nil?
+22 -4
View File
@@ -5,11 +5,29 @@ require 'one_gadget/emulators/lambda'
module OneGadget
module Emulators
# Behaviour shared by the two ARM-architecture emulators, {Arm} (AArch32) and
# {AArch64}: the parts of the calling convention and memory model that are
# identical in 32- and 64-bit mode. Included into each; everything that
# genuinely differs (instruction set, register width, +pc+ handling) stays in
# the individual classes.
# {AArch64}: the condition-code table plus the parts of the calling convention
# and memory model that are identical in 32- and 64-bit mode. Included into
# each; everything that genuinely differs (instruction set, register width,
# +pc+ handling) stays in the individual classes.
module ArmFamily
# ARM condition-code suffix (the +<cc>+ in +b<cc>+ / +b.<cc>+) mapped to a
# shared {Conditional::RELATION} predicate. Decodes the opaque mnemonics once:
# +hs+/+cs+ and +lo+/+cc+ are aliases; +mi+/+pl+ (sign bit) act as signed
# +</+>=+. +vs+/+vc+ (overflow) have no constraint form and are absent, so a
# branch on them maps to +nil+ and aborts the path. (x86 uses {X86::JCC}.)
COND = {
'eq' => :eq, 'ne' => :ne,
'hs' => :uge, 'cs' => :uge, 'lo' => :ult, 'cc' => :ult,
'hi' => :ugt, 'ls' => :ule,
'ge' => :sge, 'lt' => :slt, 'gt' => :sgt, 'le' => :sle,
'mi' => :slt, 'pl' => :sge
}.freeze
# This arch family's flag-setting compare mnemonics, mapped to the ALU op
# whose result their flags reflect (see {Conditional::COMPARE_OPS}). x86 has
# its own {X86::COMPARES}.
COMPARES = { 'cmp' => :sub, 'cmn' => :add, 'tst' => :and }.freeze
# The sp-based stack that +obj+ addresses, or +nil+ when +obj+ isn't sp-relative.
# @param [String, Lambda] obj A lambda object or its string.
# @return [Hash{Integer => Lambda}, nil]
+257
View File
@@ -0,0 +1,257 @@
# frozen_string_literal: true
require 'one_gadget/helper'
module OneGadget
module Emulators
# Shared modelling of compare instructions and conditional branches.
#
# A gadget candidate may cross a conditional branch: the fetcher stitches the
# actual taken/not-taken path (see {OneGadget::Fetcher::Base#candidates}), and
# the emulator turns the branch decision into a gadget constraint.
#
# Branches are resolved with one line of look-ahead: at the branch we record a
# pending decision, and on the next line we compare that line's address to the
# branch target to learn whether the stitched path took the branch.
#
# The including class (an {OneGadget::Emulators::Processor} subclass) must
# provide +registers+ and +register?+ (operand lookup), +operands(cmd)+ (the
# arch's operand splitter), +self.class.bits+ (32/64, for the signedness cast),
# and the +@flags+/+@pending+/+@constraints+ state {Processor#initialize} sets up.
#
# @example A +b.ne+ whose branch is *not* taken becomes the constraint +x2 == 0x1+
# # c7a4c: cmp x2, 1
# # c7a50: b.ne c7a9c # stitched path falls through (does not jump)
# # => emitted gadget constraint: x2 == 0x1
module Conditional
# Taken-semantics of each supported branch condition, keyed by a predicate
# named after the comparison it encodes (the LLVM +icmp+ names): a leading
# +u+ = unsigned, +s+ = signed. Value is +[relation, signedness]+, where
# signedness (+nil+/+:u+/+:s+) selects the operand cast.
# :eq :ne equality
# :ult :ule :ugt :uge unsigned < <= > >=
# :slt :sle :sgt :sge signed < <= > >=
RELATION = {
eq: ['==', nil], ne: ['!=', nil],
ult: ['<', :u], ule: ['<=', :u], ugt: ['>', :u], uge: ['>=', :u],
slt: ['<', :s], sle: ['<=', :s], sgt: ['>', :s], sge: ['>=', :s]
}.freeze
# Relation under the not-taken branch.
NEGATE = { '==' => '!=', '!=' => '==', '>=' => '<', '<' => '>=', '>' => '<=', '<=' => '>' }.freeze
# The flag-setting compares we model, keyed by the ALU operation the compare
# performs -- its flags reflect that result. Each entry says whether magnitude
# conditions (anything beyond +eq+/+ne+) are sound afterwards (+ordered+) and
# names the method that renders its constraint text. An arch maps its own
# mnemonics onto these ops (its +COMPARES+), so adding an arch needs no change
# here; adding a genuinely new ALU op means one entry plus its +render_*+ method.
COMPARE_OPS = {
sub: { ordered: true, render: :render_sub }, # subtraction: flags from lhs - rhs
add: { ordered: true, render: :render_add }, # addition: flags from lhs + rhs
and: { ordered: false, render: :render_and } # bitwise AND: flags from lhs & rhs (zero flag)
}.freeze
# Record a compare so a following conditional branch can be rendered.
# Normally reached through {#handle_compare}; call it directly only when an
# arch models a flag-setting instruction that {#handle_compare} doesn't cover.
# @param [Symbol] op The ALU operation the compare performs -- a {COMPARE_OPS}
# key (+:sub+/+:add+/+:and+). Its flags reflect this result, which decides
# both which branch conditions are expressible and how the constraint is
# rendered. Each arch maps its own mnemonics to these ops (their +COMPARES+);
# a new arch adds a {COMPARE_OPS} entry only for a genuinely new operation.
# @param [String] lhs Rendered left operand: a register's current value, or an immediate in hex.
# @param [String] rhs Rendered right operand.
# @return [true]
# @example Record +cmp x2, 0x1+ (subtraction), readying the next branch
# record_compare(:sub, '0x1', '0x1') #=> true
# # a following +b.ne+ not taken renders 0x1 == 0x1 (a stripped tautology)
def record_compare(op, lhs, rhs)
@flags = { op:, lhs:, rhs: }
true
end
# Model a compare line: record its two operands' current values under the
# compare's ALU op, so a following conditional branch can be rendered.
#
# Call this from +process!+ when the mnemonic is one of the arch's compares
# (its +COMPARES+ maps the mnemonic to the op), *before* dispatching to the
# +inst_*+ handlers.
# @param [Symbol] op The compare's ALU operation (a {COMPARE_OPS} key), which the
# arch resolves from the mnemonic.
# @param [String] cmd Passed to the arch's +operands+ splitter, so it is whatever
# that splitter expects (a full objdump line, or just its operand part).
# @return [true]
# @example Wiring in an arch's +process!+ (its +COMPARES+ holds the mnemonic map)
# return handle_compare(COMPARES[mnem], cmd) if COMPARES.key?(mnem)
# @example What it records for +cmp x2, 1+ (x2 currently 0x30)
# handle_compare(:sub, '4a1c0: cmp x2, 1') #=> true
# # records op :sub, lhs = 0x30, rhs = 0x1, ready for the next branch
def handle_compare(op, cmd)
lhs, rhs = operands(cmd)
record_compare(op, operand_str(lhs), operand_str(rhs))
end
# The mnemonic of an objdump line. Use it at the top of +process!+ to decide
# whether a line is a compare or a branch.
# @param [String] cmd One objdump line.
# @return [String] The mnemonic, or +''+ when the line has none.
# @example
# mnemonic('4a1c0: cmp x2, 1') #=> 'cmp'
# mnemonic('4a1d0: b.ne 4a200 <foo>') #=> 'b.ne'
# mnemonic('4a1d4: je 4a200') #=> 'je'
# mnemonic('') #=> ''
def mnemonic(cmd)
cmd[/\A[0-9a-f]+:\s*(\S+)/, 1] || ''
end
# Render an operand for a constraint: a register becomes its current value,
# an immediate becomes hex, anything else (a memory operand) stays as-is.
# {#handle_compare} uses it on each compare operand; call it yourself only
# when writing a bespoke +branch_on_*+ helper.
# @param [String] operand A single operand string from a compare/branch line.
# @return [String] The rendered operand.
# @example (assuming register +x2+ currently holds the immediate +0x1+)
# operand_str('x2') #=> '0x1' # a register -> its current value
# operand_str('0x40') #=> '0x40' # a hex immediate -> unchanged
# operand_str('16') #=> '0x10' # a decimal immediate -> hex
# operand_str('[sp+0x8]') #=> '[sp+0x8]' # a memory operand -> unchanged
def operand_str(operand)
return registers[operand].to_s if register?(operand)
OneGadget::Helper.hex(Integer(operand))
rescue ArgumentError
operand
end
# Register a branch on the last recorded compare's flags, resolved on the next
# line. Call it from +handle_branch+; +cond+ is the comparison predicate and
# +target+ is the branch's direct destination address.
# @param [Symbol] cond The comparison predicate, a {RELATION} key. Each arch
# first maps its own branch mnemonic to it via its adapter table.
# @param [Integer] target Destination address of the branch.
# @return [true, :fail] +:fail+ (abort the path) when it cannot be expressed
# soundly: no compare was seen, or a magnitude condition follows an
# equality-only compare (a {COMPARE_OPS} op with +ordered: false+).
# @example After +cmp x2, #1+, a following +b.ne 4a200+ (arch maps +b.ne+ to +:ne+)
# branch_on_compare(:ne, 0x4a200) #=> true
# # if the stitched path FALLS THROUGH (doesn't reach 0x4a200) the not-taken
# # relation of +:ne+ is emitted: x2 == 0x1 ; if it jumps there: x2 != 0x1
# @example No preceding compare -> the path is unsound and is aborted
# branch_on_compare(:ne, 0x4a200) #=> :fail # when @flags is nil
def branch_on_compare(cond, target)
return :fail if @flags.nil?
rel = RELATION[cond]
return :fail if rel.nil?
# A magnitude condition needs a compare whose flags reflect a full ordering
# (+:sub+/+:add+); an equality-only compare (+:and+) supports just eq/ne.
return :fail unless COMPARE_OPS.fetch(@flags[:op])[:ordered] || %i[eq ne].include?(cond)
op = @flags[:op]
lhs = @flags[:lhs]
rhs = @flags[:rhs]
@pending = { target:, render: ->(taken) { compare_constraint(op, lhs, rhs, rel, taken) } }
true
end
# Register a self-contained branch that tests a register against zero. It carries
# its own compare, so no preceding compare is needed. +negate:+ selects the sense:
# +false+ branches when the register is zero, +true+ when it isn't.
# @param [Integer] target Destination address of the branch.
# @param [String] reg The tested register, already rendered (as {#operand_str} returns).
# @param [Boolean] negate +false+ = branch when +reg+ is zero, +true+ = when it isn't.
# @example aarch64 +cbz x0, 4a200+ - branch taken when +x0 == 0+
# branch_on_zero(0x4a200, 'x0', negate: false) #=> true
# # fall-through path emits x0 != 0 ; taken path emits x0 == 0
# @example x86 reuses it for +jrcxz+/+jecxz+/+jcxz+ (always branch-if-zero)
# branch_on_zero(0x4a200, 'rcx', negate: false)
def branch_on_zero(target, reg, negate:)
hit = negate ? '!=' : '==' # taken (not negated) => reg == 0
miss = negate ? '==' : '!='
@pending = { target:, render: ->(taken) { "#{reg} #{taken ? hit : miss} 0" } }
true
end
# Register a self-contained branch that tests a single bit of a register: also
# carries its own test, so no preceding compare is needed. Renders a bitmask test.
# @param [Integer] target Destination address of the branch.
# @param [String] reg The tested register, already rendered (as {#operand_str} returns).
# @param [Integer] bit The bit index being tested.
# @param [Boolean] negate +false+ = branch when the bit is zero, +true+ = when it's set.
# @example aarch64 +tbz w0, #4, 4a200+ - branch taken when bit 4 of +w0+ is 0
# branch_on_bit(0x4a200, 'w0', 4, negate: false) #=> true
# # taken path emits (w0 & 0x10) == 0 ; fall-through emits (w0 & 0x10) != 0
def branch_on_bit(target, reg, bit, negate:)
mask = OneGadget::Helper.hex(1 << bit)
hit = negate ? '!=' : '=='
miss = negate ? '==' : '!='
@pending = { target:, render: ->(taken) { "(#{reg} & #{mask}) #{taken ? hit : miss} 0" } }
true
end
# Resolve the pending branch using +cmd+'s address: if it equals the branch
# target the stitched path took the branch, else it fell through. On
# resolution the rendered relation is appended to the gadget's constraints.
# Must be called at the top of +process!+ for every line (a no-op when nothing
# is pending), so the branch registered on the previous line sees this line's address.
# @param [String] cmd The current objdump line.
# @example The fixed first line of every arch's +process!+
# def process!(cmd)
# resolve_pending_branch(cmd)
# ...
# end
# # if the previous line did +branch_on_compare(:ne, 0x4a200)+ and this
# # +cmd+ sits at 0x4a200, the branch was taken; otherwise it fell through
def resolve_pending_branch(cmd)
return if @pending.nil?
taken = branch_addr(cmd) == @pending[:target]
@constraints << [:raw, @pending[:render].call(taken)]
@pending = nil
end
private
# Render the constraint a taken/not-taken branch imposes on the recorded
# compare: flip the operator via {NEGATE} on the not-taken edge, prefix the
# signedness cast, then defer to the op's +render_*+ method (see {COMPARE_OPS}).
# Raises +KeyError+ if +op+ isn't a known {COMPARE_OPS} entry.
# @param [Symbol] op The compare's ALU operation (a {COMPARE_OPS} key).
# @param [Array(String, Symbol)] rel The +[operator, signedness]+ from {RELATION}.
# @example +b.ne+ (not taken) after +cmp x2, 1+ -- a +:sub+ compare, 64-bit
# compare_constraint(:sub, 'x2', '0x1', ['!=', nil], false) #=> 'x2 == 0x1'
# @example +jne+ (taken) after +test eax, eax+ -- an +:and+ compare
# compare_constraint(:and, 'eax', 'eax', ['!=', nil], true) #=> 'eax != 0'
def compare_constraint(op, lhs, rhs, rel, taken)
operator, sign = rel
operator = NEGATE[operator] unless taken
cast = sign ? "(#{sign}#{self.class.bits})" : ''
__send__(COMPARE_OPS.fetch(op)[:render], cast, lhs, rhs, operator)
end
# Subtraction (+:sub+): a direct comparison of the two operands.
def render_sub(cast, lhs, rhs, op)
"#{cast}#{lhs} #{op} #{rhs}"
end
# Addition (+:add+): the flags reflect +lhs + rhs+, i.e. that sum compared against 0.
def render_add(cast, lhs, rhs, op)
"#{cast}(#{lhs} + #{rhs}) #{op} 0"
end
# Bitwise AND (+:and+): a bitmask test; identical operands collapse to a plain zero test.
def render_and(_cast, lhs, rhs, op)
lhs == rhs ? "#{lhs} #{op} 0" : "(#{lhs} & #{rhs}) #{op} 0"
end
# The address of an objdump line, used to tell whether the pending branch
# was taken.
# @example
# branch_addr('4a200: nop') #=> 0x4a200
def branch_addr(cmd)
cmd[/\A\s*([0-9a-f]+):/, 1].to_i(16)
end
end
end
end
+9 -2
View File
@@ -125,13 +125,20 @@ module OneGadget
if arg[0] == '['
ridx = arg.rindex(']')
immi = parse(arg[(ridx + 1)..])
lm = parse(arg[1...ridx], predefined:).deref
inner = parse(arg[1...ridx], predefined:)
# An absolute address (the +[0x1234]+ in +lea rax, [0x1234]+) parses to an Integer;
# model it as a based-nowhere Lambda so it can still be dereferenced.
inner = Lambda.new(nil).tap { |l| l.immi = inner } if inner.is_a?(Integer)
lm = inner.deref
lm += immi unless immi.zero?
return lm
end
base, disp = mem_obj(arg)
obj = predefined[base] || Lambda.new(base)
obj = predefined[base]
# A register may currently hold a call-target string (after a call);
# treat that as a fresh symbolic base. Lambda/Integer/xmm-array pass through.
obj = Lambda.new(base) if obj.nil? || obj.is_a?(String)
obj += disp unless disp.zero?
obj
end
+18 -2
View File
@@ -1,13 +1,21 @@
# frozen_string_literal: true
require 'one_gadget/emulators/conditional'
require 'one_gadget/emulators/lambda'
require 'one_gadget/error'
module OneGadget
# Instruction emulator to solve the constraint of gadgets.
module Emulators
# Base class of a processor.
# Base of the per-architecture instruction emulators, used to symbolically
# execute a candidate and solve its constraints. A subclass implements the
# arch's supported instructions, calling convention and stack model; the
# shared branch/compare machinery comes from {Conditional}.
#
# To add an architecture, see +docs/adding-an-architecture.md+.
class Processor
include Conditional
attr_reader :registers # @return [Hash{String => OneGadget::Emulators::Lambda}] The current registers' state.
attr_reader :sp_based_stack # @return [Hash{Integer => OneGadget::Emulators::Lambda}] Stack content based on sp.
attr_reader :sp # @return [String] Stack pointer.
@@ -22,6 +30,8 @@ module OneGadget
@registers = registers.to_h { |reg| [reg, to_lambda(reg)] }
@sp = sp
@constraints = []
@flags = nil # last compare, for a following conditional branch
@pending = nil # a conditional branch awaiting one-line-ahead resolution
@sp_based_stack = Hash.new do |h, k|
h[k] = OneGadget::Emulators::Lambda.new(sp).tap do |lmda|
lmda.immi = k
@@ -35,7 +45,13 @@ module OneGadget
# @return [(Instruction, Array<String>)]
# The parsing result.
def parse(cmd)
inst = instructions.find { |i| i.match?(cmd) }
# instructions is a constant set; build the array (and a mnemonic index)
# once instead of re-allocating it for every line.
@inst_index ||= instructions.each_with_object({}) { |i, h| h[i.inst] ||= i }
mnem = cmd[/\A[0-9a-f]+:\s*(\S+)/, 1] || cmd[/\A\s*(\S+)/, 1]
inst = @inst_index[mnem]
# Fall back to the original scan for any mnemonic that isn't a bare word.
inst ||= (@inst_list ||= instructions).find { |i| i.match?(cmd) }
raise Error::UnsupportedInstructionError, "Not implemented instruction in #{cmd}" if inst.nil?
[inst, inst.fetch_args(cmd)]
+60 -3
View File
@@ -32,14 +32,32 @@ module OneGadget
# @return [Boolean]
# If successfully processed.
def process!(cmd)
inst, args = parse(cmd)
# return registers[pc] = args[0] if inst.inst == 'call'
return true if inst.inst == 'jmp' # believe the fetcher has handled jmp.
resolve_pending_branch(cmd)
mnem = mnemonic(cmd)
return handle_compare(COMPARES[mnem], cmd) if COMPARES.key?(mnem)
return handle_branch(mnem, cmd) != :fail if branch_mnem?(mnem)
inst, args = parse(cmd)
sym = :"inst_#{inst.inst}"
__send__(sym, *args) != :fail
end
# x86 conditional-jump mnemonics mapped to shared {Conditional::RELATION}
# predicates (x86's adapter table, as {AArch64::COND} is for arm/aarch64).
JCC = {
'je' => :eq, 'jz' => :eq, 'jne' => :ne, 'jnz' => :ne,
'jb' => :ult, 'jc' => :ult, 'jnae' => :ult, 'jae' => :uge, 'jnb' => :uge, 'jnc' => :uge,
'ja' => :ugt, 'jnbe' => :ugt, 'jbe' => :ule, 'jna' => :ule,
'jl' => :slt, 'jnge' => :slt, 'jge' => :sge, 'jnl' => :sge,
'jg' => :sgt, 'jnle' => :sgt, 'jle' => :sle, 'jng' => :sle,
'js' => :slt, 'jns' => :sge
}.freeze
# x86 flag-setting compare mnemonics mapped to the ALU op whose result their
# flags reflect (see {Conditional::COMPARE_OPS}). +test+ is a bitwise AND, +cmp+
# a subtraction.
COMPARES = { 'cmp' => :sub, 'test' => :and }.freeze
# Supported instruction set.
# @return [Array<Instruction>] The supported instructions.
def instructions
@@ -84,6 +102,34 @@ module OneGadget
private
def branch_mnem?(mnem)
mnem == 'jmp' || JCC.key?(mnem) || %w[jcxz jecxz jrcxz].include?(mnem)
end
# Operands of +cmd+ (mnemonic dropped), size hints removed, each stripped of
# a trailing +<symbol>+.
def operands(cmd)
cmd.sub(/\A[0-9a-f]+:\s*\S+\s*/, '').split(',').map do |o|
o.gsub(/\b(XMMWORD|QWORD|DWORD|WORD|BYTE|PTR)\b/, '').strip.sub(/\s*<.*>\z/, '')
end
end
# The (direct) target address of a jump line.
def jump_target(cmd)
cmd[/\A[0-9a-f]+:\s*\S+\s+([0-9a-f]+)/, 1].to_i(16)
end
def handle_branch(mnem, cmd)
return true if mnem == 'jmp' # unconditional: control handled by the stitched path
return branch_on_zero(jump_target(cmd), cx_reg(mnem), negate: false) if mnem.end_with?('cxz')
branch_on_compare(JCC[mnem], jump_target(cmd))
end
def cx_reg(mnem)
{ 'jcxz' => 'cx', 'jecxz' => 'ecx', 'jrcxz' => 'rcx' }[mnem]
end
def inst_mov(dst, src)
src = arg_to_lambda(src)
if register?(dst)
@@ -105,6 +151,8 @@ module OneGadget
def inst_movaps(dst, src)
# XXX: here we only support `movaps [sp+*], xmm*`
src, dst = check_xmm_sp(src, dst) { raise_unsupported('movaps', dst, src) }
raise_unsupported('movaps', dst, src) unless src.is_a?(Array)
off = dst.evaluate(eval_dict)
@constraints << [:raw, "#{sp} & 0xf == #{0x10 - off & 0xf}"]
(128 / self.class.bits).times do |i|
@@ -120,10 +168,14 @@ module OneGadget
if self.class.bits == 64 && xmm_reg?(dst) && src.start_with?('r') && register?(src)
dst = arg_to_lambda(dst)
src = arg_to_lambda(src)
raise_unsupported('movq', dst, src) unless dst.is_a?(Array)
dst[0] = src
return
end
dst, src = check_xmm_sp(dst, src) { raise_unsupported('movq', dst, src) }
raise_unsupported('movq', dst, src) unless dst.is_a?(Array)
off = src.evaluate(eval_dict)
(64 / self.class.bits).times do |i|
dst[i] = sp_based_stack[off + i * size_t]
@@ -134,6 +186,8 @@ module OneGadget
def inst_movhps(dst, src)
# XXX: here we only support `movhps xmm*, [sp+*]`
dst, src = check_xmm_sp(dst, src) { raise_unsupported('movhps', dst, src) }
raise_unsupported('movhps', dst, src) unless dst.is_a?(Array)
off = src.evaluate(eval_dict)
(64 / self.class.bits).times do |i|
dst[i + 64 / self.class.bits] = sp_based_stack[off + i * size_t]
@@ -162,6 +216,8 @@ module OneGadget
dst = arg_to_lambda(dst)
src = arg_to_lambda(src)
raise_unsupported('punpcklqdq', dst, src) unless dst.is_a?(Array) && src.is_a?(Array)
(64 / self.class.bits).times do |i|
dst[i + 64 / self.class.bits] = src[i]
end
@@ -200,6 +256,7 @@ module OneGadget
end
def inst_sub(dst, src)
check_register!(dst)
src = arg_to_lambda(src)
raise Error::UnsupportedInstructionArgumentError, "Unhandled -= of type #{src.class}" unless src.is_a?(Integer)
+3 -1
View File
@@ -52,7 +52,9 @@ module OneGadget
(gadgets[j].constraints - g.constraints).empty?
end
end
res.sort_by(&:offset)
# The same offset can reach the call via more than one branch direction;
# list it once, keeping the easiest (fewest-constraint) variant.
res.group_by(&:offset).map { |_, gs| gs.min_by { |g| g.constraints.size } }.sort_by(&:offset)
end
end
extend ClassMethods
+13 -3
View File
@@ -7,15 +7,25 @@ module OneGadget
module Fetcher
# Define common methods for gadget fetchers.
class AArch64 < Base
# aarch64 condition codes (used to recognise +b.<cond>+ branches).
CONDS = %w[eq ne cs hs cc lo mi pl vs vc hi ls ge lt gt le].freeze
private
def emulator
OneGadget::Emulators::AArch64.new
end
# If str contains a branch instruction.
def branch?(str)
%w[b b.hi b.gt b.eq b.le b.ls b.lt b.ne b.cs].any? { |f| str.include?(" #{f} ") }
def branch_lead_chars
'bct'
end
def branch_kind(line)
m = mnemonic(line)
return :conditional if %w[cbz cbnz tbz tbnz].include?(m) || (m.start_with?('b.') && CONDS.include?(m[2..]))
return :unconditional if %w[b b.al].include?(m)
:terminator if %w[ret br braa brab braaz brabz].include?(m)
end
def call_str
+3 -30
View File
@@ -13,43 +13,16 @@ module OneGadget
OneGadget::Emulators::Amd64.new
end
# The branch-aware walker (see {Base#branch_aware_candidates}) already
# stitches +jmp+ targets, so only the filter remains.
def candidates
# one basic block case
cands = super do |candidate|
super do |candidate|
next true if candidate.include?('posix_spawn@')
next false unless candidate.include?(bin_sh_hex) # works in x86-64
next false unless candidate.lines.last.include?('execve') # only care execve
true
end
cands + jmp_case_candidates
end
# find gadgets in form:
# lea rdi, '/bin/sh'
# ...
# jmp xxx
# xxx:
# ...
# call execve
def jmp_case_candidates
`#{@objdump.command}|grep -E '# #{bin_sh_hex}' -A 8`.split('--').map do |cand|
cand = cand.lines.map(&:strip).reject(&:empty?)
jmp_at = cand.index { |c| c.include?('jmp') }
next nil if jmp_at.nil?
cand = cand[0..jmp_at]
next if cand.any? { |c| c.include?(call_str) }
jmp_addr = cand.last.scan(/jmp\s+([\da-f]+)\s/)[0][0].to_i(16)
dump = `#{@objdump.command(start: jmp_addr, stop: jmp_addr + 100)}|grep -E '[0-9a-f]+:'`
remain = dump.lines.map(&:strip).reject(&:empty?)
call_execve = remain.index { |r| r.match(/call.*<execve[^+]*>/) }
next if call_execve.nil?
remain = remain[0..call_execve]
[cand + remain].join("\n")
end.compact
end
def bin_sh_hex
+107 -15
View File
@@ -1,5 +1,7 @@
# frozen_string_literal: true
require 'elftools'
require 'one_gadget/emulators/arm'
require 'one_gadget/fetchers/base'
@@ -7,8 +9,92 @@ module OneGadget
module Fetcher
# Fetcher for 32-bit ARM (A32 / Thumb-2).
class Arm < Base
# Condition codes as branch suffixes (+bne+, +bcs+, ...).
CONDS = %w[eq ne cs hs cc lo mi pl vs vc hi ls ge lt gt le].freeze
private
# Locate the `bl` sites that call a terminal function *without* disassembling
# the whole libc - a full objdump of a Thumb-2 arm libc is ~4x slower than
# amd64/aarch64 (~1.1s vs ~0.3s), and only ~0.35% of it is ever needed.
#
# We take the exec*/posix_spawn* addresses from the symbol table and scan
# +.text+ for A32/Thumb `BL` instructions branching to them. This is exact
# for the direct `bl <exec*>` calls the search cares about (validated to
# match the objdump-grep sites on the arm-libc-2.23/2.27/2.39 fixtures);
# any false positive merely adds a harmless empty window, and if the scan
# finds nothing we fall back to a full disassembly.
def terminal_call_sites
File.open(file) do |f|
elf = ELFTools::ELFFile.new(f)
targets = terminal_symbol_addresses(elf)
return [] if targets.empty?
text = elf.section_by_name('.text')
return nil if text.nil?
scan_bl(text.header.sh_addr.to_i, text.data, targets)
end
rescue ELFTools::ELFError
nil # not something we can scan; disassemble everything
end
# Addresses (Thumb bit cleared) of exported/defined exec*/posix_spawn* funcs.
def terminal_symbol_addresses(elf)
addrs = {}
%w[.dynsym .symtab].each do |name|
sec = elf.section_by_name(name)
next if sec.nil?
sec.each_symbols do |sym|
value = sym.header.st_value.to_i
addrs[value & ~1] = true if value.nonzero? && sym.name =~ /\A(exec|posix_spawn)/
end
end
addrs
end
# Scan +data+ (loaded at +base+) for A32/Thumb BL branches into +targets+.
# Over-approximates (a stray word may decode to a BL into a target); that is
# harmless - it just adds an empty window - as long as no real BL is missed.
def scan_bl(base, data, targets)
halves = data.unpack('v*') # 16-bit little-endian halfwords
sites = halves.each_index.filter_map do |i|
addr = base + i * 2
addr if call_targets_at(halves, i, addr).any? { |t| targets.key?(t) }
end
sites.uniq
end
# Masked target(s) of any A32/Thumb BL that could begin at halfword +i+.
def call_targets_at(halves, i, addr)
targets = []
targets << (thumb_bl_target(addr, halves[i], halves[i + 1]) & ~1) if thumb_bl?(halves, i)
targets << a32_bl_target(addr, halves[i] | (halves[i + 1] << 16)) if i.even? && halves[i + 1]
targets.compact
end
def thumb_bl?(halves, i)
halves[i].between?(0xf000, 0xf7ff) && halves[i + 1]&.allbits?(0xd000)
end
def thumb_bl_target(addr, high, low)
s = (high >> 10) & 1
i1 = (~(((low >> 13) & 1) ^ s)) & 1
i2 = (~(((low >> 11) & 1) ^ s)) & 1
imm = (s << 24) | (i1 << 23) | (i2 << 22) | ((high & 0x3ff) << 12) | ((low & 0x7ff) << 1)
imm -= (1 << 25) if s == 1
(addr + 4 + imm) & 0xffffffff
end
def a32_bl_target(addr, word)
return nil unless ((word >> 24) & 0x0f) == 0x0b # cond|101|1 => BL
imm = (word & 0xffffff) << 2
imm -= (1 << 26) if imm.anybits?(1 << 25)
(addr + 8 + imm) & 0xffffffff
end
def emulator
OneGadget::Emulators::Arm.new(file)
end
@@ -63,26 +149,32 @@ module OneGadget
[disasm_lines[ldr_at], disasm_lines[add_at]]
end
# The target's full objdump disassembly as stripped +"ADDR: insn"+ lines,
# cached for the lifetime of the fetcher.
def disasm_lines
@disasm_lines ||= `#{@objdump.command}`.lines.map(&:strip).grep(/\A[0-9a-f]+:/)
def branch_lead_chars
'bct'
end
# Map from an instruction's address to its index in {#disasm_lines}, so a
# given address can be located in the disassembly in O(1).
def disasm_index
@disasm_index ||= disasm_lines.each_with_index.to_h { |line, i| [line[/\A([0-9a-f]+):/, 1].to_i(16), i] }
# +b+ is unconditional; +bne+/+beq+/... and Thumb +cbz+/+cbnz+ are
# conditional (+bl+/+blx+ are calls, not branches). +bx+/table branches and
# returns via +pop {..,pc}+ / +ldm .. {..,pc}+ / +mov pc,..+ / +ldr pc,..+
# terminate the path.
def branch_kind(line)
m = branch_mnemonic(line)
return :conditional if conditional_mnemonic?(m)
return :unconditional if m == 'b'
return :terminator if m.start_with?('bx') || %w[tbb tbh].include?(m)
return :terminator if %w[pop ldm ldmia ldmfd ldmdb].include?(m) && line.match?(/\bpc\b/)
:terminator if line.match?(/:\s*(mov|ldr)(\.[wn])?\s+pc\b/)
end
# If str contains a branch instruction. +bl+/+blx+ are calls, not branches.
def branch?(str)
mnem = (str[/\A\s*[0-9a-f]+:\s*(\S+)/, 1] || str.split.first || '').sub(/\.(w|n)\z/, '')
return false if mnem.start_with?('bl')
def conditional_mnemonic?(mnem)
return true if %w[cbz cbnz].include?(mnem)
mnem.match?(/\A(b|bx|cbn?z|tbb|tbh|
b(eq|ne|cs|hs|cc|lo|mi|pl|vs|vc|hi|ls|ge|lt|gt|le)|
bx(eq|ne|cs|hs|cc|lo|mi|pl|vs|vc|hi|ls|ge|lt|gt|le))\z/x)
!mnem.start_with?('bl') && mnem.start_with?('b') && CONDS.include?(mnem[1..])
end
def branch_mnemonic(line)
mnemonic(line).sub(/\.[wn]\z/, '')
end
def call_str
+281 -42
View File
@@ -5,12 +5,49 @@ require 'one_gadget/fetchers/objdump'
module OneGadget
module Fetcher
# Define common methods for gadget fetchers.
# Base of the per-architecture gadget fetchers. It discovers candidate
# instruction sequences - a backward control-flow walk from each
# +exec+/+posix_spawn+ call - and turns a solved candidate into a
# {OneGadget::Gadget::Gadget}. A subclass supplies only the arch-specific
# pieces (call mnemonic, string/global recognition, branch classification).
#
# To add an architecture, see +docs/adding-an-architecture.md+; +AArch64+ is
# the simplest example.
class Base
# The absolute path to glibc.
# @return [String] The filename.
attr_reader :file
# Give up on a control-flow path once it has crossed this many conditional branches.
MAX_FORKS = 4
# Hard cap on a single path's length (loop/runaway guard).
PATH_BUDGET = 80
# How much to disassemble around each terminal call when an architecture can
# locate the calls cheaply (see {#terminal_call_sites} / {#windowed_disasm}).
#
# The backward walk stays within {PATH_BUDGET} instructions and {MAX_FORKS}
# branch hops of the call, and a branch *into* that region comes from close
# by. Measured across real amd64/aarch64/arm libcs, the earliest line the
# walk reaches is <0x98b before the call and branches into it come from
# <0x8bb further - 0x1246 all told. WINDOW_BACK = 0x2000 leaves ~60% margin;
# a gadget whose code and branch-predecessors exceed it would be missed,
# which is why windowing is opt-in per arch (fast disassembly arches keep
# the full, exhaustive scan) and falls back to full disassembly if the call
# scan comes up empty.
WINDOW_BACK = 0x2000
# A little past the call, enough to cover the call instruction itself.
WINDOW_FWD = 0x80
# Cache values that are a deterministic function of an objdump command
# (its output and everything derived from it), so re-analysing the same
# file - common in the specs, harmless for the CLI which reads a file once -
# doesn't redo the disassembly or the whole-binary scans.
def self.cached(kind, command)
@cached ||= Hash.new { |h, k| h[k] = {} }
@cached[kind][command] ||= yield
end
# Instantiate a fetcher object.
# @param [String] file Absolute path to the target libc.
def initialize(file)
@@ -24,20 +61,41 @@ module OneGadget
# @return [Array<OneGadget::Gadget::Gadget>] Gadgets found.
def find
str_offset('/bin/sh') # ensure it's glibc-like; raises "not glibc?" if not found
candidates.map do |cand|
gadgets = []
# Overlapping candidate paths share tails, so the same suffix (a start line
# and everything after it) recurs across candidates; emulate each once.
seen = {}
candidates.each do |cand|
lines = cand.lines
# use processor to find which can lead to a valid one-gadget call.
gadgets = []
(lines.size - 2).downto(0) do |i|
processor = emulate(lines[i..])
options = resolve(processor)
next if options.nil? # impossible to be a gadget
suffix = lines[i..]
next if seen.key?(key = suffix.join)
offset = offset_of(lines[i])
gadgets << OneGadget::Gadget::Gadget.new(offset, **options)
seen[key] = true
gadget = resolve_suffix(suffix)
gadgets << gadget unless gadget.nil?
end
gadgets
end.flatten
end
gadgets
end
# Emulate a candidate suffix and turn it into a gadget, or +nil+ if it isn't one.
def resolve_suffix(lines)
processor = emulate(lines)
# resolve reads argument registers, which may not be evaluable on an
# exotic path; such a candidate simply isn't a gadget.
options = begin
resolve(processor)
rescue OneGadget::Error::Error
nil
end
return if options.nil?
# A branch that compares a value with itself yields a trivial condition:
# drop the gadget if it's unsatisfiable, else strip the always-true one.
return if options[:constraints].any? { |c| contradiction?(c) }
options[:constraints] = options[:constraints].reject { |c| tautology?(c) }
OneGadget::Gadget::Gadget.new(offset_of(lines.first), **options)
end
# Fetch candidates that end with call exec*.
@@ -50,22 +108,7 @@ module OneGadget
# @return [Array<String>]
# Each +String+ returned is multi-lines of assembly code.
def candidates(&)
call_regexp = "#{call_str}.*<(exec[^+]*|posix_spawn[^+]*)>$"
cands = []
`#{@objdump.command}|grep -E '#{call_regexp}' -B 30`.split('--').each do |cand|
lines = cand.lines.map(&:strip).reject(&:empty?)
# split with call_regexp
loop do
idx = lines.index { |l| l =~ /#{call_regexp}/ }
break if idx.nil?
cands << lines.shift(idx + 1).join("\n")
end
end
# remove all jmps
cands = slice_prefix(cands, &method(:branch?))
cands.select!(&) if block_given?
cands
branch_aware_candidates(&)
end
private
@@ -144,6 +187,10 @@ module OneGadget
# @param [OneGadget::Emulators::Lambda] lmda The parsed +argv_ptr+ (a stack register, zero dereference).
def check_stack_argv(processor, argv_ptr, lmda, allow_null)
stack = processor.get_corresponding_stack(lmda.obj)
# A stack register we don't track a stack for (the frame pointer):
# fall back to treating it as an opaque pointer.
return check_nonstack_argv(lmda.to_s, allow_null) if stack.nil?
argv = (0..3).map { |i| stack[lmda.immi + processor.class.bits / 8 * i].to_s }
# if argv is already valid, no constraints are needed! (but probably won't happen :p)
@@ -229,9 +276,13 @@ module OneGadget
return global_var?(envp_ptr) if envp_ptr.start_with?('[[')
lmda = OneGadget::Emulators::Lambda.parse(envp_ptr)
if lmda.deref_count.zero? && OneGadget::ABI.stack_register?(lmda.obj)
# A concrete integer or a stack register we don't track a stack for
# (the frame pointer) both fall through to the opaque-pointer case.
stack = lmda.is_a?(OneGadget::Emulators::Lambda) && lmda.deref_count.zero? &&
OneGadget::ABI.stack_register?(lmda.obj) &&
processor.get_corresponding_stack(lmda.obj)
if stack
# I haven't see this case after some tests, but just in case :)
stack = processor.get_corresponding_stack(lmda.obj)
envp = (0..3).map { |i| stack[lmda.immi + processor.class.bits / 8 * i].to_s }
# TODO: Handle the case when libc will write something into envp
cons = global_var?(envp[0]) ? nil : "#{envp_ptr} == NULL || {#{envp.join(', ')}, ...} is a valid envp"
@@ -268,6 +319,10 @@ module OneGadget
# Meet all constraints then posix_spawn eventually calls execve(path, argv, envp)
def resolve_posix_spawn(processor)
args = Array.new(6) { |i| processor.argument(i) }
# pid/file_actions/attrp are reasoned about as pointers (Lambdas); a
# concrete non-zero integer there is a fixed address we can't constrain.
return nil if [args[0], args[2], args[3]].any? { |a| a.is_a?(Integer) && !a.zero? }
res = resolve_execve_args(processor, args[1].to_s, args[4].to_s, args[5].to_s, allow_null_argv: false)
return nil if res.nil?
@@ -311,19 +366,6 @@ module OneGadget
[]
end
def slice_prefix(cands, &block)
cands.map do |cand|
lines = cand.lines
to_rm = lines[0...-1].rindex(&block)
lines = lines[to_rm + 1..] unless to_rm.nil?
lines.join
end
end
# If str contains a branch instruction.
def branch?(_str); raise NotImplementedError
end
def str_offset(str)
File.binread(file).index("#{str}\x00") ||
raise(Error::ArgumentError, "File #{file.inspect} doesn't contain string #{str.inspect}, not glibc?")
@@ -332,6 +374,203 @@ module OneGadget
def offset_of(assembly)
assembly.scan(/^([\da-f]+):/)[0][0].to_i(16)
end
# A single (non-disjunctive) branch condition comparing a value with
# itself: +X == X+ / +X >= X+ is always true; +X != X+ / +X < X+ never is.
def trivial_relation(con)
return nil if con.include?(' || ')
m = con.match(/\A(?:\([su]\d+\))?(.+?) (==|!=|<=|>=|<|>) (.+)\z/)
return nil unless m && m[1] == m[3]
%w[== >= <=].include?(m[2])
end
def tautology?(con)
trivial_relation(con) == true
end
def contradiction?(con)
trivial_relation(con) == false
end
# Regexp (as a String) matching an objdump line that calls a terminal
# function (+exec*+ / +posix_spawn*+) we can turn into a gadget.
def terminal_call_regexp
"#{call_str}.*<(exec[^+]*|posix_spawn[^+]*)>$"
end
# Enumerate gadget candidates by walking the control-flow graph *backward*
# from each terminal call along its predecessors (the instruction that falls
# through to it, plus any branch that targets it), forking at conditional
# edges up to {MAX_FORKS} times. Each emitted candidate is a flat,
# address-preserving line-list ending at the terminal call, consumed
# unchanged by {#find}; the emulator turns each crossed conditional edge into
# a constraint (see {OneGadget::Emulators::Conditional}).
def branch_aware_candidates(&)
cands = []
re = /#{terminal_call_regexp}/
disasm_lines.each_index do |idx|
next unless disasm_lines[idx].match?(re)
back_walk(idx, 0, Set.new, []) { |lines| cands << lines.join("\n") }
end
cands.uniq!
cands.select!(&) if block_given?
cands
end
# Depth-first backward walk. +visited+ (a Set) and +path+ are mutated with
# backtracking so a fork explores independently without per-step copies;
# +path+ is built in forward order (the terminal call stays last). Emits a
# candidate at each leaf - {#find} then tries every start line within it.
def back_walk(idx, forks, visited, path, &blk)
addr = disasm_addrs[idx]
return if path.size >= PATH_BUDGET
visited.add(addr)
path.unshift(disasm_lines[idx])
edges = predecessors(idx).reject do |pidx, cond|
visited.include?(disasm_addrs[pidx]) || (cond && forks >= MAX_FORKS)
end
if edges.empty?
blk.call(path.dup)
else
edges.each { |pidx, cond| back_walk(pidx, forks + (cond ? 1 : 0), visited, path, &blk) }
end
path.shift
visited.delete(addr)
end
# Address of each disassembly line, by index. Cached (per objdump command).
def disasm_addrs
@disasm_addrs ||= Base.cached(:addrs, @objdump.command) { disasm_lines.map { |l| offset_of(l) } }
end
# Predecessors of +disasm_lines[idx]+ as +[pred_index, conditional_edge?]+:
# the instruction that falls through to it, plus any branch that targets it.
# Computed lazily (the walk only touches lines near terminal calls) and cached.
def predecessors(idx)
(@predecessors ||= {})[idx] ||= begin
preds = []
if idx.positive?
kind = branch_kind(disasm_lines[idx - 1])
preds << [idx - 1, kind == :conditional] unless %i[unconditional terminator].include?(kind)
end
(branch_pred_map[disasm_addrs[idx]] || []).each do |b|
preds << [b, branch_kind(disasm_lines[b]) == :conditional]
end
preds
end
end
# Map of target-address => indexes of (conditional or unconditional) direct
# branches that jump there. Scans the whole disassembly once (a branch can
# target a call region from anywhere), so keep the per-line test cheap.
def branch_pred_map
@branch_pred_map ||= Base.cached(:branch_pred, @objdump.command) do
lead = branch_lead_regex
disasm_lines.each_index.with_object(Hash.new { |h, k| h[k] = [] }) do |i, map|
line = disasm_lines[i]
# Cheap precompiled reject (no per-line allocation) before the full test.
next unless line.match?(lead)
next unless %i[conditional unconditional].include?(branch_kind(line))
tgt = branch_target(line)
map[tgt] << i if tgt
end
end
end
# Precompiled over-approximation of a branch line, built from the arch's
# {#branch_lead_chars}, to skip the full test for the (majority) non-branch
# lines during the whole-binary scan.
def branch_lead_regex
@branch_lead_regex ||= /\A[0-9a-f]+:\s+[#{branch_lead_chars}]/
end
# Parse the (direct) target address of a branch line, or +nil+ if indirect.
def branch_target(line)
line.sub(/\A[0-9a-f]+:\s*\S+\s*/, '')[/\b([0-9a-f]+)\b\s*(?:<|\z)/, 1]&.to_i(16)
end
# The mnemonic of an objdump line, memoized because each line is tested by
# several branch predicates during the CFG scan.
# @example
# mnemonic('4a1d0: b.ne 4a200 <foo>') #=> 'b.ne'
# mnemonic('4a1c0: cbz x0, 4a200') #=> 'cbz'
def mnemonic(line)
(@mnemonic ||= {})[line] ||= line[/\A[0-9a-f]+:\s*(\S+)/, 1] || ''
end
# Classify a disassembly +line+ for the control-flow walk (arch-specific):
# :conditional - may or may not be taken; the walk explores both edges
# and turns the decision into a gadget constraint
# :unconditional - always taken, to a determined (direct) target
# :terminator - ends the path with no determined successor (a return or
# an indirect/computed jump)
# nil - not a branch; execution falls through to the next line
def branch_kind(_line); raise NotImplementedError
end
# The leading character(s) of this arch's branch mnemonics, used to build
# {#branch_lead_regex}. Must be plain regex-safe literals: they are spliced
# into a character class, so no +]+, +\+, +-+ or +^+.
# @example
# 'bct' # aarch64/arm: b, cbz/cbnz, tbz/tbnz
# 'j' # x86: jmp, jcc
def branch_lead_chars; raise NotImplementedError
end
# The target's full objdump disassembly as stripped +"ADDR: insn"+ lines,
# cached for the lifetime of the fetcher.
def disasm_lines
@disasm_lines ||= Base.cached(:disasm, @objdump.command) do
sites = terminal_call_sites
sites.nil? || sites.empty? ? full_disasm : windowed_disasm(sites)
end
end
# Disassemble the whole file (the exhaustive default).
def full_disasm
objdump_lines
end
# Disassemble only [call-WINDOW_BACK, call+WINDOW_FWD] around each call site,
# merging overlaps. Used when {#terminal_call_sites} located the calls without
# a full disassembly (the win on the slow-to-objdump Thumb-2 arm libcs).
def windowed_disasm(sites)
windows = sites.sort.map { |a| [[a - WINDOW_BACK, 0].max, a + WINDOW_FWD] }
merge_ranges(windows).flat_map { |lo, hi| objdump_lines(start: lo, stop: hi) }
end
# Merge a list of sorted [lo, hi] ranges, coalescing any that overlap.
def merge_ranges(ranges)
ranges.each_with_object([]) do |(lo, hi), merged|
if merged.last && lo <= merged.last[1]
merged.last[1] = hi if hi > merged.last[1]
else
merged << [lo, hi]
end
end
end
def objdump_lines(start: nil, stop: nil)
`#{@objdump.command(start:, stop:)}`.lines.map(&:strip).grep(/\A[0-9a-f]+:/)
end
# Addresses of `bl`/`call` sites reaching a terminal function, found without a
# full disassembly. +nil+ (the default) means the arch has no cheap finder,
# so the whole file is disassembled.
def terminal_call_sites
nil
end
# Map from an instruction's address to its index in {#disasm_lines}, so a
# given address can be located in the disassembly in O(1).
def disasm_index
@disasm_index ||= disasm_lines.each_with_index.to_h { |line, i| [line[/\A([0-9a-f]+):/, 1].to_i(16), i] }
end
end
end
end
+16 -3
View File
@@ -1,16 +1,29 @@
# frozen_string_literal: true
require 'one_gadget/emulators/x86'
require 'one_gadget/fetchers/base'
module OneGadget
module Fetcher
# Define common methods for gadget fetchers.
class X86 < Base
# Conditional-jump mnemonics (shared with the emulator).
JCC = OneGadget::Emulators::X86::JCC
private
# If str contains a branch instruction.
def branch?(str)
%w[jmp je jne jl jb ja jg].any? { |f| str.include?(f) }
def branch_lead_chars
'j'
end
# +jcc+/+jcxz+ are conditional; a direct +jmp+ is unconditional while an
# indirect one (+jmp rax+, +jmp QWORD PTR [..]+) and +ret+/+leave+ terminate.
def branch_kind(line)
m = mnemonic(line)
return :conditional if JCC.key?(m) || %w[jcxz jecxz jrcxz].include?(m)
return branch_target(line).nil? ? :terminator : :unconditional if m == 'jmp'
:terminator if m.start_with?('ret') || m == 'leave'
end
def objdump_options
+21
View File
@@ -105,9 +105,30 @@ module OneGadget
when / == NULL$/ then calculate_null_score(expr.sub(' == NULL', ''))
when / <= 0$/ then calculate_null_score(expr.sub(' <= 0', ''))
when / is a valid (argv|envp)$/ then 0.2 # This usually means the register has to be a readable pointer.
when / (==|!=|<=|>=|<|>) / then calculate_relation_score(expr) # a branch condition
end
end
# Score a branch-derived relational constraint such as +x2 == 0x1+ or
# +(u64)x0 >= 0x400+: an equality on one specific value is harder than an
# inequality/range, and a dereferenced left-hand side is harder still.
def calculate_relation_score(expr)
op = expr[/ (==|!=|<=|>=|<|>) /, 1]
lhs = expr.split(/ #{Regexp.escape(op)} /, 2).first.sub(/\A\([su]\d+\)/, '')
base = op == '==' ? 0.4 : 0.6
base * 0.9**lhs_deref_count(lhs)
end
# Dereference depth of a relation's left side, used to weight the score.
# A compound expression (e.g. +(a & b)+ from +tst+, +(a + b)+ from +cmn+) is
# not a plain lambda and has no dereference, so it scores as depth 0.
def lhs_deref_count(lhs)
lmda = OneGadget::Emulators::Lambda.parse(lhs)
lmda.is_a?(OneGadget::Emulators::Lambda) ? lmda.deref_count : 0
rescue OneGadget::Error::Error
0
end
def calculate_writable_score(identity)
lmda = OneGadget::Emulators::Lambda.parse(identity)
return 0.81 if lmda.deref_count != 0
+1 -1
View File
@@ -94,7 +94,7 @@ constraints:
skip_unless_objdump
expect { described_class.work(%w[--force --raw --level 1] + [libc_file]) }.to output(<<-EOS).to_stdout
324279 324286 324293 324386 939679 940120 940127 940131 1090444 1090456
323640 324279 324286 324293 324386 939679 939705 939712 940120 940127 940131 1090444 1090456
EOS
end
+56
View File
@@ -8,6 +8,62 @@ describe OneGadget::Emulators::AArch64 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
+29
View File
@@ -7,6 +7,35 @@ describe OneGadget::Emulators::Amd64 do
@processor = described_class.new
end
describe 'conditional branches' do
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 + jcc (Intel mnemonics)' do
expect(branch_constraint('cmp rax,0x1', 'jne', 0x2000, 0x1008)).to eq ['rax == 0x1'] # not taken
@processor = described_class.new
expect(branch_constraint('cmp rax,0x400', 'jb', 0x2000, 0x2000)).to eq ['(u64)rax < 0x400'] # taken
@processor = described_class.new
expect(branch_constraint('cmp rsi,rdi', 'jg', 0x2000, 0x2000)).to eq ['(s64)rsi > rdi'] # taken
end
it 'renders test (zero flag only) and jrcxz' do
# test eax,eax sets ZF = (eax == 0); je taken means eax == 0.
expect(branch_constraint('test eax,eax', 'je', 0x2000, 0x2000)).to eq ['eax == 0']
@processor = described_class.new
expect(branch_constraint(nil, 'jrcxz', 0x2000, 0x1008)).to eq ['rcx != 0'] # not taken
end
it 'aborts a magnitude branch after test' do
@processor.process('1000: test eax,eax')
expect(@processor.process('1004: jg 2000 <x>')).to be false
end
end
describe 'process' do
it 'libc-2.24 gadget' do
gadget = <<-EOS
+25
View File
@@ -8,6 +8,31 @@ describe OneGadget::Emulators::Arm do
@processor = described_class.new
end
describe 'conditional branches' do
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 + bne / beq (condition suffixes)' do
expect(branch_constraint('cmp r2, #1', 'bne', 0x2000, 0x1008)).to eq ['r2 == 0x1'] # not taken
@processor = described_class.new
expect(branch_constraint('cmp r0, r1', 'beq.n', 0x2000, 0x2000)).to eq ['r0 == r1'] # taken
end
it 'renders signed/unsigned and cbz' do
expect(branch_constraint('cmp r0, #0x400', 'bcs', 0x2000, 0x2000)).to eq ['(u32)r0 >= 0x400'] # taken
@processor = described_class.new
expect(branch_constraint(nil, 'cbz r0,', 0x2000, 0x1008)).to eq ['r0 != 0'] # not taken
end
it 'aborts a cmp-based branch with no preceding compare' do
expect(@processor.process('1004: bne 2000 <x>')).to be false
end
end
describe 'process' do
it 'libc-2.23 gadget (Thumb PIC /bin/sh idiom)' do
# ldr rX, [pc, #imm] loads a literal-pool word; add rX, pc turns it into a
+37
View File
@@ -0,0 +1,37 @@
# frozen_string_literal: true
require 'one_gadget/fetchers/arm'
describe OneGadget::Fetcher::Arm do
def fetcher(version)
OneGadget::Fetcher::Arm.new(data_path("arm-libc-#{version}.so"))
end
# Terminal-call sites found by disassembling the whole libc and grepping.
def objdump_sites(fetcher)
cmd = fetcher.instance_variable_get(:@objdump).command
lines = `#{cmd}`.lines.grep(/#{fetcher.send(:terminal_call_regexp)}/)
lines.map { |l| l[/\A\s*([0-9a-f]+):/, 1].to_i(16) }.sort
end
describe 'terminal_call_sites (BL byte scan)' do
# The scan must miss no real bl-to-exec site (a false positive only adds a
# harmless empty window, so we require superset, not equality). Gadget-level
# correctness for every arm fixture is covered by one_gadget_arm_spec; here we
# check the scanner directly on 2.27, whose scan includes one such false
# positive. (One version keeps this from re-running a full objdump per libc.)
it 'finds every objdump bl-to-exec site (superset)' do
skip_unless_objdump
f = fetcher('2.27')
sites = f.send(:terminal_call_sites)
expect(objdump_sites(f) - sites).to be_empty
expect(sites).not_to be_empty
end
it 'falls back to a full disassembly (nil) when the file is not scannable' do
f = fetcher('2.23')
allow(f).to receive(:file).and_return(__FILE__) # a Ruby source, not an ELF
expect(f.send(:terminal_call_sites)).to be_nil
end
end
end
+17
View File
@@ -35,4 +35,21 @@ describe OneGadget::Fetcher::Base do
expect(yielded).to match(/\Arsp == NULL \|\| \{.*\} is a valid envp\z/)
end
end
# A branch comparing a value with itself (e.g. +cmp x0, x0+): "X == X" is always
# true so the constraint is dropped; "X != X" never is, so the gadget is infeasible.
describe '#tautology? / #contradiction?' do
it 'classifies a self-comparison by its operator' do
expect(fetcher.send(:tautology?, 'rax == rax')).to be true
expect(fetcher.send(:tautology?, '(s64)x0 >= x0')).to be true
expect(fetcher.send(:contradiction?, 'rax != rax')).to be true
expect(fetcher.send(:contradiction?, 'x0 < x0')).to be true
end
it 'leaves genuine relations and disjunctions alone' do
expect(fetcher.send(:tautology?, 'x2 == 0x1')).to be false
expect(fetcher.send(:contradiction?, 'x2 == 0x1')).to be false
expect(fetcher.send(:trivial_relation, '[rax] == NULL || rax == rax')).to be_nil
end
end
end
+9
View File
@@ -86,6 +86,15 @@ constraints:
expect(new(['writable: x20+0x338']).score).to be_within(eps).of 0.81
end
it 'branch conditions' do
expect(new(['x2 == 0x1']).score).to be_within(eps).of 0.4 # equality
expect(new(['(u64)x0 >= 0x400']).score).to be_within(eps).of 0.6 # inequality
expect(new(['[x1+0x8] != 0']).score).to be_within(eps).of 0.54 # deref penalised
expect(new(['(x3 & 0x10) == 0']).score).to be_within(eps).of 0.4 # bit-test, parses to x3+0x10
expect(new(['(x0 & x1) != 0']).score).to be_within(eps).of 0.6 # distinct regs: lhs unparseable
expect(new(['(s64)(x0 + 0x10) < 0']).score).to be_within(eps).of 0.6 # cmn compare: lhs unparseable
end
it 'level 3' do
expect(new(['[[x4+0xad0]] == NULL']).score).to be_within(eps).of 0.9**3
expect(new(['x4+0xad0 == NULL']).score).to be_within(eps).of 0.1
+14 -4
View File
@@ -11,7 +11,8 @@ describe 'one_gadget_aarch64' do
it 'libc-2.23' do
path = data_path('aarch64-libc-2.23.so')
expect(OneGadget.gadgets(file: path, force_file: true, level: 1))
.to eq [0x3d6c4, 0x3d6cc, 0x3d6d0, 0x3d6d4, 0x3d6dc, 0x3d718, 0x60c1c, 0x60c20]
.to eq [0x3d6c4, 0x3d6cc, 0x3d6d0, 0x3d6d4, 0x3d6dc, 0x3d718,
0x60c1c, 0x60c20, 0x9b9e0, 0x9b9e4, 0x9bc5c]
end
it 'libc-2.24' do
@@ -21,9 +22,7 @@ describe 'one_gadget_aarch64' do
it 'libc-2.27' do
path = data_path('aarch64-libc-2.27.so')
expect(OneGadget.gadgets(file: path,
force_file: true)).to eq [0x3f160, 0x3f168, 0x3f16c,
0x3f184, 0x3f1a8, 0x63e90]
expect(OneGadget.gadgets(file: path, force_file: true)).to eq [0x3f160, 0x63e90, 0xa32e8]
end
# glibc 2.43 no longer exposes a straight-line execve("/bin/sh") gadget; the
@@ -40,6 +39,17 @@ describe 'one_gadget_aarch64' do
expect(gadgets.map(&:effect).uniq)
.to eq ['posix_spawn(sp+0xc, "/bin/sh", 0, sp+0x218, sp+0x50, environ)']
end
# The execve("/bin/sh") gadget in 2.43's execvp is split across a
# `cmp x2, #1; b.ne`, so it only appears once the not-taken branch is
# expressed as the constraint `x2 == 0x1`.
it 'resolves a gadget guarded by a conditional branch' do
path = data_path('aarch64-libc-2.43.so')
gadget = OneGadget.gadgets(file: path, force_file: true, level: 1, details: true)
.find { |g| g.offset == 0xc7a40 }
expect(gadget.effect).to eq 'execve("/bin/sh", x4, x6)'
expect(gadget.constraints).to include('x2 == 0x1')
end
end
it 'objdump not installed' do
+8 -6
View File
@@ -12,7 +12,8 @@ describe 'one_gadget_amd64' do
it 'libc-2.19' do
path = data_path('libc-2.19-cf699a15caae64f50311fc4655b86dc39a479789.so')
expect(OneGadget.gadgets(file: path, force_file: true, level: 1))
.to eq [0x46421, 0x46428, 0x4647c, 0xc1ba3, 0xc1bf2, 0xe4968, 0xe5765, 0xe5771, 0xe66bd]
.to eq [0x461fb, 0x46421, 0x46428, 0x4647c, 0xc1ba3, 0xc1bf2, 0xc1ca7, 0xe4968, 0xe5765, 0xe5771,
0xe654d, 0xe6552, 0xe6557, 0xe6670, 0xe6677, 0xe6685, 0xe668a, 0xe668f, 0xe6697, 0xe669e, 0xe66bd]
end
it 'libc-2.24' do
@@ -36,11 +37,12 @@ describe 'one_gadget_amd64' do
it 'libc-2.31' do
path = data_path('libc-2.31-9fdb74e7b217d06c93172a8243f8547f947ee6d1.so')
expect(OneGadget.gadgets(file: path, force_file: true,
level: 1)).to eq [0x51e2b, 0x51e32, 0x51e39, 0x51e40, 0x51e45, 0x51e55, 0x51e5a,
0x51e5d, 0x51e62, 0x84165, 0x8416c, 0x84173, 0x84176, 0x8417b,
0x84180, 0x8418c, 0x84192, 0x84199, 0x841a0, 0xe3b2e, 0xe3b31,
0xe3b34, 0xe3d23, 0xe3d26, 0xe3d92, 0xe3d99, 0xe3da0, 0xe3dd7,
0xe3de1, 0xe3de5, 0xe3ded, 0x1075da, 0x1075e2, 0x1075e7, 0x1075f1]
level: 1)).to eq [0x51df8, 0x51e2b, 0x51e32, 0x51e39, 0x51e40, 0x51e45, 0x51e55,
0x51e5a, 0x51e5d, 0x51e62, 0x51e65, 0x840c3, 0x84165, 0x8416c,
0x84173, 0x84176, 0x8417b, 0x84180, 0x8418c, 0x84192, 0x84199,
0x841a0, 0x841a3, 0xe3b2e, 0xe3b31, 0xe3b34, 0xe3d23, 0xe3d26,
0xe3d88, 0xe3d92, 0xe3d99, 0xe3da0, 0xe3dd7, 0xe3de1, 0xe3de5,
0xe3ded, 0x1075e7, 0x1075f1, 0x107cea]
expect(OneGadget.gadgets(file: path)).to eq [0xe3b2e, 0xe3b31, 0xe3b34]
end
+10 -2
View File
@@ -15,12 +15,20 @@ describe 'one_gadget_arm' do
it 'libc-2.27' do
path = data_path('arm-libc-2.27.so')
expect(OneGadget.gadgets(file: path, force_file: true)).to eq [0x2d39c, 0x73f7a]
expect(OneGadget.gadgets(file: path, force_file: true)).to eq [0x2d39c, 0x73f7a, 0x73f96]
end
it 'libc-2.39' do
path = data_path('arm-libc-2.39.so')
expect(OneGadget.gadgets(file: path, force_file: true)).to eq [0x38f6c, 0x88a48, 0x9ef1a]
expect(OneGadget.gadgets(file: path, force_file: true)).to eq [0x38f6c, 0x88a48, 0x9ef1a, 0x9f2bc]
end
it 'resolves a gadget guarded by a conditional branch' do
path = data_path('arm-libc-2.27.so')
gadget = OneGadget.gadgets(file: path, force_file: true, level: 1, details: true)
.find { |g| g.offset == 0x73f2c }
expect(gadget.effect).to eq 'execve("/bin/sh", sp-0x10, r2)'
expect(gadget.constraints).to include('[r1] == 0')
end
it 'resolves environ through the GOT base register' do