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
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ PubFFI = ::FFI unless defined?(PubFFI) # rubocop:disable Style/RedundantConstantBase
6
+
7
+ module PWN
8
+ module FFI
9
+ # Thin libcapstone disassembler.
10
+ module Capstone
11
+ extend PubFFI::Library
12
+
13
+ CS_ARCH_ARM = 0
14
+ CS_ARCH_ARM64 = 1
15
+ CS_ARCH_X86 = 3
16
+ CS_MODE_LITTLE_ENDIAN = 0
17
+ CS_MODE_32 = 4
18
+ CS_MODE_64 = 8
19
+
20
+ # Capstone cs_insn layout used by cs_disasm.
21
+ class Insn < PubFFI::Struct
22
+ layout :id, :uint,
23
+ :address, :uint64,
24
+ :size, :ushort,
25
+ :bytes, [:uchar, 24],
26
+ :mnemonic, [:char, 32],
27
+ :op_str, [:char, 160],
28
+ :detail, :pointer
29
+ end
30
+
31
+ @load_error = nil
32
+ begin
33
+ ffi_lib %w[capstone libcapstone.so.5 libcapstone.so.4 libcapstone.so]
34
+ rescue LoadError => e
35
+ @load_error = e
36
+ end
37
+
38
+ class << self
39
+ attr_reader :load_error
40
+ end
41
+
42
+ unless @load_error
43
+ attach_function :cs_open, %i[int int pointer], :int
44
+ attach_function :cs_disasm, %i[size_t pointer size_t uint64 size_t pointer], :size_t
45
+ attach_function :cs_free, %i[pointer size_t], :void
46
+ attach_function :cs_close, [:pointer], :int
47
+ end
48
+
49
+ public_class_method def self.available?(opts = {})
50
+ opts[:mod]
51
+ load_error.nil?
52
+ end
53
+
54
+ public_class_method def self.disassemble(opts = {})
55
+ raise 'ERROR: libcapstone not available' unless available?(mod: self)
56
+
57
+ raw = opts[:bytes] || opts[:opcodes]
58
+ raise 'ERROR: bytes is required' if raw.to_s.empty?
59
+
60
+ bytes = raw.to_s.b
61
+ arch, mode = arch_mode(arch: opts[:arch], endian: opts[:endian])
62
+ handle = PubFFI::MemoryPointer.new(:size_t)
63
+ raise 'ERROR: cs_open failed' unless cs_open(arch, mode, handle).zero?
64
+
65
+ buf = PubFFI::MemoryPointer.from_string(bytes)
66
+ insn_ptr = PubFFI::MemoryPointer.new(:pointer)
67
+ count = cs_disasm(handle.read_ulong, buf, bytes.bytesize, (opts[:address] || 0).to_i, 0, insn_ptr)
68
+ insns = []
69
+ base = insn_ptr.read_pointer
70
+ count.times do |i|
71
+ insn = Insn.new(base + (i * Insn.size))
72
+ insns << { address: insn[:address], mnemonic: insn[:mnemonic].to_s, op_str: insn[:op_str].to_s, size: insn[:size] }
73
+ end
74
+ cs_free(base, count) unless base.null?
75
+ cs_close(handle)
76
+ { engine: 'capstone', insns: insns, count: count }
77
+ end
78
+
79
+ private_class_method def self.arch_mode(opts = {})
80
+ arch = opts[:arch].to_s.downcase
81
+ case arch
82
+ when 'i386', 'i686', 'x86'
83
+ [CS_ARCH_X86, CS_MODE_32]
84
+ when 'aarch64', 'arm64'
85
+ [CS_ARCH_ARM64, CS_MODE_LITTLE_ENDIAN]
86
+ when /arm/
87
+ [CS_ARCH_ARM, CS_MODE_LITTLE_ENDIAN]
88
+ else
89
+ [CS_ARCH_X86, CS_MODE_64]
90
+ end
91
+ end
92
+
93
+ public_class_method def self.authors
94
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
95
+ end
96
+
97
+ public_class_method def self.help
98
+ puts "USAGE:
99
+ # True when libcapstone is loadable.
100
+ #{self}.available?(
101
+ mod: 'optional - ignored; present so (opts = {}) reads opts['
102
+ )
103
+
104
+ # Disassemble bytes with Capstone.
105
+ #{self}.disassemble(
106
+ bytes: 'required - raw machine-code bytes',
107
+ opcodes: 'optional - alias for bytes',
108
+ arch: 'optional - x86_64|x86|arm|aarch64',
109
+ endian: 'optional - :little or :big byte order',
110
+ address: 'optional - start address'
111
+ )
112
+
113
+ # Print the AUTHOR(S) string for this module.
114
+ #{self}.authors
115
+ "
116
+ constants.sort
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ PubFFI = ::FFI unless defined?(PubFFI) # rubocop:disable Style/RedundantConstantBase
6
+
7
+ module PWN
8
+ module FFI
9
+ # Thin libkeystone assembler.
10
+ module Keystone
11
+ extend PubFFI::Library
12
+
13
+ KS_ARCH_ARM = 1
14
+ KS_ARCH_ARM64 = 2
15
+ KS_ARCH_X86 = 4
16
+ KS_MODE_LITTLE_ENDIAN = 0
17
+ KS_MODE_32 = 4
18
+ KS_MODE_64 = 8
19
+
20
+ @load_error = nil
21
+ begin
22
+ ffi_lib %w[keystone libkeystone.so.0 libkeystone.so]
23
+ rescue LoadError => e
24
+ @load_error = e
25
+ end
26
+
27
+ class << self
28
+ attr_reader :load_error
29
+ end
30
+
31
+ unless @load_error
32
+ attach_function :ks_open, %i[int int pointer], :int
33
+ attach_function :ks_asm, %i[pointer string uint64 pointer pointer pointer], :int
34
+ attach_function :ks_free, [:pointer], :void
35
+ attach_function :ks_close, [:pointer], :int
36
+ end
37
+
38
+ public_class_method def self.available?(opts = {})
39
+ opts[:mod]
40
+ load_error.nil?
41
+ end
42
+
43
+ public_class_method def self.assemble(opts = {})
44
+ raise 'ERROR: libkeystone not available' unless available?(mod: self)
45
+
46
+ asm = opts[:asm].to_s
47
+ raise 'ERROR: asm is required' if asm.empty?
48
+
49
+ arch, mode = arch_mode(arch: opts[:arch], endian: opts[:endian])
50
+ ks_ptr = PubFFI::MemoryPointer.new(:pointer)
51
+ raise 'ERROR: ks_open failed' unless ks_open(arch, mode, ks_ptr).zero?
52
+
53
+ ks = ks_ptr.read_pointer
54
+ enc = PubFFI::MemoryPointer.new(:pointer)
55
+ size = PubFFI::MemoryPointer.new(:ulong)
56
+ count = PubFFI::MemoryPointer.new(:ulong)
57
+ rc = ks_asm(ks, asm, (opts[:address] || 0).to_i, enc, size, count)
58
+ raise 'ERROR: ks_asm failed' unless rc.zero?
59
+
60
+ n = size.read_ulong
61
+ bytes = enc.read_pointer.read_string(n)
62
+ ks_free(enc.read_pointer)
63
+ ks_close(ks)
64
+ { engine: 'keystone', bytes: bytes, hex: bytes.unpack1('H*'), count: count.read_ulong }
65
+ end
66
+
67
+ private_class_method def self.arch_mode(opts = {})
68
+ arch = opts[:arch].to_s.downcase
69
+ case arch
70
+ when 'i386', 'i686', 'x86'
71
+ [KS_ARCH_X86, KS_MODE_32]
72
+ when 'aarch64', 'arm64'
73
+ [KS_ARCH_ARM64, KS_MODE_LITTLE_ENDIAN]
74
+ when /arm/
75
+ [KS_ARCH_ARM, KS_MODE_LITTLE_ENDIAN]
76
+ else
77
+ [KS_ARCH_X86, KS_MODE_64]
78
+ end
79
+ end
80
+
81
+ public_class_method def self.authors
82
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
83
+ end
84
+
85
+ public_class_method def self.help
86
+ puts "USAGE:
87
+ # True when libkeystone is loadable.
88
+ #{self}.available?(
89
+ mod: 'optional - ignored; present so (opts = {}) reads opts['
90
+ )
91
+
92
+ # Assemble instructions with Keystone.
93
+ #{self}.assemble(
94
+ asm: 'required - assembly source (one instruction per line)',
95
+ arch: 'optional - x86_64|x86|arm|aarch64',
96
+ endian: 'optional - :little or :big byte order',
97
+ address: 'optional - start address'
98
+ )
99
+
100
+ # Print the AUTHOR(S) string for this module.
101
+ #{self}.authors
102
+ "
103
+ constants.sort
104
+ end
105
+ end
106
+ end
107
+ end
data/lib/pwn/ffi.rb CHANGED
@@ -21,9 +21,11 @@ module PWN
21
21
  # `.available?` returns false so callers can fall back to pure Ruby.
22
22
  module FFI
23
23
  autoload :AdalmPluto, 'pwn/ffi/adalm_pluto'
24
+ autoload :Capstone, 'pwn/ffi/capstone'
24
25
  autoload :DSPNative, 'pwn/ffi/dsp_native'
25
26
  autoload :FFTW, 'pwn/ffi/fftw'
26
27
  autoload :HackRF, 'pwn/ffi/hack_rf'
28
+ autoload :Keystone, 'pwn/ffi/keystone'
27
29
  autoload :Liquid, 'pwn/ffi/liquid'
28
30
  autoload :RTLSdr, 'pwn/ffi/rtl_sdr'
29
31
  autoload :SoapySDR, 'pwn/ffi/soapy_sdr'
data/lib/pwn/migrate.rb CHANGED
@@ -49,7 +49,7 @@ module PWN
49
49
  # Bump this whenever the shape of any file under ~/.pwn changes in a
50
50
  # way that requires a one-time transform. Add the transform as an
51
51
  # entry in MIGRATIONS keyed by the NEW schema number.
52
- SCHEMA_VERSION = 3
52
+ SCHEMA_VERSION = 4
53
53
 
54
54
  OK = "\e[32mok\e[0m"
55
55
  BAD = "\e[31mFAIL\e[0m"
@@ -241,6 +241,10 @@ module PWN
241
241
 
242
242
  File.write(path, YAML.dump(raw))
243
243
  io.puts " · agents.yml added default model to #{patched} personas"
244
+ },
245
+ 4 => lambda { |_root, io|
246
+ io.puts ' · pwn.yaml ai_sandbox, ai_router, model_routes task classes'
247
+ PWN::Migrate.backfill_vault(io: io)
244
248
  }
245
249
  }.freeze
246
250
 
@@ -3,10 +3,14 @@
3
3
  require 'open3'
4
4
  require 'digest'
5
5
  require 'json'
6
+ require 'fileutils'
7
+ require 'shellwords'
8
+ require 'rbconfig'
9
+ require 'base64'
6
10
 
7
11
  module PWN
8
12
  module Plugins
9
- # AFL++ campaign wrapper.
13
+ # AFL++ / libFuzzer campaign wrapper with corpus, dictionary, and RE triage handoff.
10
14
  module AFLplusplus
11
15
  public_class_method def self.required_bins
12
16
  %w[afl-fuzz]
@@ -41,22 +45,107 @@ module PWN
41
45
  end
42
46
  end
43
47
 
48
+ public_class_method def self.dictionary_from_binary(opts = {})
49
+ path = (opts[:path] || opts[:target] || opts[:bin]).to_s
50
+ raise ArgumentError, 'path is required' if path.empty?
51
+
52
+ triage = PWN::Plugins::BinaryParser.triage(path: path, session_id: opts[:session_id] || 'fuzz-dict')
53
+ strings = triage[:interesting_strings] || {}
54
+ tokens = []
55
+ tokens.concat(Array(strings[:urls]))
56
+ tokens.concat(Array(strings[:paths]))
57
+ tokens.concat(Array(strings[:format_strings]))
58
+ tokens.concat(Array(strings[:keys]))
59
+ tokens.concat(Array(triage[:imports]).map(&:to_s).grep(/\A[A-Za-z_]{3,}/).first(20))
60
+ tokens = tokens.map(&:to_s).uniq.reject(&:empty?).first(200)
61
+ quoted = tokens.map { |token| format('"%s"', token.gsub('\\', '\\\\').gsub('"', '\\"')) }
62
+ { path: path, tokens: tokens, dict: quoted.join("\n") }
63
+ end
64
+
65
+ public_class_method def self.prepare_corpus(opts = {})
66
+ in_dir = (opts[:in_dir] || opts[:corpus]).to_s
67
+ raise ArgumentError, 'in_dir is required' if in_dir.empty?
68
+
69
+ FileUtils.mkdir_p(in_dir)
70
+ File.binwrite(File.join(in_dir, 'seed'), "A\n") unless Dir.children(in_dir).any? { |name| File.file?(File.join(in_dir, name)) }
71
+ Array(opts[:seeds]).each_with_index do |seed, idx|
72
+ FileUtils.cp(seed, File.join(in_dir, "seed-#{idx}")) if File.file?(seed.to_s)
73
+ end
74
+ in_dir
75
+ end
76
+
77
+ public_class_method def self.campaign_command(opts = {})
78
+ payload = {
79
+ engine: opts[:engine] || 'aflplusplus',
80
+ in_dir: opts[:in_dir],
81
+ out_dir: opts[:out_dir],
82
+ target: opts[:target],
83
+ max_runtime: opts[:max_runtime],
84
+ cwd: opts[:cwd],
85
+ env: opts[:env],
86
+ handoff: opts.fetch(:handoff, true)
87
+ }
88
+ b64 = Base64.strict_encode64(JSON.generate(payload))
89
+ Shellwords.join(
90
+ [
91
+ RbConfig.ruby,
92
+ '-rpwn',
93
+ '-rjson',
94
+ '-rbase64',
95
+ '-e',
96
+ 'PWN::Plugins::AFLplusplus.campaign(JSON.parse(Base64.decode64(ARGV[0]), symbolize_names: true))',
97
+ b64
98
+ ]
99
+ )
100
+ end
101
+
102
+ public_class_method def self.campaign(opts = {})
103
+ opts = opts.transform_keys(&:to_sym)
104
+ in_dir = prepare_corpus(opts)
105
+ out_dir = (opts[:out_dir] || opts[:output]).to_s
106
+ raise ArgumentError, 'out_dir is required' if out_dir.empty?
107
+
108
+ FileUtils.mkdir_p(out_dir)
109
+ bin = binary_from_target(target: opts[:target])
110
+ dict_info = File.file?(bin.to_s) ? dictionary_from_binary(path: bin) : { dict: '', tokens: [] }
111
+ dict_path = File.join(out_dir, 'tokens.dict')
112
+ File.write(dict_path, dict_info[:dict].to_s)
113
+ unless opts[:skip_fuzz]
114
+ argv = fuzzer_argv(opts.merge(in_dir: in_dir, dict: dict_path, binary: bin, out_dir: out_dir))
115
+ run_fuzzer(argv: argv, max_runtime: opts[:max_runtime], env: opts[:env], cwd: opts[:cwd])
116
+ end
117
+ crash_triage(out_dir: out_dir, target: bin || opts[:target], handoff: opts.fetch(:handoff, true))
118
+ end
119
+
44
120
  public_class_method def self.crash_triage(opts = {})
45
121
  out_dir = (opts[:out_dir] || opts[:output]).to_s
46
122
  raise 'ERROR: out_dir is required' if out_dir.empty?
47
123
 
48
- crashes = Dir[File.join(out_dir, '**/crashes/id:*')]
49
- hashes = crashes.map { |p| [p, Digest::SHA256.file(p).hexdigest] }
50
- rows = hashes.map do |path, sha|
51
- art = File.join(out_dir, "crash-#{sha[0, 12]}.json")
52
- File.write(art, JSON.generate(path: path, sha256: sha))
53
- { path: path, sha256: sha, artifact: art }
124
+ files = crash_files(out_dir: out_dir)
125
+ target = binary_from_target(target: opts[:target])
126
+ groups = Hash.new { |hash, key| hash[key] = { files: [] } }
127
+ files.each do |path|
128
+ stdin = File.binread(path)
129
+ crash = replay_crash(target: target, stdin: stdin)
130
+ key = backtrace_hash(crash: crash)
131
+ groups[key][:files] << path
132
+ groups[key][:crash] ||= crash
133
+ groups[key][:stdin] ||= stdin
54
134
  end
135
+ triaged = []
136
+ if opts[:handoff] != false
137
+ groups.each do |key, group|
138
+ triaged << handoff_re003(target: target, crash: group[:crash], stdin: group[:stdin], files: group[:files], backtrace_hash: key)
139
+ end
140
+ end
141
+ hashes = files.map { |path| [path, Digest::SHA256.file(path).hexdigest] }
55
142
  {
56
- crashes: crashes,
57
- unique: hashes.map(&:last).uniq,
58
- asan: crashes.any? { |p| File.binread(p).include?('AddressSanitizer') },
59
- artifacts: rows
143
+ crashes: files,
144
+ unique: groups.keys,
145
+ triaged: triaged,
146
+ pipeline: 'pwn-re-003',
147
+ asan: files.any? { |path| File.binread(path).include?('AddressSanitizer') },
148
+ artifacts: hashes.map { |path, sha| { path: path, sha256: sha } }
60
149
  }
61
150
  end
62
151
 
@@ -96,10 +185,54 @@ module PWN
96
185
  output: 'optional - alias for out_dir'
97
186
  )
98
187
 
99
- # Dedup AFL++ crash files by sha256 and note AddressSanitizer hits.
188
+ # Build an AFL/libFuzzer dictionary from interesting strings in a binary.
189
+ #{self}.dictionary_from_binary(
190
+ path: 'required - filesystem path of the fuzz target ELF',
191
+ target: 'optional - alias for path',
192
+ bin: 'optional - alias for path',
193
+ session_id: 'optional - artifact session id for BinaryParser.triage'
194
+ )
195
+
196
+ # Ensure a corpus directory exists and contains at least one seed.
197
+ #{self}.prepare_corpus(
198
+ in_dir: 'required - corpus directory (defaults to opts[:corpus])',
199
+ corpus: 'optional - alias for in_dir',
200
+ seeds: 'optional - extra seed file paths to copy into the corpus'
201
+ )
202
+
203
+ # Build a durable Jobs command that runs campaign then RE triage.
204
+ #{self}.campaign_command(
205
+ engine: 'optional - aflplusplus or libfuzzer (defaults to aflplusplus)',
206
+ in_dir: 'required - corpus directory',
207
+ out_dir: 'required - fuzzer output directory',
208
+ target: 'required - fuzz target command or binary path',
209
+ max_runtime: 'optional - seconds the supervisor may run',
210
+ cwd: 'optional - working directory',
211
+ env: 'optional - string environment map',
212
+ handoff: 'optional - false skips PWN-RE-003 crash handoff (defaults to true)'
213
+ )
214
+
215
+ # Prepare corpus/dict, run AFL++ or libFuzzer, then triage unique crashes.
216
+ #{self}.campaign(
217
+ engine: 'optional - aflplusplus or libfuzzer (defaults to aflplusplus)',
218
+ in_dir: 'required - corpus directory',
219
+ out_dir: 'required - fuzzer output directory',
220
+ output: 'optional - alias for out_dir',
221
+ target: 'required - fuzz target command or binary path',
222
+ max_runtime: 'optional - seconds forwarded to the fuzzer',
223
+ cwd: 'optional - working directory',
224
+ env: 'optional - string environment map',
225
+ skip_fuzz: 'optional - true harvests existing crashes without launching a fuzzer',
226
+ handoff: 'optional - false skips PWN-RE-003 crash handoff (defaults to true)',
227
+ seeds: 'optional - extra seed file paths to copy into the corpus'
228
+ )
229
+
230
+ # Dedup crashes by backtrace hash and hand unique ones to PWN-RE-003 triage.
100
231
  #{self}.crash_triage(
101
232
  out_dir: 'required - AFL output directory containing crashes/',
102
- output: 'optional - alias for out_dir'
233
+ output: 'optional - alias for out_dir',
234
+ target: 'optional - fuzz target binary used to replay crashes',
235
+ handoff: 'optional - false skips GDBMI/BinaryParser/ExploitDev handoff (defaults to true)'
103
236
  )
104
237
 
105
238
  # Minimize a crash input with afl-tmin when present.
@@ -114,6 +247,98 @@ module PWN
114
247
  "
115
248
  constants.sort
116
249
  end
250
+
251
+ private_class_method def self.binary_from_target(opts = {})
252
+ parts = begin
253
+ Shellwords.split(opts[:target].to_s)
254
+ rescue ArgumentError
255
+ [opts[:target].to_s]
256
+ end
257
+ parts.find { |part| part != '@@' && !part.start_with?('-') } || parts.first
258
+ end
259
+
260
+ private_class_method def self.fuzzer_argv(opts = {})
261
+ engine = (opts[:engine] || 'aflplusplus').to_s
262
+ if engine.downcase.include?('libfuzzer')
263
+ argv = [opts[:binary] || binary_from_target(target: opts[:target])]
264
+ argv << "-max_total_time=#{opts[:max_runtime].to_i}" if opts[:max_runtime].to_i.positive?
265
+ argv << "-dict=#{opts[:dict]}" if opts[:dict].to_s != '' && File.file?(opts[:dict].to_s)
266
+ argv << "-artifact_prefix=#{File.join(opts[:out_dir].to_s, 'crash-')}"
267
+ argv << opts[:in_dir].to_s
268
+ else
269
+ argv = ['afl-fuzz', '-i', opts[:in_dir].to_s, '-o', opts[:out_dir].to_s]
270
+ argv += ['-x', opts[:dict].to_s] if opts[:dict].to_s != '' && File.file?(opts[:dict].to_s)
271
+ argv += ['--', *Shellwords.split(opts[:target].to_s)]
272
+ end
273
+ argv
274
+ end
275
+
276
+ private_class_method def self.run_fuzzer(opts = {})
277
+ env = { 'AFL_NO_UI' => '1', 'AFL_SKIP_CPUFREQ' => '1', 'AFL_I_DONT_CARE_ABOUT_MISSING_CRASHES' => '1' }
278
+ env.merge!(opts[:env].transform_keys(&:to_s)) if opts[:env].is_a?(Hash)
279
+ kw = {}
280
+ kw[:chdir] = opts[:cwd] if opts[:cwd].to_s != ''
281
+ system(env, *opts[:argv], **kw)
282
+ end
283
+
284
+ private_class_method def self.crash_files(opts = {})
285
+ out_dir = opts[:out_dir]
286
+ files = Dir[File.join(out_dir, '**/crashes/id:*')]
287
+ files += Dir[File.join(out_dir, 'crash-*')]
288
+ files += Dir[File.join(out_dir, '**/crash-*')]
289
+ files.select { |path| File.file?(path) && File.basename(path) != 'README.txt' }.uniq
290
+ end
291
+
292
+ private_class_method def self.replay_crash(opts = {})
293
+ target = opts[:target].to_s
294
+ stdin = opts[:stdin].to_s
295
+ return dummy_crash(stdin: stdin) if target.empty?
296
+
297
+ PWN::Plugins::GDBMI.run_to_crash(binary: target, stdin: stdin)
298
+ rescue StandardError
299
+ stdout, stderr, status = Open3.capture3(target, stdin_data: stdin)
300
+ sig = status.termsig ? Signal.signame(status.termsig) : nil
301
+ {
302
+ signal: sig ? "SIG#{sig}" : 'UNKNOWN',
303
+ pc: nil,
304
+ fault_addr: nil,
305
+ backtrace: stderr.lines.grep(/#\d+/).map(&:strip),
306
+ stdout: stdout,
307
+ stderr: stderr
308
+ }
309
+ end
310
+
311
+ private_class_method def self.dummy_crash(opts = {})
312
+ { signal: 'SIGSEGV', pc: Digest::SHA256.hexdigest(opts[:stdin].to_s)[0, 8], fault_addr: nil, backtrace: [] }
313
+ end
314
+
315
+ private_class_method def self.backtrace_hash(opts = {})
316
+ crash = opts[:crash] || {}
317
+ Digest::SHA256.hexdigest([crash[:signal], crash[:pc], crash[:fault_addr], Array(crash[:backtrace]).inspect].join('|'))[0, 16]
318
+ end
319
+
320
+ private_class_method def self.handoff_re003(opts = {})
321
+ target = opts[:target].to_s
322
+ crash = opts[:crash] || {}
323
+ binary_triage = begin
324
+ PWN::Plugins::BinaryParser.triage(path: target, session_id: 'pwn-re-003') if File.file?(target)
325
+ rescue StandardError
326
+ nil
327
+ end
328
+ exploitdev = begin
329
+ PWN::Plugins::ExploitDev.from_crash(crash: crash, payload: opts[:stdin]) if crash.is_a?(Hash)
330
+ rescue StandardError
331
+ nil
332
+ end
333
+ {
334
+ backtrace_hash: opts[:backtrace_hash],
335
+ crash: crash,
336
+ binary_triage: binary_triage,
337
+ exploitdev: exploitdev,
338
+ files: opts[:files],
339
+ pipeline: 'pwn-re-003'
340
+ }
341
+ end
117
342
  end
118
343
  end
119
344
  end