diff --git a/.rubocop.yml b/.rubocop.yml index 416f26e..a8bbbd4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -60,6 +60,9 @@ Style/AccessorGrouping: Style/ArrayIntersect: Enabled: false +Style/AsciiComments: + Enabled: true + Style/FormatStringToken: Enabled: false diff --git a/README.md b/README.md index da6f7c1..e77391c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.tpl.md b/README.tpl.md index eac7a07..3f9babd 100644 --- a/README.tpl.md +++ b/README.tpl.md @@ -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 diff --git a/docs/adding-an-architecture.md b/docs/adding-an-architecture.md new file mode 100644 index 0000000..466e7f7 --- /dev/null +++ b/docs/adding-an-architecture.md @@ -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:: < OneGadget::Fetcher::Base # find candidates, describe constraints +OneGadget::Emulators:: < 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
(engine)"]:::engine + FA["Fetcher::<Arch>
(you implement)"]:::arch + FB -->|inherits| FA + end + subgraph EM["Emulator side: execute one candidate"] + direction TB + EP["Emulators::Processor
(engine)"]:::engine + CO["module Conditional"]:::mixin + AF["module ArmFamily"]:::mixin + EA["Emulators::<Arch>
(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::`** โ€” 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::`** โ€” 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::<Arch>#find"]:::arch + B --> C["Base#candidates
(backward CFG walk)"]:::engine + C --> D{"for each start instruction"} + D --> E["Base#emulate
(process! per instruction)"]:::engine + E --> F["module Conditional
(per instruction)"]:::mixin + E --> G["arch inst_* handlers"]:::arch + F --> H["branch โ†’ constraint"]:::mixin + D --> I["Base#resolve
(argv / envp checks)"]:::engine + H --> J["Gadget
(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:: < Fetcher::Base` + +| Method | What it returns | +| --- | --- | +| `emulator` | a fresh `Emulators::` 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:: < Emulators::Processor` + +| Method | What it does | +| --- | --- | +| `initialize` | `super(OneGadget::ABI., 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_(...)` | 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::` entry. +* `OneGadget::Helper.arch_specific_objdump` โ€” the cross objdump binary name. + +## Tests + +Add a `spec/one_gadget__spec.rb` that runs `OneGadget.gadgets` against a +real libc fixture and asserts the exact gadget offsets, plus emulator unit tests +in `spec/emulators/_spec.rb`. diff --git a/lib/one_gadget/emulators/aarch64.rb b/lib/one_gadget/emulators/aarch64.rb index 4f32aca..6386390 100644 --- a/lib/one_gadget/emulators/aarch64.rb +++ b/lib/one_gadget/emulators/aarch64.rb @@ -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 ++. + 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 diff --git a/lib/one_gadget/emulators/arm.rb b/lib/one_gadget/emulators/arm.rb index 2ca9d45..85d9acb 100644 --- a/lib/one_gadget/emulators/arm.rb +++ b/lib/one_gadget/emulators/arm.rb @@ -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? diff --git a/lib/one_gadget/emulators/arm_family.rb b/lib/one_gadget/emulators/arm_family.rb index 8684a2f..e1a30a5 100644 --- a/lib/one_gadget/emulators/arm_family.rb +++ b/lib/one_gadget/emulators/arm_family.rb @@ -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 ++ in +b+ / +b.+) 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] diff --git a/lib/one_gadget/emulators/conditional.rb b/lib/one_gadget/emulators/conditional.rb new file mode 100644 index 0000000..e913611 --- /dev/null +++ b/lib/one_gadget/emulators/conditional.rb @@ -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 ') #=> '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 diff --git a/lib/one_gadget/emulators/lambda.rb b/lib/one_gadget/emulators/lambda.rb index a35b54f..0ae3785 100644 --- a/lib/one_gadget/emulators/lambda.rb +++ b/lib/one_gadget/emulators/lambda.rb @@ -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 diff --git a/lib/one_gadget/emulators/processor.rb b/lib/one_gadget/emulators/processor.rb index d8ba40c..f98351d 100644 --- a/lib/one_gadget/emulators/processor.rb +++ b/lib/one_gadget/emulators/processor.rb @@ -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)] # 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)] diff --git a/lib/one_gadget/emulators/x86.rb b/lib/one_gadget/emulators/x86.rb index eb65f79..dcf21bd 100644 --- a/lib/one_gadget/emulators/x86.rb +++ b/lib/one_gadget/emulators/x86.rb @@ -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] 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 ++. + 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) diff --git a/lib/one_gadget/fetcher.rb b/lib/one_gadget/fetcher.rb index 51c1412..7da5eb6 100644 --- a/lib/one_gadget/fetcher.rb +++ b/lib/one_gadget/fetcher.rb @@ -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 diff --git a/lib/one_gadget/fetchers/aarch64.rb b/lib/one_gadget/fetchers/aarch64.rb index 4c50286..d3c9924 100644 --- a/lib/one_gadget/fetchers/aarch64.rb +++ b/lib/one_gadget/fetchers/aarch64.rb @@ -7,15 +7,25 @@ module OneGadget module Fetcher # Define common methods for gadget fetchers. class AArch64 < Base + # aarch64 condition codes (used to recognise +b.+ 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 diff --git a/lib/one_gadget/fetchers/amd64.rb b/lib/one_gadget/fetchers/amd64.rb index c12f55b..693c13f 100644 --- a/lib/one_gadget/fetchers/amd64.rb +++ b/lib/one_gadget/fetchers/amd64.rb @@ -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.*/) } - next if call_execve.nil? - - remain = remain[0..call_execve] - [cand + remain].join("\n") - end.compact end def bin_sh_hex diff --git a/lib/one_gadget/fetchers/arm.rb b/lib/one_gadget/fetchers/arm.rb index cd1fdea..8faf2a6 100644 --- a/lib/one_gadget/fetchers/arm.rb +++ b/lib/one_gadget/fetchers/arm.rb @@ -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 ` 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 diff --git a/lib/one_gadget/fetchers/base.rb b/lib/one_gadget/fetchers/base.rb index f83eeca..9bdc6db 100644 --- a/lib/one_gadget/fetchers/base.rb +++ b/lib/one_gadget/fetchers/base.rb @@ -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] 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] # 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 ') #=> '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 diff --git a/lib/one_gadget/fetchers/x86.rb b/lib/one_gadget/fetchers/x86.rb index 8be5531..562c7c1 100644 --- a/lib/one_gadget/fetchers/x86.rb +++ b/lib/one_gadget/fetchers/x86.rb @@ -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 diff --git a/lib/one_gadget/gadget.rb b/lib/one_gadget/gadget.rb index 94ee24d..afcbc51 100644 --- a/lib/one_gadget/gadget.rb +++ b/lib/one_gadget/gadget.rb @@ -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 diff --git a/spec/cli_spec.rb b/spec/cli_spec.rb index 3e1c2e3..d37f9e0 100644 --- a/spec/cli_spec.rb +++ b/spec/cli_spec.rb @@ -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 diff --git a/spec/emulators/aarch64_spec.rb b/spec/emulators/aarch64_spec.rb index 5f1bce9..3f0ff47 100644 --- a/spec/emulators/aarch64_spec.rb +++ b/spec/emulators/aarch64_spec.rb @@ -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)} ") + @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 ')).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 ')).to be false # magnitude after tst: unsound + end + end + describe 'process' do it 'libc-2.23 gadget' do gadget = <<-EOS diff --git a/spec/emulators/amd64_spec.rb b/spec/emulators/amd64_spec.rb index 10d5236..5831d05 100644 --- a/spec/emulators/amd64_spec.rb +++ b/spec/emulators/amd64_spec.rb @@ -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)} ") + @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 ')).to be false + end + end + describe 'process' do it 'libc-2.24 gadget' do gadget = <<-EOS diff --git a/spec/emulators/arm_spec.rb b/spec/emulators/arm_spec.rb index c21f895..161417a 100644 --- a/spec/emulators/arm_spec.rb +++ b/spec/emulators/arm_spec.rb @@ -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)} ") + @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 ')).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 diff --git a/spec/fetchers/arm_spec.rb b/spec/fetchers/arm_spec.rb new file mode 100644 index 0000000..0c875e6 --- /dev/null +++ b/spec/fetchers/arm_spec.rb @@ -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 diff --git a/spec/fetchers/base_spec.rb b/spec/fetchers/base_spec.rb index 31bbc00..a9cc28b 100644 --- a/spec/fetchers/base_spec.rb +++ b/spec/fetchers/base_spec.rb @@ -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 diff --git a/spec/gadget_spec.rb b/spec/gadget_spec.rb index 2185fc4..5766bf4 100644 --- a/spec/gadget_spec.rb +++ b/spec/gadget_spec.rb @@ -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 diff --git a/spec/one_gadget_aarch64_spec.rb b/spec/one_gadget_aarch64_spec.rb index 8b647ed..da27feb 100644 --- a/spec/one_gadget_aarch64_spec.rb +++ b/spec/one_gadget_aarch64_spec.rb @@ -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 diff --git a/spec/one_gadget_amd64_spec.rb b/spec/one_gadget_amd64_spec.rb index 1b983df..bf15343 100644 --- a/spec/one_gadget_amd64_spec.rb +++ b/spec/one_gadget_amd64_spec.rb @@ -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 diff --git a/spec/one_gadget_arm_spec.rb b/spec/one_gadget_arm_spec.rb index 22f3c13..2c884ab 100644 --- a/spec/one_gadget_arm_spec.rb +++ b/spec/one_gadget_arm_spec.rb @@ -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