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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 286018f7712ec4f495a1703eaea59f3c114f49b18196f69e291a0a1e7ff3c699
|
|
4
|
+
data.tar.gz: 4403aef59eb80f8fac2425216d6f25f2cc25c06d8d62017089a2c1e15e03685b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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(*
|
|
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
|
-
|
|
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)
|