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