augure-profiler 0.0.1 → 0.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a668c990f083c8eb2d00f67ce45ccb99fb37525ee1013e484e63cf6b00f0b561
4
- data.tar.gz: f86407235e580f36e236b52b1c406dfb7159b5d9dea733ec5d293ff68ac54678
3
+ metadata.gz: 286018f7712ec4f495a1703eaea59f3c114f49b18196f69e291a0a1e7ff3c699
4
+ data.tar.gz: 4403aef59eb80f8fac2425216d6f25f2cc25c06d8d62017089a2c1e15e03685b
5
5
  SHA512:
6
- metadata.gz: 1493d1d5d121787f04ce661350f6eb89922cfe64555f15e9cf6cd90c7d543f45cd7d7e8eff4fcfaf7b4ad9b1492f7fd362cce5b043f10d80e319895824f2cf5f
7
- data.tar.gz: 3a19f8acd46eaa23537b10bf279442631e93a567fd22d81e5aec918e50b63c5ae782874c8779836afa3815f6f5a21bd7c8169fd84e0e2450b182ff214c4d13f4
6
+ metadata.gz: 75a950feec5e2a0e5c225ee0561ae808e3693dc0fe95745f7635af553044457525c3b71a59e9a06117efab1e62aca711c200cf0bb8ef4a29dffe5f6787fb169c
7
+ data.tar.gz: bd1bc7c1c60722ca35038a554edb2311ab7a562e06853a6a6d11ac4e3bba77599fdd9a8451609731f7e5a240ccf17be00a0f7ad6ff2ae7ed0a3b7a2962f90c9a
@@ -23,6 +23,7 @@ module AugureProfiler
23
23
  ["int80_ret", "cd 80 c3"], # int 0x80 ; ret
24
24
  ["pop_rdi_ret", "5f c3"], # pop rdi ; ret
25
25
  ["pop_rsi_ret", "5e c3"], # pop rsi ; ret
26
+ ["pop_rsi_r15_ret", "5e 41 5f c3"], # pop rsi ; pop r15 ; ret (the callme flavor)
26
27
  ["pop_rdx_ret", "5a c3"], # pop rdx ; ret
27
28
  ["pop_rax_ret", "58 c3"], # pop rax ; ret
28
29
  ["JMP_STACK", "ff e4"], # jmp esp/rsp - renamed per arch
@@ -46,9 +47,60 @@ module AugureProfiler
46
47
  emit_checksec(facts)
47
48
  emit_symbols(facts)
48
49
  emit_gadgets(facts)
50
+ emit_vuln_facts(facts)
49
51
  facts
50
52
  end
51
53
 
54
+ # The vuln facts the binary itself carries, so the operator's
55
+ # suggest stays for the knowledge NO ELF can show (a lib-side
56
+ # overflow, a heap class, a filter). The static frame read is the
57
+ # evidence: an unsafe call site feeding an rbp-frame buffer whose
58
+ # room the copy can reach - the sized family only when the length
59
+ # is not a bounded immediate. The fmtstr class: the printf-family
60
+ # format argument living on the stack.
61
+ def emit_vuln_facts(facts)
62
+ static = StaticOffset.offset(@path)
63
+ if static
64
+ facts.add("vuln", "sof")
65
+ facts.add("vuln_function", static[:function]) if static[:function] && !static[:function].empty?
66
+ facts.add("sink", static[:sink]) if static[:sink]
67
+ end
68
+
69
+ sink = StaticOffset.stack_format_sink(@path)
70
+ return unless sink
71
+
72
+ facts.add("vuln", "fmtstr")
73
+ facts.add("fmtstr_read", "true")
74
+ end
75
+
76
+ # the csu byte signatures, both known compiler flavors: the popper
77
+ # = pop rbx,rbp,r12,r13,r14,r15 ; ret (the chain re-enters at the
78
+ # pop rbx), the mov block ends in the csu call signature and starts
79
+ # 9 bytes earlier (three 3-byte register movs, REX.W/REX.WR 0x89
80
+ # opcodes - the mov order is the compiler's choice: r15->rdx on
81
+ # recent gcc, r13->rdx on the classic layout)
82
+ CSU_POPPER_BIN = ["5b5d415c415d415e415fc3"].pack("H*").freeze
83
+ CSU_CALL_BIN = ["41ff14dc"].pack("H*").freeze
84
+
85
+ def self.csu_gadget_addrs(bytes, base, extent)
86
+ win = bytes.is_a?(String) ? bytes.b : bytes.pack("C*").b
87
+ win = win.byteslice(0, extent) if extent && extent < win.bytesize
88
+ popper = win.index(CSU_POPPER_BIN)
89
+ mov = find_csu_mov(win)
90
+ {popper: popper ? base + popper : nil, mov: mov ? base + mov : nil}
91
+ end
92
+
93
+ def self.find_csu_mov(win)
94
+ off = 0
95
+ while (call = win.index(CSU_CALL_BIN, off))
96
+ start = call - 9
97
+ return start if start >= 0 && [0x4c, 0x44].include?(win.getbyte(start)) && win.getbyte(start + 1) == 0x89
98
+
99
+ off = call + 1
100
+ end
101
+ nil
102
+ end
103
+
52
104
  private
53
105
 
54
106
  def emit_checksec(facts)
@@ -90,6 +142,10 @@ module AugureProfiler
90
142
  # needs the address, not just the existence.
91
143
  @elf.symbols.each do |sym|
92
144
  next unless sym.name.to_s.match?(/\Awin\z|win\z|flag\z/)
145
+ # an IMPORT is not a win HERE: the ret2csu shape imports its
146
+ # ret2win from the companion library; the symbol's value is 0
147
+ # and a built [qword win] payload would jump to NULL
148
+ next if sym.shndx.to_s == "UNDEF"
93
149
 
94
150
  facts.add("target_function", "true")
95
151
  facts.add("win_symbol", sym.name, sym.value.to_i)
@@ -189,15 +245,39 @@ module AugureProfiler
189
245
  hits.map { |(_order, type, addr)| [type, addr] }
190
246
  end
191
247
 
192
- # Static binaries expose the __libc_csu_init universal gadgets under
193
- # well-known symbols; their presence IS the gadget fact.
248
+ # Static binaries expose the __libc_csu_init universal gadgets; their
249
+ # presence IS the gadget fact. The two useful sequences sit at
250
+ # compiler-dependent offsets INSIDE the function (the entry/+0x40
251
+ # guess broke on the newer mov rdx,r15 flavor), so both are located
252
+ # by byte signature within the symbol's extent: the popper fact aims
253
+ # at the pop rbx (the chain entry, the leading add rsp,8 stays one
254
+ # slot earlier for layouts that need it), the mov block by its call
255
+ # signature. The pack teaches the arg mapping for its target.
194
256
  def emit_csu_gadgets(facts)
195
- names = @elf.symbols.map(&:name).compact
196
- return unless names.include?("__libc_csu_init")
197
-
198
257
  sym = @elf.symbols.find { |s| s.name == "__libc_csu_init" }
199
- facts.add("gadget", "csu_popper", sym.value.to_i)
200
- facts.add("gadget", "csu_mov", sym.value.to_i + 0x40)
258
+ return unless sym
259
+
260
+ base = sym.value.to_i
261
+ extent = sym.size.to_i
262
+ extent = 0x100 if extent <= 0
263
+ section = executable_sections.find do |s|
264
+ s.addr.to_i <= base && base < s.addr.to_i + s.size.to_i
265
+ end
266
+ return unless section
267
+
268
+ window = bytes_slice(section, base - section.addr.to_i)
269
+ return if window.nil? || window.empty?
270
+
271
+ addrs = self.class.csu_gadget_addrs(window, base, extent)
272
+ facts.add("gadget", "csu_popper", addrs[:popper]) if addrs[:popper]
273
+ facts.add("gadget", "csu_mov", addrs[:mov]) if addrs[:mov]
274
+ end
275
+
276
+ def bytes_slice(section, from)
277
+ bytes = section_bytes(section)
278
+ return nil if bytes.nil?
279
+
280
+ bytes[from, bytes.size - from] || []
201
281
  end
202
282
  end
203
283
  end
@@ -9,7 +9,19 @@ module AugureProfiler
9
9
  # setup: offset = displacement + saved-rbp + return slot. The debugger
10
10
  # stays as the verifier, not the measurer.
11
11
  module StaticOffset
12
- UNSAFE_CALLS = %w[strcpy gets scanf sprintf read readline strcat].freeze
12
+ # The unbounded family: no length argument exists, the copy runs to
13
+ # the NUL (or the newline). Any call site feeding it a stack buffer
14
+ # is the overflow, no further question asked.
15
+ UNSAFE_CALLS = %w[strcpy gets scanf sprintf readline strcat].freeze
16
+ # The sized family: the length rides in an argument (rdx on x64).
17
+ # When that argument is an immediate at most as large as the room
18
+ # between the buffer and the return slot, the copy cannot reach it:
19
+ # a bounded call, not the overflow - the scan moves on. A register
20
+ # (the read's count, the ret2plt memcpy) is unbounded by definition.
21
+ BOUNDED_CALLS = %w[read fgets fread memcpy memmove strncpy].freeze
22
+ # The variadic format users: the format argument is the attacker's
23
+ # bytes only when it comes from a stack buffer.
24
+ PRINTF_LIKE = %w[printf fprintf].freeze
13
25
 
14
26
  module_function
15
27
 
@@ -24,12 +36,12 @@ module AugureProfiler
24
36
 
25
37
  # Returns {offset:, sink:, function:} or nil when no unsafe call site
26
38
  # carries a readable rbp-frame buffer.
27
- def offset(path)
39
+ def offset(path, calls: UNSAFE_CALLS + BOUNDED_CALLS)
28
40
  elf = Metasm::ELF.decode_file(path)
29
41
  arch = (elf.header.e_class.to_s == "64") ? :x64 : :x86
30
42
  @path = path
31
43
  stubs = plt_stubs(elf, arch)
32
- unsafe = stubs.slice(*UNSAFE_CALLS)
44
+ unsafe = stubs.slice(*calls)
33
45
  return nil if unsafe.empty?
34
46
 
35
47
  @unsafe_addrs = unsafe.invert
@@ -39,7 +51,9 @@ module AugureProfiler
39
51
  bytes = data.bytes
40
52
  # The CHEAP scan first: a linear e8 rel32 walk nets every call to
41
53
  # an unsafe stub - the full .text disassembly (minutes on a 1MB
42
- # text) is only needed if this finds nothing.
54
+ # text) is only needed if this finds nothing. Sites without an
55
+ # rbp-frame buffer (a global, a pointer arg) no longer stop the
56
+ # scan: a later site may carry the overflow.
43
57
  call_site = nil
44
58
  sink = nil
45
59
  bytes.each_index do |i|
@@ -48,7 +62,14 @@ module AugureProfiler
48
62
  target = text.addr + i + 5 + bytes[(i + 1)..(i + 4)].pack("C*").unpack1("l<")
49
63
  next unless (name = @unsafe_addrs[target])
50
64
 
51
- call_site = text.addr + i
65
+ site = text.addr + i
66
+ frame = frame_bytes(data, site - text.addr, arch)
67
+ next if frame.nil? || !frame[:offset]&.positive?
68
+
69
+ next if BOUNDED_CALLS.include?(name) &&
70
+ size_imm_cannot_reach?(bytes, site - text.addr, frame[:offset], arch, name)
71
+
72
+ call_site = site
52
73
  sink = name
53
74
  break
54
75
  end
@@ -112,6 +133,100 @@ module AugureProfiler
112
133
  nil
113
134
  end
114
135
 
136
+ # The sized family's length argument rides rdx (x64): an immediate
137
+ # at most as large as the room between the buffer and the return
138
+ # slot means the copy cannot reach it. x86 passes the length on the
139
+ # stack, no immediate to read: treated as unbounded (the
140
+ # conservative direction: a false vuln beats a missed one).
141
+ def size_imm_cannot_reach?(bytes, call_off, room, arch, sink)
142
+ return false unless arch == :x64
143
+
144
+ # the length argument's register: rdx for the memcpy family, rsi
145
+ # for fgets (buf, size, stream)
146
+ opcodes = (sink == "fgets") ?
147
+ [[0xbe], [0x48, 0xc7, 0xc6], [0x49, 0xc7, 0xc6]] : # mov esi/rsi, imm32
148
+ [[0xba], [0x48, 0xc7, 0xc2], [0x49, 0xc7, 0xc2]] # mov edx/rdx, imm32
149
+
150
+ start = [call_off - 16, 0].max
151
+ (start..(call_off - 5)).each do |j|
152
+ opcodes.each do |ops|
153
+ if ops.size == 1
154
+ return true if bytes[j] == ops[0] && (imm = imm32(bytes, j + 1)) && imm <= room
155
+ elsif j >= ops.size - 1
156
+ opcode_ok = (1...(ops.size)).all? { |k| bytes[j - k] == ops[ops.size - 1 - k] }
157
+ return true if opcode_ok && bytes[j] == ops.last && (imm = imm32(bytes, j + 1)) && imm <= room
158
+ end
159
+ end
160
+ end
161
+ false
162
+ end
163
+
164
+ def imm32(bytes, off)
165
+ return nil if off.negative? || off + 4 > bytes.size
166
+
167
+ bytes[off, 4].pack("C*").unpack1("L<")
168
+ end
169
+
170
+ # The printf-family sites whose format argument is a STACK buffer:
171
+ # lea reg,[rbp-disp] feeding rdi (directly, 48 8d 7d d8, or through
172
+ # a register move, 48 8d 45 d8 then 48 89 c7) means the format is
173
+ # the attacker's bytes - the fmtstr class. A .rodata format rides
174
+ # an immediate (mov edi, imm32): not a vuln. x86 passes args on the
175
+ # stack, no lea-into-rdi idiom to read: nil (an honest miss).
176
+ def stack_format_sink(path)
177
+ elf = Metasm::ELF.decode_file(path)
178
+ arch = (elf.header.e_class.to_s == "64") ? :x64 : :x86
179
+ return nil unless arch == :x64
180
+
181
+ @path = path # plt_stubs reads the section bytes through @path
182
+
183
+ stubs = plt_stubs(elf, arch)
184
+ like = stubs.slice(*PRINTF_LIKE)
185
+ return nil if like.empty?
186
+
187
+ like_addrs = like.invert
188
+ text = elf.sections.find { |s| s.name == ".text" }
189
+ data = section_data(path, text)
190
+ bytes = data.bytes
191
+ bytes.each_index do |i|
192
+ next unless bytes[i] == 0xe8
193
+
194
+ target = text.addr + i + 5 + bytes[(i + 1)..(i + 4)].pack("C*").unpack1("l<")
195
+ next unless like_addrs[target]
196
+
197
+ return like_addrs[target] if stack_format_rdi?(bytes, i)
198
+ end
199
+ nil
200
+ rescue Metasm::ParseError, Metasm::InvalidExeFormat
201
+ nil
202
+ end
203
+
204
+ # a mini-modrm decode of the lea feeding the format argument: REX.W
205
+ # (48) + 8d + modrm(mod=01|10, rm=101=rbp) = lea reg,[rbp-disp8|32].
206
+ # reg=rdi (111) is the direct form; reg=rax (000) needs the
207
+ # immediate mov rdi, rax (48 89 c7) right after the lea. Only 0x48
208
+ # qualifies as REX: REX.B would turn rbp into r13.
209
+ def stack_format_rdi?(bytes, call_off)
210
+ start = [call_off - 24, 0].max
211
+ (start...(call_off - 3)).each do |j|
212
+ next unless bytes[j] == 0x48
213
+ next unless bytes[j + 1] == 0x8d
214
+
215
+ modrm = bytes[j + 2]
216
+ mod = modrm >> 6
217
+ next unless mod == 0b01 || mod == 0b10
218
+ next unless (modrm & 7) == 0b101
219
+
220
+ reg = (modrm >> 3) & 7
221
+ len = (mod == 0b01) ? 4 : 7
222
+ return true if reg == 0b111
223
+ next unless reg == 0b000
224
+
225
+ return true if bytes[j + len, 3] == [0x48, 0x89, 0xc7]
226
+ end
227
+ false
228
+ end
229
+
115
230
  # The buffer displacement in the argument setup, then the frame math:
116
231
  # on x64: [rbp-N] -> saved rbp at N..N+8, return at N+8..N+16.
117
232
  def frame_disp(instrs, call_idx, arch)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AugureProfiler
4
- VERSION = "0.0.1"
4
+ VERSION = "0.0.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: augure-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregory Epp