Class: OneGadget::Fetchers::Base

Inherits:
Object
  • Object
show all
Defined in:
lib/one_gadget/fetchers/base.rb

Overview

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 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.

Direct Known Subclasses

AArch64, Arm, X86

Constant Summary collapse

MAX_FORKS =

Give up on a control-flow path once it has crossed this many conditional branches.

4
PATH_BUDGET =

Hard cap on a single path's length (loop/runaway guard).

80
WINDOW_BACK =

How much to disassemble around each terminal call when an architecture can locate the calls cheaply (see #terminal_call_sites / #windowed_disasm).

The walk runs backwards from the call, but what it reaches does not: a branch predecessor can sit after the call, so the window has to hold the loop or later block that jumps back into the region.

Both directions were measured by windowing every architecture against its own full disassembly across the spec corpus. Gadgets start being lost below 0x400 either way, and a window too small to hold a predecessor invents them as well: the line before the first of a window is the last of another, and nothing about it follows. These leave 16x that, and still disassemble about a fifth of a libc -- terminal calls cluster into a handful of merged windows, so a wider one costs little.

A gadget whose code and branch-predecessors exceed the window is 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.

0x4000
WINDOW_FWD =

As far past the call, for a predecessor that branches back into the region.

0x4000

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(file) ⇒ Base

Instantiate a fetcher object.

Parameters:

  • file (String)

    Absolute path to the target libc.



70
71
72
73
74
75
# File 'lib/one_gadget/fetchers/base.rb', line 70

def initialize(file)
  @file = file
  arch = self.class.name.split('::').last.downcase.to_sym
  @objdump = Objdump.new(file, arch)
  @objdump.extra_options = objdump_options
end

Instance Attribute Details

#fileString (readonly)

The absolute path to glibc.

Returns:

  • (String)

    The filename.



22
23
24
# File 'lib/one_gadget/fetchers/base.rb', line 22

def file
  @file
end

Class Method Details

.cached(kind, command) ⇒ Object

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.



63
64
65
66
# File 'lib/one_gadget/fetchers/base.rb', line 63

def self.cached(kind, command)
  @cached ||= Hash.new { |h, k| h[k] = {} }
  @cached[kind][command] ||= yield
end

Instance Method Details

#candidates {|cand| ... } ⇒ Array<String>

Fetch candidates that end with call exec*.

Provide a block to filter gadget candidates.

Yield Parameters:

  • cand (String)

    Is this candidate valid?

Yield Returns:

  • (Boolean)

    True for valid.

Returns:

  • (Array<String>)

    Each String returned is multi-lines of assembly code.



178
179
180
# File 'lib/one_gadget/fetchers/base.rb', line 178

def candidates(&)
  branch_aware_candidates(&)
end

#executed_windows(lines) {|window| ... } ⇒ void

This method returns an undefined value.

Every suffix of lines -- a start line and everything after it, longest last -- cut down to the part that runs: emulation ends at the terminal call, so anything past one never executes. A suffix reaches beyond a terminal call when the function holds several and the candidate was walked back from a later one. Bounding each window here lets everything downstream -- #emulate and its overrides, the dedup key in #find -- read a window as executed code.

Each line is classified once per candidate rather than once per suffix containing it: walking the start backwards, the first terminal call at or after it only moves when the start is itself one.

Parameters:

  • lines (Array<String>)

    One candidate, as a line list.

Yield Parameters:

  • window (Array<String>)


113
114
115
116
117
118
119
# File 'lib/one_gadget/fetchers/base.rb', line 113

def executed_windows(lines)
  stop = lines.size - 1 if terminal_call_line?(lines.last)
  (lines.size - 2).downto(0) do |i|
    stop = i if terminal_call_line?(lines[i])
    yield(stop ? lines[i..stop] : lines[i..])
  end
end

#findArray<OneGadget::Gadget::Gadget>

Do find gadgets in glibc.

Returns:



79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/one_gadget/fetchers/base.rb', line 79

def find
  str_offset('/bin/sh') # ensure it's glibc-like; raises "not glibc?" if not found
  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|
    executed_windows(cand.lines) do |suffix|
      next if seen.key?(key = suffix.join)

      seen[key] = true
      next if refused_before_call?(suffix)

      gadget = resolve_suffix(suffix)
      gadgets << gadget unless gadget.nil?
    end
  end
  gadgets
end

#refused_before_call?(window) ⇒ Boolean

Whether emulating window can only stop short of its terminal call: it runs a line the emulator has already refused (see Emulators::Processor#refused_line), and a window that never reaches the call is not a gadget. Refusal belongs to the line, so one learnt anywhere settles every window carrying it -- and overlapping candidates carry the same lines over and over.

Parameters:

  • window (Array<String>)

Returns:

  • (Boolean)


142
143
144
145
146
# File 'lib/one_gadget/fetchers/base.rb', line 142

def refused_before_call?(window)
  return false if @refused.nil?

  (window.size - 1).times.any? { |i| @refused.key?(window[i]) }
end

#resolve_suffix(lines) ⇒ Object

Emulate a candidate suffix and turn it into a gadget, or nil if it isn't one.



149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/one_gadget/fetchers/base.rb', line 149

def resolve_suffix(lines)
  processor = emulate(lines)
  (@refused ||= {})[processor.refused_line] = true if processor.refused_line
  # 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) }
  options[:closed_fds] = processor.closed_fds
  OneGadget::Gadget::Gadget.new(offset_of(lines.first), **options)
end

#terminal_call_line?(line) ⇒ Boolean

Whether line is the call that ends a gadget, by the same rule the emulator stops on (Emulators::Processor#terminal_call?). Not #terminal_call_regexp, which is looser so it can locate call sites: it also matches the posix_spawn setup helpers, which emulation runs through. The mnemonic must be a call, so a branch whose target symbol merely looks similar (+<execlp@@GLIBC_2.4+0x136>+) doesn't end the window.

Returns:

  • (Boolean)


127
128
129
130
131
132
# File 'lib/one_gadget/fetchers/base.rb', line 127

def terminal_call_line?(line)
  return false unless line[/\A\s*[0-9a-f]+:\s*(\S+)/, 1]&.match?(/\A#{call_str}x?(?:\.[wn])?\z/)

  name = line[/<([^@>]+)/, 1]
  !name.nil? && OneGadget::Emulators::Processor::TERMINAL_CALL_RE.match?(name)
end