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.
@@ -0,0 +1,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "metasm"
4
+
5
+ module AugureProfiler
6
+ # The gdb-free offset: read it straight off the frame. Decode the ELF,
7
+ # resolve the unsafe imports' PLT stubs (the .rela.plt order), find the
8
+ # call site, and read the buffer's rbp displacement in the argument
9
+ # setup: offset = displacement + saved-rbp + return slot. The debugger
10
+ # stays as the verifier, not the measurer.
11
+ module StaticOffset
12
+ UNSAFE_CALLS = %w[strcpy gets scanf sprintf read readline strcat].freeze
13
+
14
+ module_function
15
+
16
+ # metasm leaves section.encoded nil on decode: the raw bytes come
17
+ # from the file at the section's offset (same as the profiler).
18
+ def self.section_data(path, sec)
19
+ File.open(path) do |f|
20
+ f.seek(sec.offset.to_i)
21
+ f.read(sec.size.to_i)
22
+ end
23
+ end
24
+
25
+ # Returns {offset:, sink:, function:} or nil when no unsafe call site
26
+ # carries a readable rbp-frame buffer.
27
+ def offset(path)
28
+ elf = Metasm::ELF.decode_file(path)
29
+ arch = (elf.header.e_class.to_s == "64") ? :x64 : :x86
30
+ @path = path
31
+ stubs = plt_stubs(elf, arch)
32
+ unsafe = stubs.slice(*UNSAFE_CALLS)
33
+ return nil if unsafe.empty?
34
+
35
+ @unsafe_addrs = unsafe.invert
36
+
37
+ text = elf.sections.find { |s| s.name == ".text" }
38
+ data = section_data(@path, text)
39
+ bytes = data.bytes
40
+ # The CHEAP scan first: a linear e8 rel32 walk nets every call to
41
+ # an unsafe stub - the full .text disassembly (minutes on a 1MB
42
+ # text) is only needed if this finds nothing.
43
+ call_site = nil
44
+ sink = nil
45
+ bytes.each_index do |i|
46
+ next unless bytes[i] == 0xe8
47
+
48
+ target = text.addr + i + 5 + bytes[(i + 1)..(i + 4)].pack("C*").unpack1("l<")
49
+ next unless (name = @unsafe_addrs[target])
50
+
51
+ call_site = text.addr + i
52
+ sink = name
53
+ break
54
+ end
55
+ unless call_site
56
+ # LAST RESORT: the flow disassembly of the whole .text (minutes
57
+ # on a 1MB text - the e8 scan above almost always wins).
58
+ cpu = (arch == :x64) ? Metasm::X64.new : Metasm::Ia32.new
59
+ sc = Metasm::Shellcode.new(cpu)
60
+ sc.base_addr = text.addr
61
+ sc.encoded = Metasm::EncodedData.new(data)
62
+ ds = sc.disassemble(text.addr)
63
+ elf.symbols.select do |sym|
64
+ sym.type.to_s == "FUNC" && sym.value.to_i >= text.addr && sym.value.to_i < text.addr + text.size.to_i
65
+ end
66
+ .each { |sym| ds.disassemble(sym.value) }
67
+ instrs = ds.decoded.to_a.sort_by { |a, _| a }.map { |a, di| [a, di.instruction] }
68
+ call_idx = instrs.index do |_a, instr|
69
+ next false unless instr.opname == "call"
70
+
71
+ arg = instr.args.first
72
+ arg.respond_to?(:expression) ? @unsafe_addrs.key?(arg.expression.reduce.to_i) : false
73
+ end
74
+ call_site = call_idx && instrs.dig(call_idx, 0)
75
+ if call_site
76
+ arg = instrs.dig(call_idx, 1)&.args&.first
77
+ sink = arg&.respond_to?(:expression) ? @unsafe_addrs[arg.expression.reduce.to_i] : nil
78
+ end
79
+ end
80
+ result = if call_site.is_a?(Integer) # nil or Integer: nil.is_a? is false
81
+ frame_bytes(data, call_site - text.addr, arch)
82
+ elsif call_site
83
+ frame_disp(instrs, call_idx, arch)
84
+ end
85
+ result&.tap do |r|
86
+ r[:sink] ||= sink
87
+ site = call_site
88
+ r[:function] = enclosing_function(elf, site.to_i)
89
+ end
90
+ rescue Metasm::ParseError, Metasm::InvalidExeFormat
91
+ nil
92
+ end
93
+
94
+ # Byte-pattern net: lea reg, [rbp-disp8] anywhere in the window
95
+ # before the unsafe call. x64: [REX.W] 8d modrm(mod=01,rm=101) disp8;
96
+ # x86: 8d modrm(mod=01,rm=101) disp8 (rbp/ebp addressed frames only).
97
+ def frame_bytes(data, call_off, arch)
98
+ width = (arch == :x64) ? 8 : 4
99
+ saved = width
100
+ rex_w = (arch == :x64) ? [0x48, 0x49, 0x4c, 0x4d] : nil
101
+ start = [call_off - 64, 2].max
102
+ (call_off - 2).downto(start) do |j|
103
+ modrm = data.getbyte(j)
104
+ next unless (modrm & 0xC7) == 0x45
105
+ next unless data.getbyte(j - 1) == 0x8d
106
+ next unless (arch == :x64) ? rex_w.include?(data.getbyte(j - 2)) : true
107
+
108
+ disp = data.getbyte(j + 1)
109
+ disp -= 256 if disp > 127
110
+ return {offset: saved - disp, sink: nil}
111
+ end
112
+ nil
113
+ end
114
+
115
+ # The buffer displacement in the argument setup, then the frame math:
116
+ # on x64: [rbp-N] -> saved rbp at N..N+8, return at N+8..N+16.
117
+ def frame_disp(instrs, call_idx, arch)
118
+ saved = (arch == :x64) ? 8 : 4
119
+ base = (arch == :x64) ? "rbp" : "ebp"
120
+ instrs[[call_idx - 8, 0].max...call_idx].reverse_each do |di|
121
+ s = di.instruction.to_s
122
+ next unless di.opname == "lea" && s =~ /\[#{base}(-0x[0-9a-f]+)\]/i
123
+
124
+ disp = Regexp.last_match(1).to_i(16)
125
+ return {offset: saved - disp,
126
+ sink: @unsafe_addrs[instrs[call_idx].instruction.args.first.expression.reduce.to_i]}
127
+ end
128
+ nil
129
+ end
130
+
131
+ def enclosing_function(elf, addr)
132
+ func = elf.symbols.select { |s| s.type.to_s == "FUNC" && s.value.to_i > 0 }
133
+ .find { |s| addr.to_i >= s.value.to_i && addr.to_i < s.value.to_i + s.size.to_i }
134
+ func&.name.to_s
135
+ end
136
+
137
+ # .rela.plt order. CFI toolchains put the call targets in .plt.sec
138
+ # (16 * i, same order); the classic .plt has PLT0 first (16 * (i + 1)).
139
+ # The symbol index indexes .dynsym.
140
+ def plt_stubs(elf, _arch)
141
+ pltsec = elf.sections.find { |s| s.name == ".plt.sec" }
142
+ plt = pltsec || elf.sections.find { |s| s.name == ".plt" }
143
+ rela = elf.sections.find { |s| s.name == ".rela.plt" }
144
+ dynsym = elf.sections.find { |s| s.name == ".dynsym" }
145
+ dynstr = elf.sections.find { |s| s.name == ".dynstr" }
146
+ return {} unless plt && rela && dynsym && dynstr
147
+
148
+ sym_data = section_data(@path, dynsym)
149
+ str_data = section_data(@path, dynstr)
150
+ name_at = lambda do |idx|
151
+ off = sym_data[idx * 24, 4].unpack1("V")
152
+ str_data[off..].unpack1("Z*")
153
+ end
154
+
155
+ entries = section_data(@path, rela)
156
+ count = entries.size / 24
157
+ stubs = {}
158
+ count.times do |i|
159
+ r_info = entries[i * 24 + 8, 8].unpack1("Q<")
160
+ name = name_at.call(r_info >> 32)
161
+ stubs[name] = (plt.name == ".plt.sec") ? plt.addr + 16 * i : plt.addr + 16 * (i + 1)
162
+ end
163
+ stubs
164
+ end
165
+
166
+ # Gadget honesty: a fact claims "at this address there is a ret" -
167
+ # re-decode the bytes and confirm the claim before anything consumes
168
+ # it. A lying gadget (wrong address, mid-instruction c3) is refused.
169
+ # Returns true when the decoded chain matches the type's semantics.
170
+ def self.verify_gadget(path, type, addr)
171
+ elf = Metasm::ELF.decode_file(path)
172
+ sec = elf.sections.find do |s|
173
+ s.flags.map(&:to_s).include?("EXECINSTR") &&
174
+ addr.to_i >= s.addr.to_i && addr.to_i < s.addr.to_i + s.size.to_i
175
+ end
176
+ return false unless sec
177
+
178
+ arch = (elf.header.e_class.to_s == "64") ? :x64 : :x86
179
+ cpu = (arch == :x64) ? Metasm::X64.new : Metasm::Ia32.new
180
+ data = section_data(path, sec)
181
+ sc = Metasm::Shellcode.new(cpu)
182
+ sc.base_addr = sec.addr.to_i
183
+ sc.encoded = Metasm::EncodedData.new(data)
184
+ ds = sc.disassemble(addr.to_i)
185
+ di = ds.di_at(addr.to_i)
186
+ return false unless di
187
+
188
+ expected = expected_chain(type, arch)
189
+ return false if expected.empty?
190
+
191
+ cur = addr.to_i
192
+ expected.each do |want|
193
+ di = ds.di_at(cur) || (ds.disassemble(cur) && ds.di_at(cur))
194
+ return false unless di
195
+
196
+ instr = di.instruction.to_s
197
+ case want
198
+ when /\Apop (.+)\z/
199
+ return false unless di.instruction.opname == "pop" && di.instruction.args.first.to_s == Regexp.last_match(1)
200
+ when /\Ajmp (.+)\z/
201
+ return false unless di.instruction.opname == "jmp" && instr.include?(Regexp.last_match(1))
202
+ else
203
+ return false unless instr == want || instr.start_with?(want)
204
+ end
205
+ cur += di.bin_length.to_i
206
+ end
207
+ true
208
+ rescue Metasm::ParseError, Metasm::InvalidExeFormat
209
+ false
210
+ end
211
+
212
+ def self.expected_chain(type, arch)
213
+ sp = (arch == :x64) ? "rsp" : "esp"
214
+ case type
215
+ when "ret_align" then ["ret"]
216
+ when "jmp_rsp" then ["jmp #{sp}"]
217
+ when "jmp_esp" then ["jmp #{sp}"]
218
+ when "syscall_ret" then %w[syscall ret]
219
+ when "int80_ret" then ["int"]
220
+ when /\Apop_.*_ret\z/
221
+ type.delete_prefix("pop_").delete_suffix("_ret").split("_").map { |r| "pop #{r}" } + ["ret"]
222
+ else
223
+ []
224
+ end
225
+ end
226
+
227
+ # The crash, identified instead of guessed. The runtime rip cannot be
228
+ # slid back through ASLR, so the identification comes from the STATIC
229
+ # side: the vulnerable function and its sink are facts the frame read
230
+ # already established, and the CONTROL evidence is the pattern the
231
+ # cyclic input planted in the registers (rbp/rip carrying pattern
232
+ # bytes = the saved frame IS ours - the old tool inferred this from
233
+ # 0x41414141 shapes; here the pattern is known, so the proof is exact).
234
+ def self.crash_facts(path, report)
235
+ return {} unless report.is_a?(Hash) && report[:signal] == "SIGSEGV"
236
+
237
+ static = offset(path) || {}
238
+ facts = {vuln_function: static[:function], sink: static[:sink]}.compact
239
+
240
+ pattern = nil
241
+ if static[:offset]
242
+ sweep = begin
243
+ AugureProfiler::Fuzzer.run(path, inputs: 40, max_len: 1024)
244
+ rescue
245
+ nil
246
+ end
247
+ crash_len = sweep&.first&.dig(:input)&.bytesize
248
+ pattern = Triage.cyclic(crash_len + 64) if crash_len
249
+ end
250
+
251
+ controlled = %w[rip rsp rbp rax rbx rcx rdx rdi rsi r8 r9 r10 r11 r12 r13 r14 r15]
252
+ .any? do |reg|
253
+ val = report.dig(:registers, reg)
254
+ next false unless val.is_a?(String) && val.start_with?("0x")
255
+
256
+ raw = [val[2..].to_i(16)].pack("Q<")
257
+ # the register carries OUR bytes: a pattern subsequence (truncated
258
+ # to 4 bytes covers the classic ret-slot window) or the A-fill
259
+ raw.include?("AAAA") || (pattern && [8, 4].any? { |w| pattern.include?(raw[0, w]) })
260
+ end
261
+ facts[:crash_site] = controlled ? "ret" : "unknown"
262
+ facts
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AugureProfiler
4
+ VERSION = "0.0.1"
5
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "augure-profiler/version"
4
+ require_relative "augure-profiler/profiler"
5
+ require_relative "augure-profiler/fuzzer"
6
+ require_relative "augure-profiler/shellcode"
7
+ require_relative "augure-profiler/static_offset"
8
+ require_relative "augure-profiler/dynamic_trace"
9
+ require_relative "augure-profiler/gadget_hunt"
10
+
11
+ module AugureProfiler
12
+ end
metadata ADDED
@@ -0,0 +1,84 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: augure-profiler
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Gregory Epp
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: augure
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: metasm
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
40
+ description: 'Generates augure fact files from ELF binaries via metasm: checksec protections,
41
+ PLT imports, unsafe-symbol hints and semantically classified gadgets. The profiler
42
+ speaks facts; augure decides.'
43
+ email:
44
+ - log0u7@users.noreply.github.com
45
+ executables:
46
+ - augure-profile
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - "/home/runner/work/augure/augure/profiler/exe/augure-profile"
51
+ - "/home/runner/work/augure/augure/profiler/lib/augure-profiler.rb"
52
+ - "/home/runner/work/augure/augure/profiler/lib/augure-profiler/dynamic_trace.rb"
53
+ - "/home/runner/work/augure/augure/profiler/lib/augure-profiler/fuzzer.rb"
54
+ - "/home/runner/work/augure/augure/profiler/lib/augure-profiler/gadget_hunt.rb"
55
+ - "/home/runner/work/augure/augure/profiler/lib/augure-profiler/profiler.rb"
56
+ - "/home/runner/work/augure/augure/profiler/lib/augure-profiler/shellcode.rb"
57
+ - "/home/runner/work/augure/augure/profiler/lib/augure-profiler/static_offset.rb"
58
+ - "/home/runner/work/augure/augure/profiler/lib/augure-profiler/version.rb"
59
+ - exe/augure-profile
60
+ homepage: https://github.com/log0u7/augure
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ homepage_uri: https://github.com/log0u7/augure
65
+ source_code_uri: https://github.com/log0u7/augure
66
+ changelog_uri: https://github.com/log0u7/augure/blob/main/CHANGELOG.md
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '3.4'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.6.9
82
+ specification_version: 4
83
+ summary: 'Binary profiler for the augure decision layer: ELF in, Datalog facts out.'
84
+ test_files: []