augure-profiler 0.0.2 → 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: 68f4e4a0deb75c047b7a76b09695771e42a28d1b81a36d7ecabf211061022078
4
- data.tar.gz: 178c357a361d80a14934c4d0fa871f545fbd0c680ab6708f47e0f6e486f5b2df
3
+ metadata.gz: 286018f7712ec4f495a1703eaea59f3c114f49b18196f69e291a0a1e7ff3c699
4
+ data.tar.gz: 4403aef59eb80f8fac2425216d6f25f2cc25c06d8d62017089a2c1e15e03685b
5
5
  SHA512:
6
- metadata.gz: a78bc1cdb1adf17cc25ca268508d6462b47da49466ffdedd4bfdbaae2be694c2ba74898bc822f949c98ad076ca1071f776b7fe2be6a3bbf6b6ed97484f171729
7
- data.tar.gz: f99e40af9b29d45c48dc1ae999f4c5b18403b251ce854b78a14b83ab50e58c5812878620c3668501e1357aae7368397838b3e25737516807a7d95ffd7d585c68
6
+ metadata.gz: 75a950feec5e2a0e5c225ee0561ae808e3693dc0fe95745f7635af553044457525c3b71a59e9a06117efab1e62aca711c200cf0bb8ef4a29dffe5f6787fb169c
7
+ data.tar.gz: bd1bc7c1c60722ca35038a554edb2311ab7a562e06853a6a6d11ac4e3bba77599fdd9a8451609731f7e5a240ccf17be00a0f7ad6ff2ae7ed0a3b7a2962f90c9a
@@ -47,9 +47,32 @@ module AugureProfiler
47
47
  emit_checksec(facts)
48
48
  emit_symbols(facts)
49
49
  emit_gadgets(facts)
50
+ emit_vuln_facts(facts)
50
51
  facts
51
52
  end
52
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
+
53
76
  # the csu byte signatures, both known compiler flavors: the popper
54
77
  # = pop rbx,rbp,r12,r13,r14,r15 ; ret (the chain re-enters at the
55
78
  # pop rbx), the mov block ends in the csu call signature and starts
@@ -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.2"
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.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gregory Epp