pwn 0.5.737 → 0.5.739

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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +0 -1
  3. data/Gemfile +2 -2
  4. data/documentation/Agent-Tool-Registry.md +17 -3
  5. data/documentation/Diagrams.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +74 -0
  8. data/documentation/Swarm.md +2 -3
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-REPL.md +0 -1
  11. data/documentation/pwn-ai-Agent.md +1 -1
  12. data/etc/default_skills/browser-exploitation/SKILL.md +10 -0
  13. data/etc/default_skills/firmware-unpack-emulate/SKILL.md +10 -0
  14. data/etc/default_skills/heap-exploitation/SKILL.md +11 -0
  15. data/etc/default_skills/kernel-pwn-triage/SKILL.md +11 -0
  16. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -1
  17. data/etc/default_skills/pwn/ai/agent/tools/sbom_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/corpus/SKILL.md +52 -0
  19. data/etc/default_skills/pwn/corpus/references/urls.md +3 -0
  20. data/etc/default_skills/pwn/engagement/SKILL.md +55 -0
  21. data/etc/default_skills/pwn/plugins/debugger/SKILL.md +62 -0
  22. data/etc/default_skills/pwn/plugins/fuzz/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/handler/SKILL.md +52 -0
  24. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  25. data/etc/default_skills/pwn/plugins/mitm_proxy/SKILL.md +1 -0
  26. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +1 -0
  27. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  28. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/redb/SKILL.md +52 -0
  30. data/etc/default_skills/pwn/plugins/sbom/SKILL.md +48 -0
  31. data/etc/default_skills/pwn/redaction/SKILL.md +1 -0
  32. data/etc/default_skills/rop-jop-chain-construction/SKILL.md +10 -0
  33. data/etc/default_skills/windows-internals-triage/SKILL.md +10 -0
  34. data/lib/pwn/ai/agent/loop.rb +80 -11
  35. data/lib/pwn/ai/agent/prompt_builder.rb +19 -1
  36. data/lib/pwn/ai/agent/registry.rb +34 -4
  37. data/lib/pwn/ai/agent/swarm.rb +4 -5
  38. data/lib/pwn/ai/agent/tool_guard.rb +11 -4
  39. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +2 -1
  40. data/lib/pwn/ai/agent/tools/job_run.rb +9 -3
  41. data/lib/pwn/ai/agent/tools/pty_session.rb +7 -1
  42. data/lib/pwn/ai/agent/tools/sbom_scan.rb +25 -0
  43. data/lib/pwn/ai/agent/tools/shell.rb +3 -2
  44. data/lib/pwn/ai/agent/tools/swarm.rb +3 -6
  45. data/lib/pwn/ai/agent/turn_finalizer.rb +2 -2
  46. data/lib/pwn/corpus.rb +107 -0
  47. data/lib/pwn/engagement.rb +158 -0
  48. data/lib/pwn/plugins/debugger.rb +284 -0
  49. data/lib/pwn/plugins/findings.rb +14 -3
  50. data/lib/pwn/plugins/fuzz.rb +62 -0
  51. data/lib/pwn/plugins/handler.rb +171 -0
  52. data/lib/pwn/plugins/jobs.rb +55 -0
  53. data/lib/pwn/plugins/mitm_proxy.rb +37 -2
  54. data/lib/pwn/plugins/nmap_it.rb +32 -0
  55. data/lib/pwn/plugins/packet.rb +18 -0
  56. data/lib/pwn/plugins/preflight_checker.rb +6 -2
  57. data/lib/pwn/plugins/process_tube.rb +99 -8
  58. data/lib/pwn/plugins/redb.rb +210 -0
  59. data/lib/pwn/plugins/repl.rb +0 -3
  60. data/lib/pwn/plugins/sbom.rb +145 -0
  61. data/lib/pwn/plugins.rb +4 -0
  62. data/lib/pwn/redaction.rb +53 -0
  63. data/lib/pwn/reports/engagement.rb +2 -1
  64. data/lib/pwn/sessions.rb +3 -1
  65. data/lib/pwn/version.rb +1 -1
  66. data/lib/pwn.rb +2 -0
  67. data/packer/provisioners/irc.sh +1 -1
  68. data/spec/lib/pwn/ai/agent/harness_2026_09_15_spec.rb +144 -0
  69. data/spec/lib/pwn/ai/agent/registry_spec.rb +50 -0
  70. data/spec/lib/pwn/ai/agent/tools/mcp_spec.rb +31 -0
  71. data/spec/lib/pwn/ai/agent/tools/sbom_scan_spec.rb +10 -0
  72. data/spec/lib/pwn/corpus_spec.rb +23 -0
  73. data/spec/lib/pwn/engagement_spec.rb +27 -0
  74. data/spec/lib/pwn/plugins/debugger_spec.rb +27 -0
  75. data/spec/lib/pwn/plugins/handler_spec.rb +25 -0
  76. data/spec/lib/pwn/plugins/redb_spec.rb +33 -0
  77. data/spec/lib/pwn/plugins/repl_spec.rb +7 -1
  78. data/spec/lib/pwn/plugins/sbom_spec.rb +22 -0
  79. data/third_party/pwn_rdoc.jsonl +97 -4
  80. metadata +36 -9
  81. data/etc/default_skills/pwn/plugins/repl/irc/SKILL.md +0 -47
  82. data/lib/pwn/plugins/repl/irc.rb +0 -153
  83. data/spec/lib/pwn/plugins/repl/irc_spec.rb +0 -21
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'ipaddr'
6
+
7
+ module PWN
8
+ # Engagement scope, host state, and vault refs under ~/.pwn/engagements.
9
+ module Engagement
10
+ public_class_method def self.open(opts = {})
11
+ row = PWN::AI::Agent::Engagement.open(opts)
12
+ dir = File.join(PWN::AI::Agent::Engagement::ROOT, row[:name].to_s)
13
+ FileUtils.mkdir_p(File.join(dir, 'evidence'))
14
+ hosts = File.join(dir, 'hosts.json')
15
+ File.write(hosts, JSON.generate({})) unless File.file?(hosts)
16
+ row.merge(dir: dir, hosts: hosts)
17
+ end
18
+
19
+ public_class_method def self.close(opts = {})
20
+ PWN::AI::Agent::Engagement.close(opts)
21
+ end
22
+
23
+ public_class_method def self.status(opts = {})
24
+ PWN::AI::Agent::Engagement.status(opts).merge(hosts: hosts(opts))
25
+ end
26
+
27
+ public_class_method def self.in_scope?(opts = {})
28
+ PWN::AI::Agent::Engagement.in_scope?(opts)
29
+ end
30
+
31
+ public_class_method def self.warn_unless_in_scope(opts = {})
32
+ return { ok: true } if in_scope?(opts) || opts[:override] == true
33
+
34
+ { ok: false, warning: "out of scope: #{opts[:host] || opts[:ip] || opts[:target]}", override_required: true }
35
+ end
36
+
37
+ public_class_method def self.record_host(opts = {})
38
+ check = warn_unless_in_scope(opts)
39
+ return check.merge(recorded: false) unless check[:ok]
40
+
41
+ name = (opts[:engagement] || opts[:name] || PWN::AI::Agent::Engagement.current_name || 'default').to_s
42
+ dir = File.join(PWN::AI::Agent::Engagement::ROOT, name)
43
+ FileUtils.mkdir_p(dir)
44
+ path = File.join(dir, 'hosts.json')
45
+ store = File.file?(path) ? JSON.parse(File.read(path), symbolize_names: true) : {}
46
+ host = (opts[:host] || opts[:ip]).to_s
47
+ row = store[host.to_sym] || { host: host, ports: [], services: [], notes: [], creds: [] }
48
+ incoming = Array(opts[:ports]).map do |port|
49
+ port.is_a?(Hash) ? port.transform_keys(&:to_sym) : { port: port }
50
+ end
51
+ by_port = {}
52
+ (Array(row[:ports]) + incoming).each do |item|
53
+ item = item.is_a?(Hash) ? item.transform_keys(&:to_sym) : { port: item }
54
+ key = item[:port]
55
+ by_port[key] = (by_port[key] || {}).merge(item)
56
+ end
57
+ row[:ports] = by_port.values.map { |item| item.keys == [:port] ? item[:port] : item }
58
+ row[:services] = (Array(row[:services]) + Array(opts[:services])).uniq
59
+ row[:notes] = (Array(row[:notes]) + Array(opts[:notes])).uniq
60
+ row[:creds] = (Array(row[:creds]) + Array(opts[:creds])).uniq
61
+ store[host.to_sym] = row
62
+ File.write(path, JSON.pretty_generate(store))
63
+ row
64
+ end
65
+
66
+ public_class_method def self.hosts(opts = {})
67
+ name = (opts[:name] || PWN::AI::Agent::Engagement.current_name || 'default').to_s
68
+ path = File.join(PWN::AI::Agent::Engagement::ROOT, name, 'hosts.json')
69
+ return {} unless File.file?(path)
70
+
71
+ JSON.parse(File.read(path), symbolize_names: true)
72
+ end
73
+
74
+ public_class_method def self.merge_scan(opts = {})
75
+ Array(opts[:hosts] || opts[:results]).each do |row|
76
+ row = row.transform_keys(&:to_sym) if row.respond_to?(:transform_keys)
77
+ record_host(
78
+ host: row[:host] || row['host'],
79
+ ports: [{ port: row[:port] || row['port'], proto: row[:proto], service: row[:service], version: row[:version], scripts: row[:scripts] }.compact],
80
+ services: [row[:service] || row['service']].compact,
81
+ override: opts[:override],
82
+ engagement: opts[:engagement] || opts[:name]
83
+ )
84
+ end
85
+ hosts(name: opts[:engagement] || opts[:name])
86
+ end
87
+
88
+ public_class_method def self.authors
89
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
90
+ end
91
+
92
+ public_class_method def self.help
93
+ puts "USAGE:
94
+ # Open an engagement and ensure its host/evidence directories exist.
95
+ #{self}.open(
96
+ name: 'optional - engagement identifier (defaults to default)',
97
+ engagement: 'optional - alias for name',
98
+ scope_cidrs: 'optional - Array of CIDR strings',
99
+ scope_domains: 'optional - Array of DNS suffixes',
100
+ excluded: 'optional - Array of excluded hosts',
101
+ roe: 'optional - rules of engagement notes'
102
+ )
103
+
104
+ # Clear the active engagement pointer.
105
+ #{self}.close(
106
+ unused: 'optional - accepted so callers can pass a Hash'
107
+ )
108
+
109
+ # Return scope plus persisted host state.
110
+ #{self}.status(
111
+ name: 'optional - engagement identifier'
112
+ )
113
+
114
+ # True when a host or URL is inside the active scope.
115
+ #{self}.in_scope?(
116
+ host: 'optional - hostname or IP',
117
+ ip: 'optional - alias for host',
118
+ target: 'optional - alias for host',
119
+ url: 'optional - URL whose host is checked'
120
+ )
121
+
122
+ # Warn on out-of-scope targets unless override is true.
123
+ #{self}.warn_unless_in_scope(
124
+ host: 'optional - hostname or IP',
125
+ ip: 'optional - alias for host',
126
+ target: 'optional - alias for host',
127
+ override: 'optional - true continues despite a scope miss'
128
+ )
129
+
130
+ # Deep-merge ports, services, notes, and vault refs for a host.
131
+ #{self}.record_host(
132
+ host: 'required - hostname or IP',
133
+ ip: 'optional - alias for host',
134
+ ports: 'optional - Array of open ports',
135
+ services: 'optional - Array of service labels',
136
+ notes: 'optional - Array of freeform notes',
137
+ creds: 'optional - Array of vault references',
138
+ override: 'optional - true records an out-of-scope host'
139
+ )
140
+
141
+ # Return the host map for an engagement.
142
+ #{self}.hosts(
143
+ name: 'optional - engagement identifier'
144
+ )
145
+
146
+ # Merge structured scan rows into host state.
147
+ #{self}.merge_scan(
148
+ hosts: 'optional - Array of {host, port, service} hashes',
149
+ results: 'optional - alias for hosts',
150
+ override: 'optional - true records out-of-scope hosts'
151
+ )
152
+
153
+ # Print the AUTHOR(S) string for this module.
154
+ #{self}.authors
155
+ "
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,284 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module PWN
6
+ module Plugins
7
+ # Structured GDB/MI driver with a handle registry that survives pwn_eval.
8
+ module Debugger
9
+ @sessions = {}
10
+
11
+ public_class_method def self.required_bins
12
+ %w[gdb]
13
+ end
14
+
15
+ public_class_method def self.launch(opts = {})
16
+ bin = (opts[:bin] || opts[:binary]).to_s
17
+ raise ArgumentError, 'bin is required' if bin.empty?
18
+
19
+ args = Array(opts[:args]).map(&:to_s)
20
+ argv = ['gdb', '--interpreter=mi2', '--quiet', '--nx', '--args', bin, *args]
21
+ tube = PWN::Plugins::ProcessTube.spawn(cmd: argv, name: opts[:handle])
22
+ row = store_session(handle: tube[:id], tube: tube, bin: bin)
23
+ drain_gdb(handle: row[:handle])
24
+ mi(handle: row[:handle], cmd: '-gdb-set confirm off')
25
+ mi(handle: row[:handle], cmd: '-gdb-set pagination off')
26
+ mi(handle: row[:handle], cmd: '-exec-run') if opts[:run] == true
27
+ row
28
+ end
29
+
30
+ public_class_method def self.attach(opts = {})
31
+ if opts[:host] || opts[:port]
32
+ host = (opts[:host] || '127.0.0.1').to_s
33
+ port = (opts[:port] || 1234).to_i
34
+ argv = ['gdb', '--interpreter=mi2', '--quiet', '--nx']
35
+ tube = PWN::Plugins::ProcessTube.spawn(cmd: argv, name: opts[:handle])
36
+ row = store_session(handle: tube[:id], tube: tube, gdbstub: "#{host}:#{port}")
37
+ drain_gdb(handle: row[:handle])
38
+ parse_mi(raw: mi(handle: row[:handle], cmd: "-target-select remote #{host}:#{port}"))
39
+ return row
40
+ end
41
+ pid = opts[:pid].to_i
42
+ raise ArgumentError, 'pid is required' unless pid.positive?
43
+
44
+ argv = ['gdb', '--interpreter=mi2', '--quiet', '--nx', '-p', pid.to_s]
45
+ tube = PWN::Plugins::ProcessTube.spawn(cmd: argv, name: opts[:handle])
46
+ row = store_session(handle: tube[:id], tube: tube, pid: pid)
47
+ drain_gdb(handle: row[:handle])
48
+ row
49
+ end
50
+
51
+ public_class_method def self.run(opts = {})
52
+ parse_mi(raw: mi(opts.merge(cmd: '-exec-run')))
53
+ end
54
+
55
+ public_class_method def self.break(opts = {})
56
+ loc = (opts[:addr_or_sym] || opts[:location] || opts[:addr] || opts[:symbol]).to_s
57
+ raise ArgumentError, 'addr_or_sym is required' if loc.empty?
58
+
59
+ parse_mi(raw: mi(opts.merge(cmd: "-break-insert #{loc}")))
60
+ end
61
+
62
+ public_class_method def self.continue(opts = {})
63
+ parse_mi(raw: mi(opts.merge(cmd: '-exec-continue')))
64
+ end
65
+
66
+ public_class_method def self.step(opts = {})
67
+ parse_mi(raw: mi(opts.merge(cmd: (opts[:into] ? '-exec-step' : '-exec-next'))))
68
+ end
69
+
70
+ public_class_method def self.read_mem(opts = {})
71
+ addr = (opts[:addr] || opts[:address]).to_s
72
+ raise ArgumentError, 'addr is required' if addr.empty?
73
+
74
+ parse_mi(raw: mi(opts.merge(cmd: "-data-read-memory-bytes #{addr} #{(opts[:len] || opts[:length] || 64).to_i}")))
75
+ end
76
+
77
+ public_class_method def self.write_mem(opts = {})
78
+ addr = (opts[:addr] || opts[:address]).to_s
79
+ data = opts[:data] || opts[:bytes]
80
+ raise ArgumentError, 'addr and data are required' if addr.empty? || data.nil?
81
+
82
+ hex = data.is_a?(String) ? data.unpack1('H*') : Array(data).pack('C*').unpack1('H*')
83
+ parse_mi(raw: mi(opts.merge(cmd: "-data-write-memory-bytes #{addr} #{hex}")))
84
+ end
85
+
86
+ public_class_method def self.regs(opts = {})
87
+ parse_mi(raw: mi(opts.merge(cmd: '-data-list-register-values x')))
88
+ end
89
+
90
+ public_class_method def self.backtrace(opts = {})
91
+ parse_mi(raw: mi(opts.merge(cmd: '-stack-list-frames')))
92
+ end
93
+
94
+ public_class_method def self.checksec(opts = {})
95
+ PWN::Plugins::GDB.mitigations(binary: opts[:bin] || opts[:binary] || session!(opts)[:bin])
96
+ end
97
+
98
+ public_class_method def self.cyclic(opts = {})
99
+ PWN::Plugins::ExploitDev.cyclic(length: opts[:length] || opts[:n] || 200)
100
+ end
101
+
102
+ public_class_method def self.cyclic_find(opts = {})
103
+ PWN::Plugins::ExploitDev.cyclic_find(value: opts[:value] || opts[:pattern], length: opts[:length] || 8_192)
104
+ end
105
+
106
+ public_class_method def self.to_pwntools_offsets(opts = {})
107
+ { cyclic: cyclic(opts), offset: cyclic_find(opts) }
108
+ end
109
+
110
+ public_class_method def self.close(opts = {})
111
+ handle = (opts[:handle] || opts[:id]).to_s
112
+ sess = @sessions.delete(handle)
113
+ PWN::Plugins::ProcessTube.close(id: handle) if sess
114
+ { closed: handle }
115
+ end
116
+
117
+ public_class_method def self.authors
118
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
119
+ end
120
+
121
+ public_class_method def self.help
122
+ puts "USAGE:
123
+ # List host binaries this module expects to be installed.
124
+ #{self}.required_bins
125
+
126
+ # Launch a binary under gdb MI and keep the handle in-process.
127
+ #{self}.launch(
128
+ bin: 'required - filesystem path of the binary to debug',
129
+ binary: 'optional - alias for bin',
130
+ args: 'optional - Array of argv strings after the binary',
131
+ handle: 'optional - ProcessTube name for the session',
132
+ run: 'optional - true issues -exec-run after gdb starts'
133
+ )
134
+
135
+ # Attach gdb MI to an existing pid or gdbstub.
136
+ #{self}.attach(
137
+ pid: 'optional - integer process id to attach',
138
+ host: 'optional - gdbstub host for remote attach',
139
+ port: 'optional - gdbstub TCP port',
140
+ handle: 'optional - ProcessTube name for the session'
141
+ )
142
+
143
+ # Run the inferior (-exec-run).
144
+ #{self}.run(
145
+ handle: 'required - session handle from launch or attach'
146
+ )
147
+
148
+ # Insert a breakpoint at a symbol or address.
149
+ #{self}.break(
150
+ handle: 'required - session handle from launch or attach',
151
+ addr_or_sym: 'required - symbol or address',
152
+ location: 'optional - alias for addr_or_sym',
153
+ addr: 'optional - alias for addr_or_sym',
154
+ symbol: 'optional - alias for addr_or_sym'
155
+ )
156
+
157
+ # Continue execution until the next stop.
158
+ #{self}.continue(
159
+ handle: 'required - session handle from launch or attach'
160
+ )
161
+
162
+ # Step one instruction or source line.
163
+ #{self}.step(
164
+ handle: 'required - session handle from launch or attach',
165
+ into: 'optional - true to step into rather than next'
166
+ )
167
+
168
+ # Read memory bytes at an address.
169
+ #{self}.read_mem(
170
+ handle: 'required - session handle from launch or attach',
171
+ addr: 'required - address to read',
172
+ address: 'optional - alias for addr',
173
+ len: 'optional - byte count (defaults to 64)',
174
+ length: 'optional - alias for len'
175
+ )
176
+
177
+ # Write raw bytes to inferior memory.
178
+ #{self}.write_mem(
179
+ handle: 'required - session handle from launch or attach',
180
+ addr: 'required - address to write',
181
+ address: 'optional - alias for addr',
182
+ data: 'required - String or byte Array to write',
183
+ bytes: 'optional - alias for data'
184
+ )
185
+
186
+ # Return register values as a parsed MI hash.
187
+ #{self}.regs(
188
+ handle: 'required - session handle from launch or attach'
189
+ )
190
+
191
+ # Return a parsed backtrace hash.
192
+ #{self}.backtrace(
193
+ handle: 'required - session handle from launch or attach'
194
+ )
195
+
196
+ # Return binary mitigations via GDB.mitigations.
197
+ #{self}.checksec(
198
+ handle: 'optional - session handle whose binary should be probed',
199
+ bin: 'optional - filesystem path of the binary',
200
+ binary: 'optional - alias for bin'
201
+ )
202
+
203
+ # Generate a cyclic de Bruijn pattern.
204
+ #{self}.cyclic(
205
+ length: 'optional - pattern length in bytes',
206
+ n: 'optional - alias for length'
207
+ )
208
+
209
+ # Find the offset of a packed register value in a cyclic pattern.
210
+ #{self}.cyclic_find(
211
+ value: 'required - leaked register value or substring',
212
+ pattern: 'optional - alias for value',
213
+ length: 'optional - haystack length (defaults to 8192)'
214
+ )
215
+
216
+ # Return both a cyclic pattern and the recovered offset.
217
+ #{self}.to_pwntools_offsets(
218
+ value: 'optional - leaked register value for cyclic_find',
219
+ length: 'optional - pattern length in bytes',
220
+ n: 'optional - alias for length'
221
+ )
222
+
223
+ # Close a debugger session handle.
224
+ #{self}.close(
225
+ handle: 'required - session handle from launch or attach',
226
+ id: 'optional - alias for handle'
227
+ )
228
+
229
+ # Print the AUTHOR(S) string for this module.
230
+ #{self}.authors
231
+ "
232
+ constants.sort
233
+ end
234
+
235
+ private_class_method def self.store_session(opts = {})
236
+ handle = opts[:handle].to_s
237
+ @sessions[handle] = { tube: opts[:tube], bin: opts[:bin], pid: opts[:pid] }
238
+ { handle: handle, pid: opts[:tube][:pid], bin: opts[:bin] }
239
+ end
240
+
241
+ private_class_method def self.session!(opts = {})
242
+ handle = (opts[:handle] || opts[:id]).to_s
243
+ sess = @sessions[handle]
244
+ raise ArgumentError, "unknown debugger handle #{handle}" unless sess
245
+
246
+ sess.merge(handle: handle)
247
+ end
248
+
249
+ private_class_method def self.drain_gdb(opts = {})
250
+ PWN::Plugins::ProcessTube.expect(id: opts[:handle], pattern: /\(gdb\)/, timeout: opts[:timeout] || 8)
251
+ rescue StandardError
252
+ nil
253
+ end
254
+
255
+ private_class_method def self.mi(opts = {})
256
+ sess = session!(opts)
257
+ PWN::Plugins::ProcessTube.write_line(id: sess[:handle], line: opts[:cmd].to_s)
258
+ row = PWN::Plugins::ProcessTube.expect(
259
+ id: sess[:handle],
260
+ pattern: /^\^done|^\^error|^\^running|^\*stopped/m,
261
+ timeout: opts[:timeout] || 8
262
+ )
263
+ row.is_a?(Hash) ? row[:matched].to_s : row.to_s
264
+ end
265
+
266
+ private_class_method def self.parse_mi(opts = {})
267
+ raw = opts[:raw].to_s
268
+ ok = raw.match?(/\^(?:done|running)|^\*stopped/m)
269
+ regs = {}
270
+ raw.scan(/name="([^"]+)",value="([^"]+)"/) { |name, val| regs[name] = val }
271
+ raw.scan(/number="(\d+)",value="([^"]+)"/) { |num, val| regs[num] ||= val }
272
+ mem = {}
273
+ if (m = raw.match(/memory=\[\{[^}]*begin="([^"]+)"[^}]*contents="([^"]+)"/))
274
+ mem = { addr: m[1], hex: m[2] }
275
+ end
276
+ bkpt = {}
277
+ if (m = raw.match(/bkpt=\{([^}]+)\}/))
278
+ m[1].scan(/(\w+)="([^"]+)"/) { |k, v| bkpt[k.to_sym] = v }
279
+ end
280
+ { ok: ok, raw: raw, records: raw.lines.map(&:chomp), registers: regs, memory: mem, bkpt: bkpt }
281
+ end
282
+ end
283
+ end
284
+ end
@@ -19,6 +19,10 @@ module PWN
19
19
  # Strict P10 boundary. Legacy record remains available for older callers.
20
20
  public_class_method def self.record_structured(opts = {})
21
21
  opts = opts.transform_keys(&:to_sym)
22
+ opts[:affected_asset] ||= opts[:target]
23
+ opts[:poc] ||= opts[:repro_cmd]
24
+ opts[:target] ||= opts[:affected_asset]
25
+ opts[:repro_cmd] ||= opts[:poc]
22
26
  %i[title cwe cvss_vector affected_asset poc remediation].each do |key|
23
27
  raise ArgumentError, "#{key} must be a non-empty string" unless opts[key].is_a?(String) && !opts[key].strip.empty?
24
28
  end
@@ -53,7 +57,8 @@ module PWN
53
57
  row = opts.slice(:title, :cwe, :cvss_vector, :cvss_score, :affected_asset, :evidence_paths, :poc,
54
58
  :attack_chain_refs, :remediation, :confidence, :engagement_id, :session_id)
55
59
  row = row.merge(id: SecureRandom.hex(6), severity: severity, status: 'open', verification_status: 'not_executed',
56
- chain_refs: refs, host: opts[:affected_asset], evidence: paths.map { |path| Digest::SHA256.file(path).hexdigest },
60
+ chain_refs: refs, host: opts[:affected_asset], target: opts[:affected_asset],
61
+ repro_cmd: opts[:poc], evidence: paths.map { |path| Digest::SHA256.file(path).hexdigest },
57
62
  poc_artifacts: [], at: Time.now.utc.iso8601)
58
63
  FileUtils.mkdir_p(File.dirname(FILE))
59
64
  File.open(FILE, 'a') do |file|
@@ -378,7 +383,11 @@ module PWN
378
383
  sha256: Digest::SHA256.file(art).hexdigest,
379
384
  size: File.size(art)
380
385
  }
381
- File.open(path, 'a') { |f| f.puts(JSON.generate(rec)) }
386
+ dest = File.join(dir, 'evidence', rec[:sha256])
387
+ FileUtils.mkdir_p(File.dirname(dest))
388
+ FileUtils.cp(art, dest) unless File.file?(dest)
389
+ rec[:stored] = dest
390
+ File.open(path, 'a') { |file| file.puts(JSON.generate(rec)) }
382
391
  end
383
392
  end
384
393
 
@@ -404,7 +413,9 @@ module PWN
404
413
  remediation: 'required - remediation instructions',
405
414
  confidence: 'required - numeric 0..1',
406
415
  engagement_id: 'optional - simple engagement identifier',
407
- session_id: 'optional - session identifier'
416
+ session_id: 'optional - session identifier',
417
+ target: 'optional - alias for affected_asset',
418
+ repro_cmd: 'optional - alias for poc'
408
419
  )
409
420
 
410
421
  # Append a legacy finding row to ~/.pwn/findings.jsonl.
@@ -190,6 +190,61 @@ module PWN
190
190
  dict.map { |blob| seed + blob.to_s }
191
191
  end
192
192
 
193
+ public_class_method def self.triage(opts = {})
194
+ require 'digest'
195
+ crashes = Array(opts[:crashes])
196
+ crashes = Dir.glob(File.join(opts[:dir].to_s, '*')) if crashes.empty?
197
+ files = crashes.select { |path| File.file?(path) }
198
+ groups = files.group_by { |path| crash_hash(path: path) }
199
+ groups.map do |hash, group|
200
+ mini = minimize_group(files: group, target: opts[:target])
201
+ body = File.binread(mini).to_s
202
+ score = exploitability(text: body, path: mini)
203
+ row = {
204
+ hash: hash,
205
+ count: group.length,
206
+ minimized: mini,
207
+ exploitability: score,
208
+ files: group
209
+ }
210
+ if opts[:record] == true && defined?(PWN::Plugins::Findings)
211
+ PWN::Plugins::Findings.record(
212
+ title: "fuzz crash #{hash}",
213
+ severity: score.include?('pc_control') ? 'high' : 'medium',
214
+ host: opts[:target].to_s,
215
+ evidence: mini,
216
+ poc: mini
217
+ )
218
+ end
219
+ row
220
+ end
221
+ end
222
+
223
+ private_class_method def self.crash_hash(opts = {})
224
+ body = File.binread(opts[:path]).to_s
225
+ if (m = body.match(/AddressSanitizer[^\n]*\n(?:.*\n){0,12}/))
226
+ Digest::SHA256.hexdigest(m[0])[0, 16]
227
+ elsif (m = body.scan(/#\d+\s+0x[0-9a-f]+.+/).first(6)).any?
228
+ Digest::SHA256.hexdigest(m.join)[0, 16]
229
+ else
230
+ Digest::SHA256.hexdigest(body)[0, 16]
231
+ end
232
+ end
233
+
234
+ private_class_method def self.minimize_group(opts = {})
235
+ files = Array(opts[:files])
236
+ files.min_by { |path| File.size(path) }
237
+ end
238
+
239
+ private_class_method def self.exploitability(opts = {})
240
+ text = opts[:text].to_s
241
+ return 'pc_control_candidate' if text.match?(/pc\s+0x|rip\s+0x|eip\s+0x|SEGV on unknown address 0x/i)
242
+ return 'writable_address_control' if text.match?(/WRITE of size|heap-buffer-overflow|stack-buffer-overflow/i)
243
+ return 'pc_control_candidate' if File.size(opts[:path].to_s).to_i >= 8
244
+
245
+ 'unknown'
246
+ end
247
+
193
248
  # Author(s):: 0day Inc. <support@0dayinc.com>
194
249
 
195
250
  public_class_method def self.authors
@@ -232,6 +287,13 @@ module PWN
232
287
  dictionary: 'optional - Array of binary/string mutations'
233
288
  )
234
289
 
290
+ # Dedup crash files by content hash and pick the smallest reproducer.
291
+ #{self}.triage(
292
+ crashes: 'optional - Array of crash file paths',
293
+ dir: 'optional - directory globbed when crashes is empty',
294
+ record: 'optional - true records unique crashes via Findings.record'
295
+ )
296
+
235
297
  # Print the AUTHOR(S) string for this module.
236
298
  #{self}.authors
237
299
  "