pwn 0.5.739 → 0.5.741

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.
Files changed (163) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Agent-Tool-Registry.md +163 -6
  4. data/documentation/Diagrams.md +1 -1
  5. data/documentation/Home.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +6 -2
  8. data/documentation/Recon-Findings-API.md +33 -1
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-ai-Agent.md +1 -1
  11. data/etc/default_skills/pwn/ai/agent/confirmation/SKILL.md +49 -0
  12. data/etc/default_skills/pwn/ai/agent/result/SKILL.md +4 -1
  13. data/etc/default_skills/pwn/ai/agent/task_dag/SKILL.md +50 -0
  14. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/browser_goto/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/agent/tools/loot_query/SKILL.md +45 -0
  17. data/etc/default_skills/pwn/ai/agent/tools/nmap_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/ai/agent/tools/nuclei_scan/SKILL.md +45 -0
  19. data/etc/default_skills/pwn/ai/agent/tools/radare2/SKILL.md +45 -0
  20. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +3 -0
  21. data/etc/default_skills/pwn/ai/router/SKILL.md +51 -0
  22. data/etc/default_skills/pwn/engagement/SKILL.md +2 -0
  23. data/etc/default_skills/pwn/ffi/capstone/SKILL.md +54 -0
  24. data/etc/default_skills/pwn/ffi/keystone/SKILL.md +50 -0
  25. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +5 -1
  26. data/etc/default_skills/pwn/plugins/ai_sandbox/SKILL.md +52 -0
  27. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/assembly/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  31. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  32. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +8 -2
  33. data/etc/default_skills/pwn/plugins/httpx/SKILL.md +49 -0
  34. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  35. data/etc/default_skills/pwn/plugins/method_catalog/SKILL.md +50 -0
  36. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +2 -0
  37. data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -1
  38. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  39. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +3 -0
  40. data/etc/default_skills/pwn/plugins/recon/SKILL.md +1 -0
  41. data/etc/default_skills/pwn/plugins/rop/SKILL.md +1 -0
  42. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +1 -0
  43. data/etc/default_skills/pwn/plugins/vault/SKILL.md +3 -0
  44. data/etc/default_skills/pwn/reports/SKILL.md +2 -0
  45. data/lib/pwn/ai/agent/confirmation.rb +163 -0
  46. data/lib/pwn/ai/agent/dispatch.rb +43 -5
  47. data/lib/pwn/ai/agent/loop.rb +45 -11
  48. data/lib/pwn/ai/agent/prompt_builder.rb +7 -4
  49. data/lib/pwn/ai/agent/registry.rb +2 -1
  50. data/lib/pwn/ai/agent/result.rb +76 -6
  51. data/lib/pwn/ai/agent/task_dag.rb +220 -0
  52. data/lib/pwn/ai/agent/tool_guard.rb +166 -24
  53. data/lib/pwn/ai/agent/tools/artifacts.rb +35 -5
  54. data/lib/pwn/ai/agent/tools/binary_triage.rb +7 -3
  55. data/lib/pwn/ai/agent/tools/browser_goto.rb +32 -0
  56. data/lib/pwn/ai/agent/tools/debug_lane.rb +23 -0
  57. data/lib/pwn/ai/agent/tools/exploitdev.rb +32 -12
  58. data/lib/pwn/ai/agent/tools/finding_record.rb +31 -5
  59. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +44 -21
  60. data/lib/pwn/ai/agent/tools/job_run.rb +30 -18
  61. data/lib/pwn/ai/agent/tools/loot_query.rb +44 -0
  62. data/lib/pwn/ai/agent/tools/nmap_scan.rb +32 -0
  63. data/lib/pwn/ai/agent/tools/nuclei_scan.rb +31 -0
  64. data/lib/pwn/ai/agent/tools/radare2.rb +55 -0
  65. data/lib/pwn/ai/agent/tools/ruby_eval.rb +19 -2
  66. data/lib/pwn/ai/agent/tools/shell.rb +22 -10
  67. data/lib/pwn/ai/agent/turn_finalizer.rb +46 -5
  68. data/lib/pwn/ai/agent.rb +3 -1
  69. data/lib/pwn/ai/cli.rb +26 -2
  70. data/lib/pwn/ai/context.rb +4 -2
  71. data/lib/pwn/ai/router.rb +196 -0
  72. data/lib/pwn/ai/tools/README.md +5 -0
  73. data/lib/pwn/ai.rb +1 -0
  74. data/lib/pwn/config.rb +10 -0
  75. data/lib/pwn/engagement.rb +45 -1
  76. data/lib/pwn/ffi/capstone.rb +120 -0
  77. data/lib/pwn/ffi/keystone.rb +107 -0
  78. data/lib/pwn/ffi.rb +2 -0
  79. data/lib/pwn/migrate.rb +5 -1
  80. data/lib/pwn/plugins/aflplusplus.rb +238 -13
  81. data/lib/pwn/plugins/ai_sandbox.rb +240 -0
  82. data/lib/pwn/plugins/artifact_registry.rb +188 -22
  83. data/lib/pwn/plugins/assembly.rb +73 -2
  84. data/lib/pwn/plugins/basic_auth.rb +17 -4
  85. data/lib/pwn/plugins/binary_parser.rb +439 -38
  86. data/lib/pwn/plugins/exploit_dev.rb +83 -14
  87. data/lib/pwn/plugins/findings.rb +208 -42
  88. data/lib/pwn/plugins/gdbmi.rb +265 -38
  89. data/lib/pwn/plugins/httpx.rb +93 -0
  90. data/lib/pwn/plugins/jobs.rb +260 -102
  91. data/lib/pwn/plugins/method_catalog.rb +328 -0
  92. data/lib/pwn/plugins/nmap_it.rb +157 -26
  93. data/lib/pwn/plugins/nuclei.rb +122 -29
  94. data/lib/pwn/plugins/preflight_checker.rb +3 -2
  95. data/lib/pwn/plugins/process_tube.rb +51 -2
  96. data/lib/pwn/plugins/radare2.rb +120 -26
  97. data/lib/pwn/plugins/recon.rb +33 -0
  98. data/lib/pwn/plugins/repl/mesh.rb +203 -13
  99. data/lib/pwn/plugins/rop.rb +121 -31
  100. data/lib/pwn/plugins/transparent_browser.rb +212 -14
  101. data/lib/pwn/plugins/vault.rb +182 -24
  102. data/lib/pwn/plugins.rb +4 -0
  103. data/lib/pwn/reports/engagement.rb +8 -2
  104. data/lib/pwn/reports/html.rb +28 -9
  105. data/lib/pwn/reports/json.rb +2 -1
  106. data/lib/pwn/reports/markdown.rb +49 -12
  107. data/lib/pwn/reports/sarif.rb +17 -3
  108. data/lib/pwn/reports.rb +184 -34
  109. data/lib/pwn/version.rb +1 -1
  110. data/spec/integration/reports_spec.rb +93 -0
  111. data/spec/integration/result_condition_spec.rb +11 -8
  112. data/spec/lib/pwn/ai/agent/confirmation_spec.rb +59 -0
  113. data/spec/lib/pwn/ai/agent/dispatch_confirmation_spec.rb +52 -0
  114. data/spec/lib/pwn/ai/agent/dispatch_policy_spec.rb +51 -0
  115. data/spec/lib/pwn/ai/agent/loop_spec.rb +86 -0
  116. data/spec/lib/pwn/ai/agent/registry_spec.rb +1 -1
  117. data/spec/lib/pwn/ai/agent/result_spec.rb +84 -1
  118. data/spec/lib/pwn/ai/agent/task_dag_spec.rb +63 -0
  119. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +48 -0
  120. data/spec/lib/pwn/ai/agent/tools/ai_sandbox_spec.rb +36 -0
  121. data/spec/lib/pwn/ai/agent/tools/binary_triage_spec.rb +21 -0
  122. data/spec/lib/pwn/ai/agent/tools/browser_goto_spec.rb +57 -0
  123. data/spec/lib/pwn/ai/agent/tools/exploitdev_spec.rb +42 -0
  124. data/spec/lib/pwn/ai/agent/tools/finding_record_spec.rb +46 -2
  125. data/spec/lib/pwn/ai/agent/tools/fuzz_campaign_spec.rb +46 -0
  126. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +52 -0
  127. data/spec/lib/pwn/ai/agent/tools/loot_query_spec.rb +46 -0
  128. data/spec/lib/pwn/ai/agent/tools/nmap_scan_spec.rb +26 -0
  129. data/spec/lib/pwn/ai/agent/tools/nuclei_scan_spec.rb +37 -0
  130. data/spec/lib/pwn/ai/agent/tools/pwn_eval_schema_spec.rb +27 -0
  131. data/spec/lib/pwn/ai/agent/tools/radare2_spec.rb +15 -0
  132. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +76 -0
  133. data/spec/lib/pwn/ai/cli_spec.rb +12 -1
  134. data/spec/lib/pwn/ai/router_spec.rb +57 -0
  135. data/spec/lib/pwn/ffi/capstone_spec.rb +33 -0
  136. data/spec/lib/pwn/ffi/keystone_spec.rb +33 -0
  137. data/spec/lib/pwn/migrate_spec.rb +30 -2
  138. data/spec/lib/pwn/plugins/aflplusplus_spec.rb +40 -0
  139. data/spec/lib/pwn/plugins/ai_sandbox_spec.rb +44 -0
  140. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +116 -1
  141. data/spec/lib/pwn/plugins/assembly_spec.rb +12 -0
  142. data/spec/lib/pwn/plugins/basic_auth_spec.rb +15 -0
  143. data/spec/lib/pwn/plugins/binary_parser_spec.rb +59 -2
  144. data/spec/lib/pwn/plugins/exploit_dev_spec.rb +44 -0
  145. data/spec/lib/pwn/plugins/findings_structured_spec.rb +196 -1
  146. data/spec/lib/pwn/plugins/gdbmi_spec.rb +99 -1
  147. data/spec/lib/pwn/plugins/httpx_spec.rb +23 -0
  148. data/spec/lib/pwn/plugins/jobs_spec.rb +141 -0
  149. data/spec/lib/pwn/plugins/method_catalog_spec.rb +29 -0
  150. data/spec/lib/pwn/plugins/nmap_it_spec.rb +49 -0
  151. data/spec/lib/pwn/plugins/nuclei_spec.rb +33 -1
  152. data/spec/lib/pwn/plugins/radare2_spec.rb +115 -0
  153. data/spec/lib/pwn/plugins/recon_spec.rb +15 -0
  154. data/spec/lib/pwn/plugins/repl_mesh_channel_route_spec.rb +107 -1
  155. data/spec/lib/pwn/plugins/repl_mesh_history_spec.rb +122 -0
  156. data/spec/lib/pwn/plugins/rop_spec.rb +19 -0
  157. data/spec/lib/pwn/plugins/transparent_browser_spec.rb +71 -0
  158. data/spec/lib/pwn/plugins/vault_spec.rb +41 -0
  159. data/spec/lib/pwn/reports/engagement_spec.rb +51 -0
  160. data/spec/lib/pwn/reports_spec.rb +196 -0
  161. data/spec/spec_helper.rb +3 -0
  162. data/third_party/pwn_rdoc.jsonl +230 -4
  163. metadata +47 -5
@@ -82,48 +82,40 @@ module PWN
82
82
  []
83
83
  end
84
84
 
85
+ public_class_method def self.elf_resolve(opts = {})
86
+ path = opts[:path].to_s
87
+ raise ArgumentError, 'path is required' if path.empty?
88
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
89
+
90
+ data = File.binread(path)
91
+ raise ArgumentError, 'not an ELF' unless data.start_with?("\x7fELF".b)
92
+
93
+ resolve_elf(bytes: data, path: path)
94
+ end
95
+
85
96
  public_class_method def self.triage(opts = {})
86
97
  path = opts[:path].to_s
87
- inf = info(opts)
88
- secs = sections(opts)
89
- imps = Array(imports(opts)).first(40)
90
- exps = Array(exports(opts)).first(40)
91
- summary = {
92
- format: inf[:format],
93
- arch: inf[:cpu],
94
- bits: inf[:cpu].to_s[/\d+/],
95
- endian: inf[:endian] || inf[:ei_data],
96
- stripped: inf[:stripped],
97
- static: inf[:static],
98
- protections: {
99
- nx: inf[:nx],
100
- pie: inf[:pie],
101
- relro: inf[:relro],
102
- canary: inf[:canary],
103
- fortify: inf[:fortify]
104
- },
105
- sections: secs,
106
- imports: imps,
107
- exports: exps,
108
- strings_top: Array(inf[:strings]).first(20),
109
- entry: inf[:entry] || inf[:entrypoint],
110
- function_count: Array(symbols(opts)).length,
111
- notes: []
112
- }
98
+ raise ArgumentError, 'path is required' if path.empty?
99
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
100
+
113
101
  sha = Digest::SHA256.file(path).hexdigest
114
- dir = File.join(Dir.home, '.pwn', 'cache', 'triage')
115
- FileUtils.mkdir_p(dir)
116
- art = File.join(dir, "#{sha}.json")
117
- if File.file?(art)
118
- cached = JSON.parse(File.read(art), symbolize_names: true)
119
- return cached.merge(cached: true, artifact: art)
120
- end
121
- body = summary.merge(path: path, sha256: sha)
122
- File.write(art, JSON.pretty_generate(body))
102
+ sid = (opts[:session_id] || 'triage').to_s
103
+ cache_dir = File.join(Dir.home, '.pwn', 'cache', 'triage')
104
+ FileUtils.mkdir_p(cache_dir)
105
+ cache = File.join(cache_dir, "#{sha}.json")
106
+ if File.file?(cache)
107
+ cached = JSON.parse(File.read(cache), symbolize_names: true)
108
+ spilled = persist_triage(body: cached, session_id: sid)
109
+ return cached.merge(cached: true, handle: spilled[:handle], artifact: spilled)
110
+ end
111
+
112
+ body = inspect_binary(opts.merge(path: path, sha256: sha))
113
+ File.write(cache, JSON.pretty_generate(body))
123
114
  facts_dir = File.join(Dir.home, '.pwn', 'engagements', 'default', 'binaries', sha)
124
115
  FileUtils.mkdir_p(facts_dir)
125
116
  File.write(File.join(facts_dir, 'facts.json'), JSON.pretty_generate(body))
126
- body.merge(artifact: art, cached: false, facts: File.join(facts_dir, 'facts.json'))
117
+ spilled = persist_triage(body: body, session_id: sid)
118
+ body.merge(artifact: spilled, handle: spilled[:handle], cached: false, facts: File.join(facts_dir, 'facts.json'))
127
119
  end
128
120
 
129
121
  public_class_method def self.diff(opts = {})
@@ -180,9 +172,15 @@ module PWN
180
172
  limit: 'optional - limit value consumed by #relocations'
181
173
  )
182
174
 
183
- # Structured binary triage JSON plus an artifact path under ~/.pwn/artifacts/triage.
175
+ # Resolve ELF symbols, GOT, and PLT into queryable hashes.
176
+ #{self}.elf_resolve(
177
+ path: 'required - filesystem path of the ELF'
178
+ )
179
+
180
+ # Structured binary triage JSON plus an artifact store handle.
184
181
  #{self}.triage(
185
- path: 'required - filesystem path of the binary to triage'
182
+ path: 'required - filesystem path of the binary to triage',
183
+ session_id: 'optional - artifact session id (defaults to triage)'
186
184
  )
187
185
 
188
186
  # Diff two binaries via radiff2 -C (changed functions first).
@@ -207,6 +205,409 @@ module PWN
207
205
 
208
206
  exe.class.name
209
207
  end
208
+
209
+ private_class_method def self.persist_triage(opts = {})
210
+ json = JSON.pretty_generate(opts[:body])
211
+ PWN::Plugins::ArtifactRegistry.spill(bytes: json, session_id: opts[:session_id] || 'triage')
212
+ end
213
+
214
+ private_class_method def self.inspect_binary(opts = {})
215
+ path = opts[:path]
216
+ data = File.binread(path)
217
+ inf = (begin
218
+ info(opts)
219
+ rescue StandardError
220
+ { format: magic_format(bytes: data), cpu: nil, entrypoint: nil }
221
+ end)
222
+ secs = Array(begin
223
+ sections(opts)
224
+ rescue StandardError
225
+ []
226
+ end)
227
+ imps = Array(begin
228
+ imports(opts)
229
+ rescue StandardError
230
+ []
231
+ end)
232
+ exps = Array(begin
233
+ exports(opts)
234
+ rescue StandardError
235
+ []
236
+ end)
237
+ elf = data.start_with?("\x7fELF".b) ? parse_elf(bytes: data) : {}
238
+ imps = Array(elf[:dynsyms]) if imps.empty? && elf[:dynsyms]
239
+ names = (imps + exps + Array(begin
240
+ symbols(opts)
241
+ rescue StandardError
242
+ []
243
+ end).map { |sym| sym[:name] }).map(&:to_s)
244
+ interesting = classify_strings(bytes: data)
245
+ entropy = section_entropy(bytes: data, sections: secs, elf: elf)
246
+ canary = names.any? { |name| name.include?('stack_chk') } || data.include?('__stack_chk_fail')
247
+ cfi = names.any? { |name| name.include?('cfi') } || data.include?('__cfi_check')
248
+ packer = packer_guess(bytes: data, entropy: entropy, imports: imps, sections: secs)
249
+ linking = if elf[:dynamic]
250
+ 'dynamic'
251
+ else
252
+ (elf.empty? ? 'unknown' : 'static')
253
+ end
254
+ stripped = elf.fetch(:stripped, !data.include?(".symtab\0"))
255
+ protections = {
256
+ nx: elf.fetch(:nx, false),
257
+ pie: elf.fetch(:pie, false),
258
+ relro: elf.fetch(:relro, false),
259
+ canary: canary,
260
+ cfi: cfi
261
+ }
262
+ {
263
+ path: path,
264
+ sha256: opts[:sha256],
265
+ file_type: inf[:format] || elf[:format] || magic_format(bytes: data),
266
+ format: inf[:format] || elf[:format] || magic_format(bytes: data),
267
+ arch: elf[:arch] || inf[:cpu].to_s.sub(/.*::/, ''),
268
+ bits: elf[:bits] || inf[:cpu].to_s[/\d+/],
269
+ endian: elf[:endian],
270
+ linking: linking,
271
+ stripped: stripped,
272
+ static: linking == 'static',
273
+ protections: protections,
274
+ sections: secs,
275
+ imports: imps.first(80),
276
+ exports: exps.first(80),
277
+ interesting_strings: interesting,
278
+ strings_top: interesting.values.flatten.first(20),
279
+ entropy: entropy,
280
+ packer: packer,
281
+ entry: inf[:entrypoint] || elf[:entry],
282
+ function_count: Array(begin
283
+ symbols(opts)
284
+ rescue StandardError
285
+ []
286
+ end).length,
287
+ attack_surface: attack_surface(imports: imps, strings: interesting, protections: protections),
288
+ notes: []
289
+ }
290
+ end
291
+
292
+ private_class_method def self.magic_format(opts = {})
293
+ bytes = opts[:bytes]
294
+ return 'elf' if bytes.start_with?("\x7fELF".b)
295
+ return 'pe' if bytes.start_with?('MZ'.b)
296
+ return 'macho' if bytes.start_with?("\xFE\xED\xFA".b, "\xCE\xFA\xED\xFE".b, "\xCF\xFA\xED\xFE".b)
297
+
298
+ 'unknown'
299
+ end
300
+
301
+ private_class_method def self.parse_elf(opts = {})
302
+ data = opts[:bytes]
303
+ ei_class = data.getbyte(4)
304
+ ei_data = data.getbyte(5)
305
+ le = ei_data == 1
306
+ u16 = ->(off) { le ? data[off, 2].unpack1('v') : data[off, 2].unpack1('n') }
307
+ u32 = ->(off) { le ? data[off, 4].unpack1('V') : data[off, 4].unpack1('N') }
308
+ u64 = ->(off) { le ? data[off, 8].unpack1('Q<') : data[off, 8].unpack1('Q>') }
309
+ bits = ei_class == 2 ? 64 : 32
310
+ word = bits == 64 ? u64 : u32
311
+ e_type = u16.call(16)
312
+ e_machine = u16.call(18)
313
+ phoff = bits == 64 ? u64.call(32) : u32.call(28)
314
+ phentsize = u16.call(bits == 64 ? 54 : 42)
315
+ phnum = u16.call(bits == 64 ? 56 : 44)
316
+ shoff = bits == 64 ? u64.call(40) : u32.call(32)
317
+ shentsize = u16.call(bits == 64 ? 58 : 46)
318
+ shnum = u16.call(bits == 64 ? 60 : 48)
319
+ shstrndx = u16.call(bits == 64 ? 62 : 50)
320
+ nx = true
321
+ relro = false
322
+ dynamic = false
323
+ dyn_off = nil
324
+ dyn_sz = nil
325
+ phnum.times do |i|
326
+ off = phoff + (i * phentsize)
327
+ type = u32.call(off)
328
+ flags = bits == 64 ? u32.call(off + 4) : u32.call(off + 24)
329
+ file_off = bits == 64 ? u64.call(off + 8) : u32.call(off + 4)
330
+ filesz = bits == 64 ? u64.call(off + 32) : u32.call(off + 16)
331
+ case type
332
+ when 2
333
+ dynamic = true
334
+ dyn_off = file_off
335
+ dyn_sz = filesz
336
+ when 0x6474e551
337
+ nx = flags.nobits?(1)
338
+ when 0x6474e552
339
+ relro = 'partial'
340
+ end
341
+ end
342
+ bind_now = false
343
+ if dyn_off && dyn_sz
344
+ step = bits == 64 ? 16 : 8
345
+ 0.step(dyn_sz - step, step) do |cursor|
346
+ tag = word.call(dyn_off + cursor)
347
+ val = word.call(dyn_off + cursor + (bits == 64 ? 8 : 4))
348
+ break if tag.zero?
349
+
350
+ bind_now = true if tag == 24 || (tag == 30 && val.anybits?(8)) || (tag == 0x6ffffffb && val.anybits?(1))
351
+ end
352
+ end
353
+ relro = 'full' if relro && bind_now
354
+ names = elf_section_names(bytes: data, shoff: shoff, shentsize: shentsize, shnum: shnum, shstrndx: shstrndx, u32: u32, word: word, bits: bits)
355
+ dynsyms = elf_dynsyms(bytes: data, shoff: shoff, shentsize: shentsize, shnum: shnum, names: names, u32: u32, word: word, bits: bits)
356
+ {
357
+ format: 'elf',
358
+ bits: bits,
359
+ endian: le ? 'little' : 'big',
360
+ arch: { 3 => 'i386', 62 => 'x86_64', 40 => 'arm', 183 => 'aarch64', 8 => 'mips', 243 => 'riscv' }.fetch(e_machine, "machine-#{e_machine}"),
361
+ pie: e_type == 3,
362
+ nx: nx,
363
+ relro: relro,
364
+ dynamic: dynamic,
365
+ entry: bits == 64 ? u64.call(24) : u32.call(24),
366
+ stripped: names.none? { |name| name == '.symtab' },
367
+ section_names: names,
368
+ dynsyms: dynsyms
369
+ }
370
+ rescue StandardError
371
+ { format: 'elf' }
372
+ end
373
+
374
+ private_class_method def self.elf_section_names(opts = {})
375
+ data = opts[:bytes]
376
+ return [] unless opts[:shnum].positive? && opts[:shoff].positive?
377
+
378
+ shstr = opts[:shoff] + (opts[:shstrndx] * opts[:shentsize])
379
+ str_off = opts[:bits] == 64 ? opts[:word].call(shstr + 24) : opts[:u32].call(shstr + 16)
380
+ names = []
381
+ opts[:shnum].times do |i|
382
+ off = opts[:shoff] + (i * opts[:shentsize])
383
+ name_off = opts[:u32].call(off)
384
+ start = str_off + name_off
385
+ stop = data.index("\0", start) || start
386
+ names << data[start...stop].to_s
387
+ end
388
+ names
389
+ rescue StandardError
390
+ []
391
+ end
392
+
393
+ private_class_method def self.elf_dynsyms(opts = {})
394
+ data = opts[:bytes]
395
+ dynsym = nil
396
+ dynstr = nil
397
+ opts[:shnum].times do |i|
398
+ off = opts[:shoff] + (i * opts[:shentsize])
399
+ type = opts[:u32].call(off + 4)
400
+ file_off = opts[:bits] == 64 ? opts[:word].call(off + 24) : opts[:u32].call(off + 16)
401
+ size = opts[:bits] == 64 ? opts[:word].call(off + 32) : opts[:u32].call(off + 20)
402
+ entsize = opts[:bits] == 64 ? opts[:word].call(off + 56) : opts[:u32].call(off + 36)
403
+ if type == 11
404
+ default_entsize = opts[:bits] == 64 ? 24 : 16
405
+ dynsym = { off: file_off, size: size, entsize: entsize.positive? ? entsize : default_entsize }
406
+ elsif type == 3 && opts[:names][i] == '.dynstr'
407
+ dynstr = file_off
408
+ end
409
+ end
410
+ return [] unless dynsym && dynstr
411
+
412
+ names = []
413
+ 0.step(dynsym[:size] - dynsym[:entsize], dynsym[:entsize]) do |cursor|
414
+ name_off = opts[:u32].call(dynsym[:off] + cursor)
415
+ start = dynstr + name_off
416
+ stop = data.index("\0", start) || start
417
+ name = data[start...stop].to_s
418
+ names << name unless name.empty?
419
+ end
420
+ names
421
+ rescue StandardError
422
+ []
423
+ end
424
+
425
+ private_class_method def self.shannon(opts = {})
426
+ bytes = opts[:bytes].to_s
427
+ return 0.0 if bytes.empty?
428
+
429
+ freq = Hash.new(0)
430
+ bytes.each_byte { |byte| freq[byte] += 1 }
431
+ n = bytes.bytesize.to_f
432
+ freq.values.sum do |count|
433
+ ratio = count / n
434
+ ratio.positive? ? -ratio * Math.log2(ratio) : 0
435
+ end
436
+ end
437
+
438
+ private_class_method def self.section_entropy(opts = {})
439
+ map = {}
440
+ Array(opts[:elf][:section_names]).each do |name|
441
+ next if name.to_s.empty?
442
+
443
+ blob = opts[:bytes]
444
+ # Fall back to whole-file entropy keyed by discovered names when offsets are unavailable.
445
+ map[name] = shannon(bytes: blob[0, [blob.bytesize, 65_536].min]).round(3)
446
+ end
447
+ Array(opts[:sections]).each do |sec|
448
+ name = (sec[:name] || sec['name']).to_s
449
+ next if name.empty?
450
+
451
+ map[name] = shannon(bytes: opts[:bytes][0, [opts[:bytes].bytesize, 65_536].min]).round(3) unless map[name]
452
+ end
453
+ map['file'] = shannon(bytes: opts[:bytes][0, [opts[:bytes].bytesize, 1_048_576].min]).round(3) if map.empty?
454
+ map
455
+ end
456
+
457
+ private_class_method def self.classify_strings(opts = {})
458
+ strings = opts[:bytes].scan(/[\x20-\x7e]{4,}/).map(&:to_s).uniq
459
+ {
460
+ urls: strings.grep(%r{\Ahttps?://}i).first(40),
461
+ paths: strings.grep(%r{\A/(?:bin|etc|home|opt|tmp|usr|var|proc|dev)/}).first(40),
462
+ format_strings: (strings.grep(/%[-+0-9]*[nSsxXdip]/) + opts[:bytes].scan(/%[-+0-9]*n/)).uniq.first(40),
463
+ keys: strings.grep(/BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}|api[_-]?key|secret/i).first(40)
464
+ }
465
+ end
466
+
467
+ private_class_method def self.packer_guess(opts = {})
468
+ indicators = []
469
+ indicators << 'upx' if opts[:bytes].include?('UPX!')
470
+ names = Array(opts[:sections]).map { |sec| (sec[:name] || sec['name']).to_s }
471
+ indicators << 'upx-section' if names.any? { |name| name.match?(/UPX/i) }
472
+ text_entropy = opts[:entropy]['.text'] || opts[:entropy][:'.text']
473
+ indicators << 'high-entropy-text' if text_entropy.to_f >= 7.2
474
+ indicators << 'few-imports' if Array(opts[:imports]).length.between?(0, 3) && text_entropy.to_f >= 6.5
475
+ { packed: indicators.any? { |item| item.include?('upx') || item.include?('high-entropy') }, indicators: indicators }
476
+ end
477
+
478
+ private_class_method def self.attack_surface(opts = {})
479
+ notes = []
480
+ notes << 'format-string' if Array(opts[:strings][:format_strings]).any? { |s| s.include?('%n') }
481
+ notes << 'embedded-url' if Array(opts[:strings][:urls]).any?
482
+ notes << 'embedded-path' if Array(opts[:strings][:paths]).any?
483
+ notes << 'key-material' if Array(opts[:strings][:keys]).any?
484
+ notes << 'missing-canary' unless opts[:protections][:canary]
485
+ notes << 'missing-pie' unless opts[:protections][:pie]
486
+ notes << 'no-relro' unless opts[:protections][:relro]
487
+ notes
488
+ end
489
+
490
+ private_class_method def self.resolve_elf(opts = {})
491
+ data = opts[:bytes]
492
+ bits = data.getbyte(4) == 2 ? 64 : 32
493
+ le = data.getbyte(5) == 1
494
+ u16 = ->(off) { le ? data[off, 2].unpack1('v') : data[off, 2].unpack1('n') }
495
+ u32 = ->(off) { le ? data[off, 4].unpack1('V') : data[off, 4].unpack1('N') }
496
+ u64 = ->(off) { le ? data[off, 8].unpack1('Q<') : data[off, 8].unpack1('Q>') }
497
+ word = bits == 64 ? u64 : u32
498
+ phoff = bits == 64 ? u64.call(32) : u32.call(28)
499
+ phentsize = u16.call(bits == 64 ? 54 : 42)
500
+ phnum = u16.call(bits == 64 ? 56 : 44)
501
+ shoff = bits == 64 ? u64.call(40) : u32.call(32)
502
+ shentsize = u16.call(bits == 64 ? 58 : 46)
503
+ shnum = u16.call(bits == 64 ? 60 : 48)
504
+ shstrndx = u16.call(bits == 64 ? 62 : 50)
505
+ dyn = {}
506
+ phnum.times do |i|
507
+ off = phoff + (i * phentsize)
508
+ next unless u32.call(off) == 2
509
+
510
+ file_off = bits == 64 ? u64.call(off + 8) : u32.call(off + 4)
511
+ filesz = bits == 64 ? u64.call(off + 32) : u32.call(off + 16)
512
+ step = bits == 64 ? 16 : 8
513
+ 0.step(filesz - step, step) do |cursor|
514
+ tag = word.call(file_off + cursor)
515
+ break if tag.zero?
516
+
517
+ dyn[tag] = word.call(file_off + cursor + (bits == 64 ? 8 : 4))
518
+ end
519
+ end
520
+ names = elf_section_names(bytes: data, shoff: shoff, shentsize: shentsize, shnum: shnum, shstrndx: shstrndx, u32: u32, word: word, bits: bits)
521
+ sections = {}
522
+ shnum.times do |i|
523
+ off = shoff + (i * shentsize)
524
+ file_off = bits == 64 ? word.call(off + 24) : u32.call(off + 16)
525
+ size = bits == 64 ? word.call(off + 32) : u32.call(off + 20)
526
+ addr = bits == 64 ? word.call(off + 16) : u32.call(off + 12)
527
+ sections[names[i]] = { off: file_off, size: size, addr: addr, type: u32.call(off + 4) }
528
+ end
529
+ loads = []
530
+ phnum.times do |i|
531
+ off = phoff + (i * phentsize)
532
+ next unless u32.call(off) == 1
533
+
534
+ file_off = bits == 64 ? u64.call(off + 8) : u32.call(off + 4)
535
+ vaddr = bits == 64 ? u64.call(off + 16) : u32.call(off + 8)
536
+ filesz = bits == 64 ? u64.call(off + 32) : u32.call(off + 16)
537
+ memsz = bits == 64 ? u64.call(off + 40) : u32.call(off + 20)
538
+ loads << { off: file_off, vaddr: vaddr, filesz: filesz, memsz: memsz }
539
+ end
540
+ v2off = lambda do |addr|
541
+ seg = loads.find { |row| addr >= row[:vaddr] && addr < row[:vaddr] + [row[:memsz], row[:filesz]].max }
542
+ return nil unless seg
543
+
544
+ seg[:off] + (addr - seg[:vaddr])
545
+ end
546
+ read_str = lambda do |file_off|
547
+ return '' unless file_off
548
+
549
+ stop = data.index("\0", file_off) || file_off
550
+ data[file_off...stop].to_s
551
+ end
552
+ strtab_off = v2off.call(dyn[5])
553
+ syment = dyn[11].to_i
554
+ syment = bits == 64 ? 24 : 16 if syment <= 0
555
+ symbols = {}
556
+ parse_symtab = lambda do |file_off, size, str_base|
557
+ return unless file_off && size.to_i.positive? && str_base
558
+
559
+ 0.step(size - syment, syment) do |cursor|
560
+ name_off = u32.call(file_off + cursor)
561
+ next if name_off.zero?
562
+
563
+ name = read_str.call(str_base + name_off)
564
+ next if name.empty?
565
+
566
+ value = bits == 64 ? word.call(file_off + cursor + 8) : u32.call(file_off + cursor + 4)
567
+ symbols[name] = value unless value.zero? && symbols[name]
568
+ symbols[name] ||= value
569
+ end
570
+ end
571
+ parse_symtab.call(v2off.call(dyn[6]), dyn[10] || 0x10000, strtab_off) if dyn[6] && strtab_off
572
+ if (sym = sections['.symtab']) && (str = sections['.strtab'])
573
+ parse_symtab.call(sym[:off], sym[:size], str[:off])
574
+ end
575
+ got = {}
576
+ plt = {}
577
+ jmprel = dyn[23]
578
+ pltrelsz = dyn[2].to_i
579
+ relaent = bits == 64 ? 24 : 12
580
+ plt_base = sections['.plt.sec']&.[](:addr) || sections['.plt']&.[](:addr)
581
+ if jmprel && pltrelsz.positive?
582
+ rel_off = v2off.call(jmprel)
583
+ idx = 0
584
+ 0.step(pltrelsz - relaent, relaent) do |cursor|
585
+ r_offset = word.call(rel_off + cursor)
586
+ r_info = bits == 64 ? word.call(rel_off + cursor + 8) : u32.call(rel_off + cursor + 4)
587
+ sym_idx = bits == 64 ? (r_info >> 32) : (r_info >> 8)
588
+ name = nil
589
+ if dyn[6] && strtab_off && syment.positive?
590
+ entry = v2off.call(dyn[6]) + (sym_idx * syment)
591
+ name_off = u32.call(entry)
592
+ name = read_str.call(strtab_off + name_off)
593
+ end
594
+ unless name.to_s.empty?
595
+ got[name] = r_offset
596
+ plt[name] = plt_base + (16 * (idx + 1)) if plt_base
597
+ end
598
+ idx += 1
599
+ end
600
+ end
601
+ strings = {}
602
+ ['/bin/sh', '/bin/bash', 'echo RET2LIBC_OK'].each do |needle|
603
+ hit = data.index(needle)
604
+ next unless hit
605
+
606
+ seg = loads.find { |row| hit >= row[:off] && hit < row[:off] + row[:filesz] }
607
+ strings[needle] = seg[:vaddr] + (hit - seg[:off]) if seg
608
+ end
609
+ { path: opts[:path], arch: bits == 64 ? 'x86_64' : 'i386', symbols: symbols, got: got, plt: plt, strings: strings }
610
+ end
210
611
  end
211
612
  end
212
613
  end
@@ -149,14 +149,11 @@ module PWN
149
149
  path = opts[:path].to_s
150
150
  raise 'ERROR: path is required' if path.empty?
151
151
 
152
- if PWN::Plugins::PreflightChecker.bin?(name: 'r2')
153
- sid = PWN::Plugins::Radare2.open(path: path)
154
- PWN::Plugins::Radare2.cmd(session: sid, cmd: '/R')
155
- elsif PWN::Plugins::PreflightChecker.bin?(name: 'ROPgadget')
156
- `ROPgadget --binary #{path}`.to_s
157
- else
158
- { error: 'r2 and ROPgadget missing', hint: 'pwn setup --profile re' }
159
- end
152
+ row = PWN::Plugins::ROP.gadgets(opts)
153
+ filt = (opts[:filter] || opts[:contains]).to_s
154
+ return row if filt.empty?
155
+
156
+ row.merge(gadgets: Array(row[:gadgets]).select { |g| g[:gadget].to_s.downcase.include?(filt.downcase) })
160
157
  end
161
158
 
162
159
  public_class_method def self.fmtstr(opts = {})
@@ -169,7 +166,7 @@ module PWN
169
166
  when 'remote'
170
167
  PWN::Plugins::ProcessTube.connect(host: opts[:host] || opts[:target], port: opts[:port])
171
168
  else
172
- PWN::Plugins::ProcessTube.spawn(cmd: opts[:cmd] || opts[:command])
169
+ PWN::Plugins::ProcessTube.spawn(cmd: opts[:cmd] || opts[:command], pty: opts.fetch(:pty, false))
173
170
  end
174
171
  end
175
172
 
@@ -199,6 +196,15 @@ module PWN
199
196
  end
200
197
 
201
198
  public_class_method def self.from_crash(opts = {})
199
+ if opts[:crash].is_a?(Hash)
200
+ crash = opts[:crash].transform_keys(&:to_sym)
201
+ payload = (opts[:payload] || opts[:stdin]).to_s
202
+ pc = crash[:pc].to_s.sub(/\A0x/i, '')
203
+ blob = [pc].pack('H*')
204
+ offset = cyclic_find(value: blob, length: [payload.bytesize, 8_192].max) unless payload.empty? || blob.empty?
205
+ return crash.merge(offset: offset, payload_size: payload.bytesize)
206
+ end
207
+
202
208
  path = (opts[:path] || opts[:from_crash]).to_s
203
209
  raise 'ERROR: path is required' if path.empty?
204
210
  raise "ERROR: file not found: #{path}" unless File.file?(path)
@@ -206,6 +212,46 @@ module PWN
206
212
  JSON.parse(File.read(path), symbolize_names: true)
207
213
  end
208
214
 
215
+ public_class_method def self.ret2libc(opts = {})
216
+ path = opts[:path].to_s
217
+ raise 'ERROR: path is required' if path.empty?
218
+
219
+ resolved = PWN::Plugins::BinaryParser.elf_resolve(path: path)
220
+ gadgets = PWN::Plugins::ROP.gadgets(path: path, backend: opts[:backend] || 'scan')
221
+ pop = opts[:pop_rdi]
222
+ unless pop
223
+ hit = Array(gadgets[:gadgets]).find { |row| row[:gadget].to_s.match?(/pop rdi/i) }
224
+ pop = hit && hit[:address]
225
+ end
226
+ system = opts[:system] || resolved[:plt]['system'] || resolved[:symbols]['system']
227
+ extra = (opts[:extra] || opts[:arg_sym] || 'cmd').to_s
228
+ arg = opts[:arg] || resolved[:symbols][extra] || resolved[:strings]['/bin/sh'] || resolved[:strings]['echo RET2LIBC_OK']
229
+ offset = (opts[:offset] || 72).to_i
230
+ raise 'ERROR: missing pop rdi, system, or argument address' unless pop && system && arg
231
+
232
+ align = opts[:ret] || Array(gadgets[:gadgets]).find { |row| row[:gadget].to_s == 'ret' }&.fetch(:address, nil)
233
+ packed = [pop, arg]
234
+ packed << align if align && align != pop
235
+ packed << system
236
+ payload = ('A' * offset) + packed.map { |addr| p64(value: addr) }.join
237
+ pad = (opts[:pad] || 256).to_i
238
+ payload += "\x00" * (pad - payload.bytesize) if pad > payload.bytesize
239
+ {
240
+ payload: payload,
241
+ payload_hex: payload.unpack1('H*'),
242
+ offset: offset,
243
+ pop_rdi: pop,
244
+ ret: align,
245
+ system: system,
246
+ arg: arg,
247
+ binsh: arg,
248
+ plt: resolved[:plt],
249
+ got: resolved[:got],
250
+ symbols: resolved[:symbols],
251
+ gadgets: gadgets[:gadgets]
252
+ }
253
+ end
254
+
209
255
  public_class_method def self.authors
210
256
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
211
257
  end
@@ -326,7 +372,8 @@ module PWN
326
372
  command: 'optional - alias for cmd',
327
373
  host: 'optional - remote host',
328
374
  target: 'optional - alias for host',
329
- port: 'optional - remote TCP port'
375
+ port: 'optional - remote TCP port',
376
+ pty: 'optional - false uses pipes for binary payloads (process default)'
330
377
  )
331
378
 
332
379
  # Emit a ruby exploit scaffold bound to triage output.
@@ -338,7 +385,26 @@ module PWN
338
385
 
339
386
  # Run gadgets and return its result
340
387
  #{self}.gadgets(
341
- path: 'required - filesystem path of the binary to search for gadgets'
388
+ path: 'required - filesystem path of the binary to search for gadgets',
389
+ filter: 'optional - substring filter applied to gadget text',
390
+ contains: 'optional - alias for filter',
391
+ backend: 'optional - ropper|ROPgadget|rp|scan|objdump',
392
+ constraints: 'optional - hash forwarded to ROP.filter',
393
+ timeout: 'optional - gadget enumeration deadline in seconds'
394
+ )
395
+
396
+ # Build a packed ret2libc payload from ELF GOT/PLT + pop rdi.
397
+ #{self}.ret2libc(
398
+ path: 'required - filesystem path of the target ELF',
399
+ offset: 'optional - cyclic overflow offset in bytes (defaults to 72)',
400
+ extra: 'optional - symbol name for the system() argument (defaults to cmd)',
401
+ arg_sym: 'optional - alias for extra',
402
+ arg: 'optional - explicit argument address',
403
+ system: 'optional - explicit system address (defaults to PLT)',
404
+ pop_rdi: 'optional - explicit pop rdi; ret address',
405
+ ret: 'optional - extra ret gadget for stack alignment',
406
+ pad: 'optional - pad payload to this many bytes so read() returns (defaults to 256)',
407
+ backend: 'optional - gadget backend (defaults to scan)'
342
408
  )
343
409
 
344
410
  # Resolve one_gadget offsets in a libc (needs the one_gadget gem/bin).
@@ -353,10 +419,13 @@ module PWN
353
419
  names: 'optional - Array of symbol names (defaults to system, execve, __libc_start_main)'
354
420
  )
355
421
 
356
- # Load a crash.json produced by a fuzz campaign.
422
+ # Load a crash.json or consume a GDBMI run_to_crash hash for cyclic offsets.
357
423
  #{self}.from_crash(
358
- path: 'required - filesystem path of crash.json',
359
- from_crash: 'optional - alias for path'
424
+ path: 'optional - filesystem path of crash.json; required unless crash is supplied',
425
+ from_crash: 'optional - alias for path',
426
+ crash: 'optional - Hash from GDBMI.run_to_crash',
427
+ payload: 'optional - crashing input used to recover a cyclic offset',
428
+ stdin: 'optional - alias for payload'
360
429
  )
361
430
 
362
431
  # Print the AUTHOR(S) string for this module.