augure-profiler 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/exe/augure-profile +42 -0
- data/home/runner/work/augure/augure/profiler/exe/augure-profile +42 -0
- data/home/runner/work/augure/augure/profiler/lib/augure-profiler/dynamic_trace.rb +80 -0
- data/home/runner/work/augure/augure/profiler/lib/augure-profiler/fuzzer.rb +291 -0
- data/home/runner/work/augure/augure/profiler/lib/augure-profiler/gadget_hunt.rb +109 -0
- data/home/runner/work/augure/augure/profiler/lib/augure-profiler/profiler.rb +203 -0
- data/home/runner/work/augure/augure/profiler/lib/augure-profiler/shellcode.rb +329 -0
- data/home/runner/work/augure/augure/profiler/lib/augure-profiler/static_offset.rb +265 -0
- data/home/runner/work/augure/augure/profiler/lib/augure-profiler/version.rb +5 -0
- data/home/runner/work/augure/augure/profiler/lib/augure-profiler.rb +12 -0
- metadata +84 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a668c990f083c8eb2d00f67ce45ccb99fb37525ee1013e484e63cf6b00f0b561
|
|
4
|
+
data.tar.gz: f86407235e580f36e236b52b1c406dfb7159b5d9dea733ec5d293ff68ac54678
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1493d1d5d121787f04ce661350f6eb89922cfe64555f15e9cf6cd90c7d543f45cd7d7e8eff4fcfaf7b4ad9b1492f7fd362cce5b043f10d80e319895824f2cf5f
|
|
7
|
+
data.tar.gz: 3a19f8acd46eaa23537b10bf279442631e93a567fd22d81e5aec918e50b63c5ae782874c8779836afa3815f6f5a21bd7c8169fd84e0e2450b182ff214c4d13f4
|
data/exe/augure-profile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Bootstrap the bundler context from the checkout: the CLI runs from any
|
|
5
|
+
# working directory (when not already inside `bundle exec`).
|
|
6
|
+
unless defined?(Bundler)
|
|
7
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __dir__)
|
|
8
|
+
require "bundler/setup"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require "optparse"
|
|
12
|
+
require_relative "../lib/augure-profiler"
|
|
13
|
+
|
|
14
|
+
out_path = nil
|
|
15
|
+
OptionParser.new do |opts|
|
|
16
|
+
opts.banner = "Usage: augure-profile <binary> [-o target.facts]"
|
|
17
|
+
opts.on("-o PATH", "Write facts to PATH (default: stdout)") { |v| out_path = v }
|
|
18
|
+
opts.on("--version", "Print the version") do
|
|
19
|
+
puts "augure-profiler #{AugureProfiler::VERSION}"
|
|
20
|
+
exit 0
|
|
21
|
+
end
|
|
22
|
+
end.parse!(ARGV)
|
|
23
|
+
|
|
24
|
+
binary = ARGV.shift or abort(<<~USAGE)
|
|
25
|
+
Usage: augure-profile <binary> [-o target.facts]
|
|
26
|
+
|
|
27
|
+
Generates an augure fact file from an ELF binary: checksec, PLT
|
|
28
|
+
imports, unsafe-symbol hints and semantically classified gadgets.
|
|
29
|
+
The binary is read-only; the profiler never executes anything.
|
|
30
|
+
USAGE
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
facts = AugureProfiler::Profiler.new(binary).facts
|
|
34
|
+
if out_path
|
|
35
|
+
File.write(out_path, facts.to_s)
|
|
36
|
+
warn "augure-profile: wrote #{out_path} (#{facts.size} facts)"
|
|
37
|
+
else
|
|
38
|
+
puts facts
|
|
39
|
+
end
|
|
40
|
+
rescue Metasm::ParseError, StandardError => e
|
|
41
|
+
abort("augure-profile: #{binary}: #{e.message}")
|
|
42
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Bootstrap the bundler context from the checkout: the CLI runs from any
|
|
5
|
+
# working directory (when not already inside `bundle exec`).
|
|
6
|
+
unless defined?(Bundler)
|
|
7
|
+
ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", __dir__)
|
|
8
|
+
require "bundler/setup"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
require "optparse"
|
|
12
|
+
require_relative "../lib/augure-profiler"
|
|
13
|
+
|
|
14
|
+
out_path = nil
|
|
15
|
+
OptionParser.new do |opts|
|
|
16
|
+
opts.banner = "Usage: augure-profile <binary> [-o target.facts]"
|
|
17
|
+
opts.on("-o PATH", "Write facts to PATH (default: stdout)") { |v| out_path = v }
|
|
18
|
+
opts.on("--version", "Print the version") do
|
|
19
|
+
puts "augure-profiler #{AugureProfiler::VERSION}"
|
|
20
|
+
exit 0
|
|
21
|
+
end
|
|
22
|
+
end.parse!(ARGV)
|
|
23
|
+
|
|
24
|
+
binary = ARGV.shift or abort(<<~USAGE)
|
|
25
|
+
Usage: augure-profile <binary> [-o target.facts]
|
|
26
|
+
|
|
27
|
+
Generates an augure fact file from an ELF binary: checksec, PLT
|
|
28
|
+
imports, unsafe-symbol hints and semantically classified gadgets.
|
|
29
|
+
The binary is read-only; the profiler never executes anything.
|
|
30
|
+
USAGE
|
|
31
|
+
|
|
32
|
+
begin
|
|
33
|
+
facts = AugureProfiler::Profiler.new(binary).facts
|
|
34
|
+
if out_path
|
|
35
|
+
File.write(out_path, facts.to_s)
|
|
36
|
+
warn "augure-profile: wrote #{out_path} (#{facts.size} facts)"
|
|
37
|
+
else
|
|
38
|
+
puts facts
|
|
39
|
+
end
|
|
40
|
+
rescue Metasm::ParseError, StandardError => e
|
|
41
|
+
abort("augure-profile: #{binary}: #{e.message}")
|
|
42
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "metasm"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "tmpdir"
|
|
6
|
+
|
|
7
|
+
module AugureProfiler
|
|
8
|
+
# The dynamic layer: the debugger in the loop, like the original
|
|
9
|
+
# tool's dbg.pm. Three answers static analysis cannot give:
|
|
10
|
+
# 1. WHICH instruction wrote the overflowing bytes over the return
|
|
11
|
+
# slot (the taint, proven by a watchpoint on the very address the
|
|
12
|
+
# static frame read computed);
|
|
13
|
+
# 2. the runtime map (libc, stack, heap) of a live run - the decor
|
|
14
|
+
# confirmed, not inferred;
|
|
15
|
+
# 3. nothing else - the tests of the built bytes remain the truth.
|
|
16
|
+
module DynamicTrace
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# 1. The write-site proof, ONE gdb session (no cross-run address
|
|
20
|
+
# to carry: the stack shifts between runs, the session cannot):
|
|
21
|
+
# break the vulnerable function the static read named, watch the
|
|
22
|
+
# return slot living at $rsp on entry, continue - the stop whose
|
|
23
|
+
# new value carries the input's bytes IS the writing instruction.
|
|
24
|
+
def write_site(binary, input)
|
|
25
|
+
static = StaticOffset.offset(binary)
|
|
26
|
+
return nil unless static&.dig(:function)
|
|
27
|
+
|
|
28
|
+
input_file = File.join(Dir.tmpdir, "augure_write_input_#{Process.pid}")
|
|
29
|
+
File.binwrite(input_file, input)
|
|
30
|
+
cmds = [
|
|
31
|
+
"break #{static[:function]}",
|
|
32
|
+
"run < #{input_file}",
|
|
33
|
+
"watch *(unsigned long *) $rsp"
|
|
34
|
+
]
|
|
35
|
+
6.times { cmds << "continue" }
|
|
36
|
+
cmds << "bt 3" << "x/i $pc"
|
|
37
|
+
out, _err, _st = Open3.capture3({"DEBUGINFOD_URLS" => ""}, "gdb", "-batch",
|
|
38
|
+
*cmds.flat_map { |c| ["-ex", c] }, binary)
|
|
39
|
+
blocks = out.split(/Hardware (?:access |)watchpoint \d+: \*/)
|
|
40
|
+
# the stop whose new value carries our filler bytes (the input's
|
|
41
|
+
# leading word) - the call machinery does not write the pattern
|
|
42
|
+
the_stop = blocks.find { |b| b =~ /New value = 4702111234474983745|New value = 4702111234474983746/ }
|
|
43
|
+
return nil unless the_stop
|
|
44
|
+
|
|
45
|
+
new_line = the_stop.lines.index { |l| l.include?("4702111234474983745") }
|
|
46
|
+
pc = new_line ? the_stop.lines[(new_line + 1)..].find { |l| l =~ /0x[0-9a-f]+ in / } : nil
|
|
47
|
+
func = pc&.slice(/in [A-Za-z_@][\w@<>]*/)&.sub("in ", "") || "unknown"
|
|
48
|
+
addr = pc&.slice(/\A\s*0x[0-9a-f]+/)&.strip
|
|
49
|
+
{function: func, instruction: addr.to_s, caller: static[:function], sink: static[:sink]}
|
|
50
|
+
ensure
|
|
51
|
+
File.delete(input_file) if input_file && File.exist?(input_file)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 2. The runtime map: what the loader actually mapped for this run.
|
|
55
|
+
# Returns {libc_base:, stack_base:, stack_end:, heap_base:} (nils
|
|
56
|
+
# when absent) - the decor confirmed.
|
|
57
|
+
def runtime_map(binary)
|
|
58
|
+
# at starti the loader has not mapped libc yet: break main, the
|
|
59
|
+
# decor is complete by then
|
|
60
|
+
out, _err, _st = Open3.capture3({"DEBUGINFOD_URLS" => ""}, "gdb", "-batch",
|
|
61
|
+
"-ex", "break main", "-ex", "run", "-ex", "info proc mappings", binary)
|
|
62
|
+
libc_base = nil
|
|
63
|
+
stack_base = nil
|
|
64
|
+
stack_end = nil
|
|
65
|
+
heap_base = nil
|
|
66
|
+
out.lines.each do |l|
|
|
67
|
+
if (m = l.match(/\A\s*(0x[0-9a-f]+)\s+(0x[0-9a-f]+)\s+\S+\s+\S+\s+(.+?)\s*\z/i))
|
|
68
|
+
lo = m[1].to_i(16)
|
|
69
|
+
hi = m[2].to_i(16)
|
|
70
|
+
what = m[3].to_s.strip
|
|
71
|
+
libc_base ||= lo if /libc\.so/.match?(what)
|
|
72
|
+
stack_base ||= lo if /\[\s*stack\s*\]/i.match?(what)
|
|
73
|
+
stack_end = hi if /\[\s*stack\s*\]/i.match?(what)
|
|
74
|
+
heap_base ||= lo if what.include?("[heap]")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
{libc_base: libc_base, stack_base: stack_base, stack_end: stack_end, heap_base: heap_base}
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module AugureProfiler
|
|
6
|
+
# Input fuzzing: mutate a seed, deliver to the target's stdin, detect
|
|
7
|
+
# crashes by signal. The built-in RubyMutator is zero-dependency and
|
|
8
|
+
# covers CTF-grade discovery; radamsa (subprocess) upgrades the
|
|
9
|
+
# mutation quality when installed. This is the fact DISCOVERY layer
|
|
10
|
+
# for binaries that have no write-up.
|
|
11
|
+
class TriageError < StandardError; end
|
|
12
|
+
|
|
13
|
+
module RubyMutator
|
|
14
|
+
BOUNDARY_BYTES = ["\x00".b, "\xff".b, "\x80".b, "\n".b, "%n".b].freeze
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def mutate(seed, count, rng, max_len: 256)
|
|
19
|
+
(0...count).map do
|
|
20
|
+
variant = apply_one_mutation(seed.dup, rng)
|
|
21
|
+
variant = variant[0, max_len]
|
|
22
|
+
variant << ("A" * 8) if variant.empty?
|
|
23
|
+
variant
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def apply_one_mutation(input, rng)
|
|
28
|
+
case rng.rand(4)
|
|
29
|
+
when 0 then bit_flip(input, rng)
|
|
30
|
+
when 1 then repeat_chunk(input, rng)
|
|
31
|
+
when 2 then inject_boundary(input, rng)
|
|
32
|
+
else extend_pattern(input, rng)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def bit_flip(input, rng)
|
|
37
|
+
return input if input.empty?
|
|
38
|
+
|
|
39
|
+
i = rng.rand(input.bytesize)
|
|
40
|
+
input.setbyte(i, input.getbyte(i) ^ (1 << rng.rand(8)))
|
|
41
|
+
input
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def repeat_chunk(input, rng)
|
|
45
|
+
return input if input.empty?
|
|
46
|
+
|
|
47
|
+
at = rng.rand(input.bytesize)
|
|
48
|
+
len = [rng.rand(input.bytesize) + 1, 16].min
|
|
49
|
+
input.insert(at, input[at, len] * 2)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def inject_boundary(input, rng)
|
|
53
|
+
at = rng.rand(input.bytesize + 1)
|
|
54
|
+
input.insert(at, BOUNDARY_BYTES[rng.rand(BOUNDARY_BYTES.size)])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The classic: grow with the cyclic pattern - offsets become readable
|
|
58
|
+
# in the crash report.
|
|
59
|
+
def extend_pattern(input, rng)
|
|
60
|
+
growth = "A" * (8 + rng.rand(64))
|
|
61
|
+
(input + growth.b)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
module Fuzzer
|
|
66
|
+
CRASH_SIGNALS = [
|
|
67
|
+
Signal.list["SEGV"], Signal.list["ABRT"], Signal.list["ILL"]
|
|
68
|
+
].freeze
|
|
69
|
+
|
|
70
|
+
module_function
|
|
71
|
+
|
|
72
|
+
def run(binary, seed: "AAAA", inputs: 50, max_len: 256, mutator: :ruby, rng: Random.new)
|
|
73
|
+
# The CTF bread-and-butter first: a deterministic length sweep.
|
|
74
|
+
# Crashes here reveal the OFFSET (the pattern bytes in the faulting
|
|
75
|
+
# address are readable in the triage report).
|
|
76
|
+
sweep = (8..max_len).step(8).map { |len| (seed * (len / seed.length + 1))[0, len] }
|
|
77
|
+
random = case mutator
|
|
78
|
+
when :ruby then RubyMutator.mutate(seed, inputs, rng, max_len: max_len)
|
|
79
|
+
when :radamsa then radamsa_mutations(seed, inputs)
|
|
80
|
+
else raise ArgumentError, "unknown mutator #{mutator.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
(sweep + random).filter_map do |input|
|
|
84
|
+
_out, _err, status = Open3.capture3(binary, stdin_data: input)
|
|
85
|
+
{input: input} if status.signaled? && CRASH_SIGNALS.include?(status.termsig)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def radamsa_mutations(seed, count)
|
|
90
|
+
return Array.new(count) { seed } unless system("sh", "-c", "command -v radamsa >/dev/null 2>&1")
|
|
91
|
+
|
|
92
|
+
(0...count).map do
|
|
93
|
+
out, _err, status = Open3.capture3("sh", "-c", "printf '%s' \"$1\" | radamsa", "sh", "-", seed)
|
|
94
|
+
(status.success? && !out.empty?) ? out : seed
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Crash triage: WHAT happened, in facts the decision layer consumes.
|
|
100
|
+
# Backends are uniform: run(binary, input) -> {signal, registers,
|
|
101
|
+
# fault_addr, control}. Default gdb (batch); rizin/r2 and lldb cover
|
|
102
|
+
# the other toolchains. metasm stays on the static side.
|
|
103
|
+
class TriageError < StandardError; end
|
|
104
|
+
|
|
105
|
+
module Triage
|
|
106
|
+
module_function
|
|
107
|
+
|
|
108
|
+
def resolve_backend(override: nil)
|
|
109
|
+
candidates = [override, :gdb, :rizin, :lldb].compact
|
|
110
|
+
candidates.find do |b|
|
|
111
|
+
bin = {gdb: "gdb", rizin: "rizin", r2: "radare2", lldb: "lldb"}[b]
|
|
112
|
+
bin && system("sh", "-c", "command -v '#{bin}' >/dev/null 2>&1")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def run(binary, input, backend: nil)
|
|
117
|
+
resolved = resolve_backend(override: backend)
|
|
118
|
+
raise TriageError, "no triage backend found (gdb, rizin or lldb required)" unless resolved
|
|
119
|
+
|
|
120
|
+
send(:"#{resolved}_triage", binary, input)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# The msf-style pattern: unique 3-grams (aA0aA1...), so ANY 3+ byte
|
|
124
|
+
# subsequence pins its exact offset. The crash address read under the
|
|
125
|
+
# debugger is found back in the pattern - that IS the offset to the
|
|
126
|
+
# return address, computed instead of guessed.
|
|
127
|
+
def cyclic(length)
|
|
128
|
+
out = +""
|
|
129
|
+
until out.size >= length
|
|
130
|
+
("a".."z").each do |a|
|
|
131
|
+
("A".."Z").each do |b|
|
|
132
|
+
("0".."9").each do |c|
|
|
133
|
+
out << a + b + c
|
|
134
|
+
return out[0, length] if out.size >= length
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
out[0, length]
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The offset of `bytes` inside the pattern: try the full register
|
|
143
|
+
# width first, then the 32-bit truncation (the classic case where
|
|
144
|
+
# only four pattern bytes survive in the saved return slot).
|
|
145
|
+
def cyclic_offset(pattern, bytes)
|
|
146
|
+
[8, 4].each do |width|
|
|
147
|
+
next if bytes.bytesize < width
|
|
148
|
+
|
|
149
|
+
found = pattern.index(bytes[0, width])
|
|
150
|
+
return found if found
|
|
151
|
+
end
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Two steps, like a human would: find the crash LENGTH with the
|
|
156
|
+
# deterministic sweep, then send a cyclic pattern of exactly that
|
|
157
|
+
# length so the crash lands on the corrupted return (an oversized
|
|
158
|
+
# pattern would fault on the stack write itself, never at the ret).
|
|
159
|
+
# The crash address read under the debugger is found back in the
|
|
160
|
+
# pattern - that IS the offset to the return address, computed
|
|
161
|
+
# instead of guessed. Returns nil when the binary does not crash or
|
|
162
|
+
# the pointer is not ours.
|
|
163
|
+
def offset(binary, backend: nil)
|
|
164
|
+
sweep = Fuzzer.run(binary, inputs: 40, max_len: 1024)
|
|
165
|
+
crash_len = sweep.first&.dig(:input)&.bytesize
|
|
166
|
+
return nil unless crash_len
|
|
167
|
+
|
|
168
|
+
# The sweep length is the MINIMAL overflow; the pattern needs
|
|
169
|
+
# enough tail to fill the whole saved-return slot with pattern
|
|
170
|
+
# bytes (8 on x64) while staying far from the write-fault zone.
|
|
171
|
+
pattern = cyclic(crash_len + 64)
|
|
172
|
+
report = run(binary, pattern, backend: backend)
|
|
173
|
+
return nil unless report[:signal] == "SIGSEGV"
|
|
174
|
+
|
|
175
|
+
# rip first (the clean case); then the saved-frame-pointer slot:
|
|
176
|
+
# a leave;ret epilogue crashes in leave when rbp is smashed, and
|
|
177
|
+
# the return slot sits exactly one register-width above it.
|
|
178
|
+
regs = report[:registers]
|
|
179
|
+
[["rip", 0], ["eip", 0], ["rbp", 8], ["ebp", 8]].each do |reg, shift|
|
|
180
|
+
addr = regs[reg]
|
|
181
|
+
next unless addr.is_a?(String) && addr.start_with?("0x")
|
|
182
|
+
|
|
183
|
+
off = cyclic_offset(pattern, [addr[2..].to_i(16)].pack("Q<"))
|
|
184
|
+
return off + shift if off
|
|
185
|
+
end
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def facts(report)
|
|
190
|
+
return "" unless report[:signal] == "SIGSEGV"
|
|
191
|
+
|
|
192
|
+
facts = ["vuln(\"sof\").\n"]
|
|
193
|
+
if report[:control]
|
|
194
|
+
facts << "return_addr_filtered(\"false\").\n"
|
|
195
|
+
end
|
|
196
|
+
facts.join
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def gdb_triage(binary, input)
|
|
200
|
+
input_file = File.join(Dir.tmpdir, "augure_fuzz_input_#{Process.pid}")
|
|
201
|
+
File.binwrite(input_file, input)
|
|
202
|
+
# DEBUGINFOD_URLS empty: gdb's debuginfo auto-download queries the
|
|
203
|
+
# network and turns every batch run into a minutes-long stall (the
|
|
204
|
+
# CI triage went 180s per example on it).
|
|
205
|
+
out, _err, _status = Open3.capture3(
|
|
206
|
+
{"DEBUGINFOD_URLS" => ""}, "gdb", "-batch",
|
|
207
|
+
"-ex", "run < #{input_file}", "-ex", "info registers",
|
|
208
|
+
"-ex", "x/i $pc", binary
|
|
209
|
+
)
|
|
210
|
+
parse_gdb(out)
|
|
211
|
+
ensure
|
|
212
|
+
File.delete(input_file) if input_file && File.exist?(input_file)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def parse_gdb(output)
|
|
216
|
+
signal = output[/Program received signal (SIG\w+)/, 1]
|
|
217
|
+
registers = {}
|
|
218
|
+
output.each_line do |line|
|
|
219
|
+
if (m = line.match(/\A(\w+)\s+(0x[0-9a-f]+|\d+)\s/))
|
|
220
|
+
registers[m[1]] = m[2]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
# The faulting address: the instruction pointer when control was
|
|
224
|
+
# hijacked, else the faulting line's address.
|
|
225
|
+
fault_addr = registers["rip"] || registers["eip"] || output[/\A(0x[0-9a-f]+)/, 1]
|
|
226
|
+
control = detect_control(registers, output)
|
|
227
|
+
{signal: signal, registers: registers, fault_addr: fault_addr, control: control}
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Input-pattern control: the faulting address is made of pattern
|
|
231
|
+
# bytes - the return address IS under input control.
|
|
232
|
+
def detect_control(registers, _output)
|
|
233
|
+
%w[rip eip].each do |reg|
|
|
234
|
+
addr = registers[reg]
|
|
235
|
+
next unless addr.is_a?(String) && addr.start_with?("0x")
|
|
236
|
+
|
|
237
|
+
bytes = [addr[2..].to_i(16)].pack("Q<").delete("\x00A")
|
|
238
|
+
return reg.to_sym if addr.include?("4141") || bytes.empty?
|
|
239
|
+
end
|
|
240
|
+
:none
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def rizin_triage(binary, input)
|
|
244
|
+
input_file = File.join(Dir.tmpdir, "augure_fuzz_input_#{Process.pid}")
|
|
245
|
+
File.binwrite(input_file, input)
|
|
246
|
+
out, _err, _status = Open3.capture3(
|
|
247
|
+
"rizin", "-d", "-q", "-c", "dc; drp; dk 9", binary, "-", "<", input_file
|
|
248
|
+
)
|
|
249
|
+
parse_rizin(out)
|
|
250
|
+
ensure
|
|
251
|
+
File.delete(input_file) if input_file && File.exist?(input_file)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def parse_rizin(output)
|
|
255
|
+
signal = output[/signal (\d+|SIG\w+)/, 1] ? "SIGSEGV" : nil
|
|
256
|
+
signal ||= "SIGSEGV" if /Segmentation fault/.match?(output)
|
|
257
|
+
registers = {}
|
|
258
|
+
output.each_line do |line|
|
|
259
|
+
if (m = line.match(/\A(\w+)\s+=\s+(0x[0-9a-f]+)/))
|
|
260
|
+
registers[m[1]] = m[2]
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
{signal: signal, registers: registers, fault_addr: registers["rip"] || registers["eip"],
|
|
264
|
+
control: :none}
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def lldb_triage(binary, input)
|
|
268
|
+
input_file = File.join(Dir.tmpdir, "augure_fuzz_input_#{Process.pid}")
|
|
269
|
+
File.binwrite(input_file, input)
|
|
270
|
+
out, _err, _status = Open3.capture3(
|
|
271
|
+
"lldb", "--batch", "-o", "run < #{input_file}", "-o", "register read",
|
|
272
|
+
"-o", "bt", binary
|
|
273
|
+
)
|
|
274
|
+
parse_lldb(out)
|
|
275
|
+
ensure
|
|
276
|
+
File.delete(input_file) if input_file && File.exist?(input_file)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def parse_lldb(output)
|
|
280
|
+
signal = output[/received signal (SIG\w+)/, 1]
|
|
281
|
+
registers = {}
|
|
282
|
+
output.each_line do |line|
|
|
283
|
+
if (m = line.match(/\A\s*(\w+) = (0x[0-9a-f]+)/))
|
|
284
|
+
registers[m[1]] = m[2]
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
{signal: signal, registers: registers, fault_addr: registers["rip"] || registers["eip"],
|
|
288
|
+
control: :none}
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "metasm"
|
|
4
|
+
require_relative "static_offset"
|
|
5
|
+
|
|
6
|
+
module AugureProfiler
|
|
7
|
+
# The general gadget hunter: the byte patterns catch the classics;
|
|
8
|
+
# this walks EVERY offset of the executable sections, decodes the
|
|
9
|
+
# chain that ends on a ret, and classifies it semantically. The
|
|
10
|
+
# multi-instruction gadgets (pop rsi; pop r15; ret) the patterns
|
|
11
|
+
# never see come out of here.
|
|
12
|
+
module GadgetHunt
|
|
13
|
+
MAX_CHAIN = 8
|
|
14
|
+
# the instructions that end a hunt-able chain
|
|
15
|
+
TERMINAL = %w[ret].freeze
|
|
16
|
+
# the semantics that kill control (the chain dies there)
|
|
17
|
+
KILLERS = %w[call jmp lret hlt iret syscall int leave loop].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# Returns [{type:, addr:}] - the facts-ready tuples.
|
|
22
|
+
def hunt(path)
|
|
23
|
+
elf = Metasm::ELF.decode_file(path)
|
|
24
|
+
arch = (elf.header.e_class.to_s == "64") ? :x64 : :x86
|
|
25
|
+
cpu = (arch == :x64) ? Metasm::X64.new : Metasm::Ia32.new
|
|
26
|
+
gadgets = []
|
|
27
|
+
elf.sections.select { |s| s.flags.map(&:to_s).include?("EXECINSTR") && s.size.to_i > 0 }
|
|
28
|
+
.each do |sec|
|
|
29
|
+
data = StaticOffset.section_data(path, sec)
|
|
30
|
+
base = sec.addr.to_i
|
|
31
|
+
# ONE EncodedData per section: decode_instruction reads from
|
|
32
|
+
# edata.ptr, so each offset is a pointer move - not a copy of
|
|
33
|
+
# the remaining section (the old data[off..] was O(n^2) in
|
|
34
|
+
# memory traffic: ~512 GB cumulative for a 1 MB .text).
|
|
35
|
+
edata = Metasm::EncodedData.new(data)
|
|
36
|
+
(0...data.bytesize).each do |i|
|
|
37
|
+
chain = decode_chain(cpu, edata, i, base)
|
|
38
|
+
next unless chain
|
|
39
|
+
|
|
40
|
+
type = classify(chain)
|
|
41
|
+
next unless type
|
|
42
|
+
|
|
43
|
+
gadgets << {type: type, addr: base + i}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
gadgets
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# decode up to MAX_CHAIN instructions starting at offset i; the
|
|
50
|
+
# chain must END on a terminal (ret) with no killer inside. Returns
|
|
51
|
+
# the instruction STRINGS or nil. edata is shared: decode_at moves
|
|
52
|
+
# its ptr, callers must not assume it stays put.
|
|
53
|
+
def decode_chain(cpu, edata, i, _base)
|
|
54
|
+
instrs = []
|
|
55
|
+
off = i
|
|
56
|
+
limit = edata.data.bytesize
|
|
57
|
+
MAX_CHAIN.times do
|
|
58
|
+
return nil if off >= limit
|
|
59
|
+
|
|
60
|
+
di = decode_at(cpu, edata, off)
|
|
61
|
+
return nil unless di
|
|
62
|
+
|
|
63
|
+
text = di.instruction.to_s
|
|
64
|
+
op = di.instruction.opname
|
|
65
|
+
return nil if KILLERS.include?(op)
|
|
66
|
+
|
|
67
|
+
break if TERMINAL.include?(op)
|
|
68
|
+
|
|
69
|
+
instrs << text
|
|
70
|
+
off += di.bin_length.to_i
|
|
71
|
+
return nil if instrs.size >= MAX_CHAIN
|
|
72
|
+
end
|
|
73
|
+
return nil if instrs.empty?
|
|
74
|
+
|
|
75
|
+
instrs + ["ret"]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# decode one instruction at edata offset off - metasm's one-shot
|
|
79
|
+
# decode_instruction reading from the shared EncodedData's ptr
|
|
80
|
+
def decode_at(cpu, edata, off)
|
|
81
|
+
edata.ptr = off
|
|
82
|
+
di = cpu.decode_instruction(edata, 0)
|
|
83
|
+
di&.instruction ? di : nil
|
|
84
|
+
rescue Metasm::DecodeError, Metasm::ParseError
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def classify(instrs)
|
|
89
|
+
tail = instrs.last
|
|
90
|
+
return nil unless tail == "ret"
|
|
91
|
+
|
|
92
|
+
pops = instrs[0...-1].select { |i| i.start_with?("pop ") }
|
|
93
|
+
if instrs.length == pops.length + 1 && pops.any?
|
|
94
|
+
regs = pops.map { |i| i.delete_prefix("pop ") }
|
|
95
|
+
return "pop_#{regs.join("_")}_ret"
|
|
96
|
+
end
|
|
97
|
+
return "syscall_ret" if instrs.length == 2 && instrs.first.start_with?("syscall")
|
|
98
|
+
return "int80_ret" if instrs.length == 2 && instrs.first.start_with?("int 0x80")
|
|
99
|
+
return "xchg_rsp_rax" if instrs.length == 2 && instrs.first =~ /xchg .*rsp.*|xchg .*esp.*/
|
|
100
|
+
|
|
101
|
+
if instrs.length == 2 && instrs.first.start_with?("mov ") && instrs.first.include?("[") &&
|
|
102
|
+
instrs.first.include?("]")
|
|
103
|
+
return "mov_deref_write"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|