pwn 0.5.719 → 0.5.721

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +53 -0
  3. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +4 -0
  4. data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +5 -2
  5. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +1 -0
  6. data/etc/default_skills/pwn/ai/agent/registry/SKILL.md +3 -0
  7. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
  8. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +3 -0
  9. data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
  10. data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
  11. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +1 -0
  13. data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
  14. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  15. data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
  17. data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -0
  18. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  19. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  20. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +2 -0
  21. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  22. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  24. data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
  25. data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
  26. data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
  27. data/lib/pwn/ai/agent/dispatch.rb +69 -1
  28. data/lib/pwn/ai/agent/engagement.rb +167 -0
  29. data/lib/pwn/ai/agent/learning.rb +105 -5
  30. data/lib/pwn/ai/agent/loop.rb +83 -25
  31. data/lib/pwn/ai/agent/metrics.rb +35 -1
  32. data/lib/pwn/ai/agent/registry.rb +28 -0
  33. data/lib/pwn/ai/agent/reward.rb +80 -15
  34. data/lib/pwn/ai/agent/swarm.rb +36 -0
  35. data/lib/pwn/ai/agent/tool_guard.rb +93 -9
  36. data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
  37. data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
  38. data/lib/pwn/ai/agent/turn_finalizer.rb +84 -0
  39. data/lib/pwn/ai/agent.rb +1 -0
  40. data/lib/pwn/config.rb +18 -2
  41. data/lib/pwn/plugins/artifact_registry.rb +32 -2
  42. data/lib/pwn/plugins/bin_workspace.rb +81 -0
  43. data/lib/pwn/plugins/binary_parser.rb +27 -0
  44. data/lib/pwn/plugins/capability.rb +68 -0
  45. data/lib/pwn/plugins/detonate.rb +62 -0
  46. data/lib/pwn/plugins/emulator.rb +64 -0
  47. data/lib/pwn/plugins/exploit_dev.rb +33 -0
  48. data/lib/pwn/plugins/findings.rb +56 -1
  49. data/lib/pwn/plugins/gdb.rb +42 -0
  50. data/lib/pwn/plugins/ghidra.rb +20 -2
  51. data/lib/pwn/plugins/packet.rb +26 -0
  52. data/lib/pwn/plugins/preflight_checker.rb +23 -0
  53. data/lib/pwn/plugins/process_tube.rb +31 -1
  54. data/lib/pwn/plugins/repl.rb +25 -2
  55. data/lib/pwn/plugins/vault.rb +116 -0
  56. data/lib/pwn/plugins.rb +4 -0
  57. data/lib/pwn/reports/engagement.rb +63 -0
  58. data/lib/pwn/reports.rb +1 -0
  59. data/lib/pwn/sessions.rb +4 -0
  60. data/lib/pwn/version.rb +1 -1
  61. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +9 -0
  62. data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
  63. data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
  64. data/spec/lib/pwn/ai/agent/loop_spec.rb +44 -0
  65. data/spec/lib/pwn/ai/agent/reward_spec.rb +17 -1
  66. data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
  67. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +9 -0
  68. data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
  69. data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
  70. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
  71. data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
  72. data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
  73. data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
  74. data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
  75. data/spec/lib/pwn/plugins/findings_spec.rb +16 -3
  76. data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
  77. data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
  78. data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
  79. data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
  80. data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
  81. data/third_party/pwn_rdoc.jsonl +73 -0
  82. metadata +25 -1
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module PWN
4
6
  module AI
5
7
  module Agent
@@ -175,6 +177,66 @@ module PWN
175
177
  MUTEX.synchronize { THREADS.count(&:alive?) }
176
178
  end
177
179
 
180
+ public_class_method def self.arbitrate(opts = {})
181
+ request = opts[:request].to_s
182
+ messages = Array(opts[:messages])
183
+ t0 = opts[:session_t0] || Thread.current[:pwn_loop_t0]
184
+ paths = Array(opts[:paths]).map(&:to_s).select { |p| p.start_with?('/') }
185
+ paths += request.scan(%r{(/[A-Za-z0-9._/+-]+\.\w+)}).flatten
186
+ paths.uniq!
187
+ ledger = evidence_ledger(messages: messages)
188
+ unmet = []
189
+ paths.each do |path|
190
+ row = ledger[path]
191
+ if row.nil? || !File.file?(path)
192
+ unmet << { criterion: 'artifact_missing', detail: path }
193
+ next
194
+ end
195
+ unmet << { criterion: 'empty_artifact', detail: path } if File.size(path) <= 0
196
+ unmet << { criterion: 'readback_missing', detail: path } unless row[:read]
197
+ unmet << { criterion: 'artifact_mtime_before_session', detail: path } if t0 && File.mtime(path) < t0 && !row[:write]
198
+ end
199
+ {
200
+ complete: unmet.empty? && (!paths.empty? || ledger.any?),
201
+ unmet: unmet,
202
+ ledger: ledger
203
+ }
204
+ end
205
+
206
+ public_class_method def self.evidence_ledger(opts = {})
207
+ ledger = {}
208
+ Array(opts[:messages]).each do |msg|
209
+ next unless msg.is_a?(Hash) && msg[:role].to_s == 'tool'
210
+
211
+ raw = msg[:content].to_s
212
+ fx = nil
213
+ begin
214
+ parsed = JSON.parse(raw, symbolize_names: true)
215
+ fx = parsed[:effect].to_s.to_sym if parsed.is_a?(Hash)
216
+ rescue StandardError
217
+ fx = nil
218
+ end
219
+ raw.scan(%r{(/[A-Za-z0-9._/+-]+)}).flatten.each do |path|
220
+ next unless path.start_with?('/')
221
+
222
+ ledger[path] ||= { write: false, read: false }
223
+ ledger[path][:write] = true if fx == :write
224
+ ledger[path][:read] = true if fx == :read
225
+ end
226
+ end
227
+ ledger
228
+ end
229
+
230
+ public_class_method def self.evidence_satisfied?(opts = {})
231
+ row = arbitrate(
232
+ request: opts[:request],
233
+ messages: opts[:messages] || opts[:trace],
234
+ paths: opts[:paths],
235
+ session_t0: opts[:session_t0]
236
+ )
237
+ row[:complete] == true && Array(row[:unmet]).empty?
238
+ end
239
+
178
240
  public_class_method def self.authors
179
241
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
180
242
  end
@@ -217,6 +279,28 @@ module PWN
217
279
  # Run pending and return its result
218
280
  #{self}.pending
219
281
 
282
+ # Arbitrate a completion claim against the turn evidence ledger.
283
+ #{self}.arbitrate(
284
+ request: 'optional - original operator request',
285
+ messages: 'optional - Array of role/content hashes for this turn',
286
+ paths: 'optional - absolute paths that must exist',
287
+ session_t0: 'optional - session start Time (mtime-before-session is named, not silent)'
288
+ )
289
+
290
+ # Build path → {write,read} ledger from tool messages.
291
+ #{self}.evidence_ledger(
292
+ messages: 'optional - Array of role/content hashes'
293
+ )
294
+
295
+ # True when write-then-readback evidence satisfies the original request.
296
+ #{self}.evidence_satisfied?(
297
+ request: 'optional - original request text',
298
+ messages: 'optional - Array of role/content hashes',
299
+ trace: 'optional - alias for messages (Array of role/content hashes)',
300
+ paths: 'optional - Array of declared deliverable paths',
301
+ session_t0: 'optional - session start Time'
302
+ )
303
+
220
304
  # Print the AUTHOR(S) string for this module.
221
305
  #{self}.authors
222
306
  "
data/lib/pwn/ai/agent.rb CHANGED
@@ -35,6 +35,7 @@ module PWN
35
35
  autoload :Policy, 'pwn/ai/agent/policy'
36
36
  autoload :ToolGuard, 'pwn/ai/agent/tool_guard'
37
37
  autoload :TurnFinalizer, 'pwn/ai/agent/turn_finalizer'
38
+ autoload :Engagement, 'pwn/ai/agent/engagement'
38
39
  autoload :OpenGoal, 'pwn/ai/agent/open_goal'
39
40
  autoload :PromptCache, 'pwn/ai/agent/prompt_cache'
40
41
 
data/lib/pwn/config.rb CHANGED
@@ -203,9 +203,25 @@ module PWN
203
203
  act: nil,
204
204
  sensitive: nil
205
205
  }
206
- # multi-agent personas : ~/.pwn/agents.yml (see PWN::AI::Agent::Swarm.help)
207
- # swarm bus : ~/.pwn/swarm/<swarm_id>/bus.jsonl
206
+ },
207
+ reward: {
208
+ verifier_precedence: true
209
+ },
210
+ engagement: {
211
+ enforce: 'block'
212
+ },
213
+ taint: {
214
+ mode: 'enforce'
215
+ },
216
+ learning: {
217
+ max_baks: 5,
218
+ compact_after_days: 30
219
+ },
220
+ capabilities: {
221
+ grantable: %w[cap_net_raw]
208
222
  }
223
+ # multi-agent personas : ~/.pwn/agents.yml (see PWN::AI::Agent::Swarm.help)
224
+ # swarm bus : ~/.pwn/swarm/<swarm_id>/bus.jsonl
209
225
  },
210
226
  plugins: {
211
227
  asm: { arch: PWN::Plugins::DetectOS.arch, endian: PWN::Plugins::DetectOS.endian.to_s },
@@ -3,6 +3,7 @@
3
3
  require 'fileutils'
4
4
  require 'json'
5
5
  require 'digest'
6
+ require 'time'
6
7
 
7
8
  module PWN
8
9
  module Plugins
@@ -70,6 +71,26 @@ module PWN
70
71
  end
71
72
  end
72
73
 
74
+ public_class_method def self.put(opts = {})
75
+ bytes = opts[:bytes]
76
+ src = opts[:path].to_s
77
+ bytes = File.binread(src) if bytes.nil? && File.file?(src)
78
+ raise 'ERROR: bytes or path is required' if bytes.nil?
79
+
80
+ sha = Digest::SHA256.hexdigest(bytes)
81
+ dir = File.join(ROOT, 'sha256', sha[0, 2])
82
+ FileUtils.mkdir_p(dir)
83
+ dest = File.join(dir, sha)
84
+ File.binwrite(dest, bytes) unless File.file?(dest)
85
+ meta = File.join(ROOT, 'manifest.jsonl')
86
+ FileUtils.mkdir_p(ROOT)
87
+ File.open(meta, 'a') do |f|
88
+ f.flock(File::LOCK_EX)
89
+ f.puts(JSON.generate(sha256: sha, size: bytes.bytesize, tool: opts[:tool], session: opts[:session_id], kind: opts[:kind], created_at: Time.now.utc.iso8601, source_path: src, dest: dest))
90
+ end
91
+ { sha256: sha, path: dest, size: bytes.bytesize }
92
+ end
93
+
73
94
  public_class_method def self.authors
74
95
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
75
96
  end
@@ -81,14 +102,14 @@ module PWN
81
102
 
82
103
  # Run register and return its result
83
104
  #{self}.register(
84
- session_id: 'optional - session id value consumed by #register',
105
+ session_id: 'optional - pwn-ai session id that produced this artifact',
85
106
  path: 'required - filesystem path to read or write',
86
107
  kind: 'optional - kind value consumed by #register'
87
108
  )
88
109
 
89
110
  # Run list and return its result
90
111
  #{self}.list(
91
- session_id: 'optional - session id value consumed by #list'
112
+ session_id: 'optional - pwn-ai session id whose artifacts to list'
92
113
  )
93
114
 
94
115
  # Read an artifact file and return sha256 plus a body cap.
@@ -104,6 +125,15 @@ module PWN
104
125
  mode: 'optional - text, hex, or base64 (defaults to text)'
105
126
  )
106
127
 
128
+ # Store bytes under artifacts/sha256/<h2>/<hash> and append manifest.jsonl.
129
+ #{self}.put(
130
+ bytes: 'optional - raw bytes to store',
131
+ path: 'optional - filesystem path to read when bytes is omitted',
132
+ tool: 'optional - tool name for provenance',
133
+ session_id: 'optional - pwn-ai session id for provenance',
134
+ kind: 'optional - artifact kind'
135
+ )
136
+
107
137
  # Print the AUTHOR(S) string for this module.
108
138
  #{self}.authors
109
139
  "
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'fileutils'
6
+
7
+ module PWN
8
+ module Plugins
9
+ # Persistent per-binary analysis workspace keyed by sha256.
10
+ module BinWorkspace
11
+ ROOT = File.join(Dir.home, '.pwn', 'binws')
12
+
13
+ public_class_method def self.for(opts = {})
14
+ path = (opts[:path] || opts[:bin]).to_s
15
+ raise 'ERROR: path is required' if path.empty?
16
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
17
+
18
+ sha = Digest::SHA256.file(path).hexdigest
19
+ dir = File.join(ROOT, sha)
20
+ FileUtils.mkdir_p(dir)
21
+ notes = File.join(dir, 'notes.json')
22
+ File.write(notes, JSON.generate({})) unless File.file?(notes)
23
+ { sha256: sha, dir: dir, path: path, notes: notes }
24
+ end
25
+
26
+ public_class_method def self.annotate(opts = {})
27
+ ws = self.for(opts)
28
+ store = JSON.parse(File.read(ws[:notes]), symbolize_names: true)
29
+ addr = (opts[:addr] || opts[:address]).to_s
30
+ raise 'ERROR: addr is required' if addr.empty?
31
+
32
+ store[addr.to_sym] = {
33
+ kind: (opts[:kind] || 'comment').to_s,
34
+ text: opts[:text].to_s,
35
+ name: opts[:name].to_s
36
+ }
37
+ File.write(ws[:notes], JSON.pretty_generate(store))
38
+ store[addr.to_sym].merge(sha256: ws[:sha256])
39
+ end
40
+
41
+ public_class_method def self.notes(opts = {})
42
+ path = (opts[:path] || opts[:bin]).to_s
43
+ ws = self.for(path: path)
44
+ JSON.parse(File.read(ws[:notes]), symbolize_names: true)
45
+ end
46
+
47
+ public_class_method def self.authors
48
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
49
+ end
50
+
51
+ public_class_method def self.help
52
+ puts "USAGE:
53
+ # Open or create ~/.pwn/binws/<sha256> for a binary.
54
+ #{self}.for(
55
+ path: 'required - filesystem path of the binary',
56
+ bin: 'optional - alias for path'
57
+ )
58
+
59
+ # Record a rename, comment, or tag at an address.
60
+ #{self}.annotate(
61
+ path: 'required - filesystem path of the binary',
62
+ addr: 'required - address or symbol',
63
+ address: 'optional - alias for addr',
64
+ kind: 'optional - comment|rename|tag (defaults to comment)',
65
+ text: 'optional - annotation body',
66
+ name: 'optional - renamed symbol'
67
+ )
68
+
69
+ # Return the annotation map for a binary workspace.
70
+ #{self}.notes(
71
+ path: 'required - filesystem path of the binary'
72
+ )
73
+
74
+ # Print the AUTHOR(S) string for this module.
75
+ #{self}.authors
76
+ "
77
+ constants.sort
78
+ end
79
+ end
80
+ end
81
+ end
@@ -4,6 +4,7 @@ require 'metasm'
4
4
  require 'digest'
5
5
  require 'json'
6
6
  require 'fileutils'
7
+ require 'open3'
7
8
 
8
9
  module PWN
9
10
  module Plugins
@@ -122,6 +123,24 @@ module PWN
122
123
  body.merge(artifact: art, cached: false)
123
124
  end
124
125
 
126
+ public_class_method def self.diff(opts = {})
127
+ a = (opts[:a] || opts[:old]).to_s
128
+ b = (opts[:b] || opts[:new]).to_s
129
+ raise 'ERROR: a and b are required' if a.empty? || b.empty?
130
+
131
+ return { error: 'radiff2 missing', hint: 'pwn setup --profile re', a: a, b: b } unless PWN::Plugins::PreflightChecker.bin?(name: 'radiff2')
132
+
133
+ stdout, stderr, status = Open3.capture3('radiff2', '-C', a, b)
134
+ funcs = stdout.lines.filter_map do |ln|
135
+ next unless ln.include?(' | ')
136
+
137
+ cols = ln.split('|').map(&:strip)
138
+ { a: cols[0], similarity: cols[1].to_f, b: cols[2], changed: cols[1].to_f < 100 }
139
+ end
140
+ funcs = funcs.sort_by { |r| r[:similarity].to_f }
141
+ { a: a, b: b, functions: funcs, stderr: stderr, exit: status.exitstatus }
142
+ end
143
+
125
144
  public_class_method def self.authors
126
145
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
127
146
  end
@@ -163,6 +182,14 @@ module PWN
163
182
  path: 'required - filesystem path of the binary to triage'
164
183
  )
165
184
 
185
+ # Diff two binaries via radiff2 -C (changed functions first).
186
+ #{self}.diff(
187
+ a: 'required - filesystem path of the first binary',
188
+ b: 'required - filesystem path of the second binary',
189
+ old: 'optional - alias for a',
190
+ new: 'optional - alias for b'
191
+ )
192
+
166
193
  # Print the AUTHOR(S) string for this module.
167
194
  #{self}.authors
168
195
  "
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module PWN
7
+ module Plugins
8
+ # Operator-approved capability elevation (never auto-sudo).
9
+ module Capability
10
+ public_class_method def self.request(opts = {})
11
+ cap = (opts[:cap] || opts[:capability]).to_s.downcase
12
+ raise 'ERROR: cap is required' if cap.empty?
13
+
14
+ reason = opts[:reason].to_s
15
+ grantable = Array(grantable_caps).map(&:to_s)
16
+ return { ok: false, cap: cap, error: 'not grantable', grantable: grantable } unless grantable.include?(cap)
17
+
18
+ unless opts[:operator_ack] == true
19
+ return {
20
+ ok: false,
21
+ cap: cap,
22
+ needs_ack: true,
23
+ prompt: "Approve setcap #{cap} on the pwn helper? Pass operator_ack: true. Reason: #{reason}"
24
+ }
25
+ end
26
+
27
+ { ok: true, cap: cap, applied: false, note: 'operator acknowledged; apply setcap on the ruby helper out of band', reason: reason }
28
+ end
29
+
30
+ public_class_method def self.grantable_caps(opts = {})
31
+ listed = Array(opts[:grantable])
32
+ return listed.map(&:to_s) unless listed.empty?
33
+ return %w[cap_net_raw] unless defined?(PWN::Env)
34
+
35
+ got = Array(PWN::Env.dig(:ai, :capabilities, :grantable)).map(&:to_s)
36
+ got.empty? ? %w[cap_net_raw] : got
37
+ rescue StandardError
38
+ %w[cap_net_raw]
39
+ end
40
+
41
+ public_class_method def self.authors
42
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
43
+ end
44
+
45
+ public_class_method def self.help
46
+ puts "USAGE:
47
+ # Request a grantable capability; never sudo without operator_ack.
48
+ #{self}.request(
49
+ cap: 'required - capability name (e.g. cap_net_raw)',
50
+ capability: 'optional - alias for cap',
51
+ reason: 'optional - why the lane needs it',
52
+ operator_ack: 'optional - true only after the operator approved'
53
+ )
54
+
55
+ # List capabilities pwn.yaml allows granting.
56
+ #{self}.grantable_caps(
57
+ unused: 'optional - reserved',
58
+ grantable: 'optional - Array of capability names to return instead of pwn.yaml'
59
+ )
60
+
61
+ # Print the AUTHOR(S) string for this module.
62
+ #{self}.authors
63
+ "
64
+ constants.sort
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'open3'
6
+ require 'tmpdir'
7
+
8
+ module PWN
9
+ module Plugins
10
+ # Isolated sample detonation. Refuses if podman/isolation is missing.
11
+ module Detonate
12
+ public_class_method def self.detonate(opts = {})
13
+ path = (opts[:path] || opts[:sample]).to_s
14
+ raise 'ERROR: path is required' if path.empty?
15
+ raise "ERROR: sample not found: #{path}" unless File.file?(path)
16
+
17
+ timeout = (opts[:timeout] || 30).to_i
18
+ net = (opts[:network] || :none).to_s
19
+ return { error: 'isolation preconditions unmet', hint: 'install podman (rootless); never falling back to host', path: path } unless PWN::Plugins::PreflightChecker.bin?(name: 'podman')
20
+
21
+ home = Dir.home
22
+ Dir.mktmpdir('pwn-detonate') do |tmp|
23
+ FileUtils.cp(path, File.join(tmp, File.basename(path)))
24
+ cmd = [
25
+ 'podman', 'run', '--rm', '--network', 'none',
26
+ '--timeout', timeout.to_s, '-v', "#{tmp}:/sample:ro", 'alpine:latest',
27
+ 'sh', '-c', 'touch /tmp/ioc; echo attempted_write_home=$HOME'
28
+ ]
29
+ stdout, stderr, status = Open3.capture3(*cmd)
30
+ iocs = {
31
+ stdout: stdout,
32
+ stderr: stderr,
33
+ exit: status.exitstatus,
34
+ home_untouched: File.directory?(home),
35
+ attempted_home_write: stdout.include?('attempted_write_home')
36
+ }
37
+ { iocs: iocs, network: net, timeout: timeout, isolated: true }
38
+ end
39
+ end
40
+
41
+ public_class_method def self.authors
42
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
43
+ end
44
+
45
+ public_class_method def self.help
46
+ puts "USAGE:
47
+ # Run a sample in a network-isolated ephemeral container.
48
+ #{self}.detonate(
49
+ path: 'required - sample filesystem path',
50
+ sample: 'optional - alias for path',
51
+ timeout: 'optional - seconds (defaults to 30)',
52
+ network: 'optional - none|sinkhole (defaults to none)'
53
+ )
54
+
55
+ # Print the AUTHOR(S) string for this module.
56
+ #{self}.authors
57
+ "
58
+ constants.sort
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module PWN
6
+ module Plugins
7
+ # Unicorn-backed single-function emulation with a stub backend for tests.
8
+ module Emulator
9
+ public_class_method def self.emulate(opts = {})
10
+ path = (opts[:binary] || opts[:path]).to_s
11
+ addr = opts[:addr] || opts[:address]
12
+ raise 'ERROR: binary and addr are required' if path.empty? || addr.to_s.empty?
13
+
14
+ max = (opts[:max_insns] || 10_000).to_i
15
+ args = Array(opts[:args])
16
+ if opts[:backend].to_s == 'stub' || opts[:plaintext]
17
+ return {
18
+ backend: 'stub',
19
+ addr: addr,
20
+ ret: opts[:plaintext] || opts[:ret],
21
+ mem_writes: Array(opts[:mem_writes]),
22
+ trace_tail: [],
23
+ max_insns: max,
24
+ args: args
25
+ }
26
+ end
27
+
28
+ begin
29
+ require 'unicorn_engine'
30
+ rescue LoadError
31
+ return { error: 'unicorn-engine gem missing', hint: 'gem install unicorn-engine', path: path }
32
+ end
33
+
34
+ { error: 'unicorn mapping not configured for this binary', path: path, addr: addr }
35
+ end
36
+
37
+ public_class_method def self.authors
38
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
39
+ end
40
+
41
+ public_class_method def self.help
42
+ puts "USAGE:
43
+ # Emulate one function under Unicorn, or a stub backend for tests.
44
+ #{self}.emulate(
45
+ binary: 'required - filesystem path of the binary',
46
+ path: 'optional - alias for binary',
47
+ addr: 'required - function address',
48
+ address: 'optional - alias for addr',
49
+ args: 'optional - Array of integer/string arguments',
50
+ max_insns: 'optional - instruction cap (defaults to 10000)',
51
+ backend: 'optional - stub to skip Unicorn',
52
+ plaintext: 'optional - stub return value',
53
+ ret: 'optional - alias for plaintext',
54
+ mem_writes: 'optional - Array of stub memory writes'
55
+ )
56
+
57
+ # Print the AUTHOR(S) string for this module.
58
+ #{self}.authors
59
+ "
60
+ constants.sort
61
+ end
62
+ end
63
+ end
64
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require 'fileutils'
4
5
 
5
6
  module PWN
6
7
  module Plugins
@@ -171,6 +172,31 @@ module PWN
171
172
  end
172
173
  end
173
174
 
175
+ public_class_method def self.scaffold(opts = {})
176
+ path = (opts[:path] || opts[:bin]).to_s
177
+ raise 'ERROR: path is required' if path.empty?
178
+
179
+ triage = {}
180
+ triage = PWN::Plugins::BinaryParser.triage(path: path) if defined?(PWN::Plugins::BinaryParser)
181
+ gadgets = []
182
+ gadgets = Array(self.gadgets(path: path)[:gadgets]) if File.file?(path)
183
+ body = <<~RB
184
+ # frozen_string_literal: true
185
+ # scaffold for #{path}
186
+ # arch=#{triage[:arch]} pie=#{triage.dig(:protections, :pie)} canary=#{triage.dig(:protections, :canary)}
187
+ require 'pwn'
188
+ bin = #{path.inspect}
189
+ cyclic = PWN::Plugins::ExploitDev.cyclic(length: 200)
190
+ io = PWN::Plugins::ExploitDev.io(cmd: bin)
191
+ PWN::Plugins::ProcessTube.write_line(id: io[:id], line: cyclic)
192
+ RB
193
+ out = opts[:out].to_s
194
+ out = File.join(Dir.home, '.pwn', 'artifacts', 'scaffolds', "#{File.basename(path)}.rb") if out.empty?
195
+ FileUtils.mkdir_p(File.dirname(out))
196
+ File.write(out, body)
197
+ { path: out, triage: triage, gadgets: gadgets.first(8) }
198
+ end
199
+
174
200
  public_class_method def self.authors
175
201
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
176
202
  end
@@ -294,6 +320,13 @@ module PWN
294
320
  port: 'optional - remote TCP port'
295
321
  )
296
322
 
323
+ # Emit a ruby exploit scaffold bound to triage output.
324
+ #{self}.scaffold(
325
+ path: 'required - binary path to scaffold against',
326
+ bin: 'optional - alias for path',
327
+ out: 'optional - output .rb path (defaults under ~/.pwn/artifacts/scaffolds)'
328
+ )
329
+
297
330
  # Run gadgets and return its result
298
331
  #{self}.gadgets(
299
332
  path: 'required - filesystem path of the binary to search for gadgets'
@@ -3,6 +3,7 @@
3
3
  require 'json'
4
4
  require 'fileutils'
5
5
  require 'securerandom'
6
+ require 'digest'
6
7
 
7
8
  module PWN
8
9
  module Plugins
@@ -22,6 +23,9 @@ module PWN
22
23
  arts << opts[:poc].to_s unless opts[:poc].to_s.empty?
23
24
  raise 'ERROR: poc_artifacts are required' if arts.empty?
24
25
 
26
+ ev = opts[:evidence].to_s
27
+ raise 'ERROR: evidence must be at least 40 characters citing the PoC' if ev.length < 40
28
+
25
29
  row = {
26
30
  id: SecureRandom.hex(6),
27
31
  title: title,
@@ -39,9 +43,28 @@ module PWN
39
43
  }
40
44
  FileUtils.mkdir_p(File.dirname(FILE))
41
45
  File.open(FILE, 'a') { |f| f.puts(JSON.generate(row)) }
46
+ evidence_anchor(row: row, arts: arts)
42
47
  row
43
48
  end
44
49
 
50
+ public_class_method def self.evidence_verify(opts = {})
51
+ eng = (opts[:engagement_id] || opts[:name] || 'default').to_s
52
+ path = File.join(Dir.home, '.pwn', 'engagements', eng, 'evidence.jsonl')
53
+ return { ok: true, rows: 0 } unless File.file?(path)
54
+
55
+ mismatches = []
56
+ n = 0
57
+ File.readlines(path).each do |ln|
58
+ row = JSON.parse(ln, symbolize_names: true)
59
+ n += 1
60
+ next unless File.file?(row[:path].to_s)
61
+
62
+ sha = Digest::SHA256.file(row[:path]).hexdigest
63
+ mismatches << row[:path] unless sha == row[:sha256].to_s
64
+ end
65
+ { ok: mismatches.empty?, rows: n, mismatches: mismatches }
66
+ end
67
+
45
68
  public_class_method def self.report(opts = {})
46
69
  return [] unless File.file?(FILE)
47
70
 
@@ -63,7 +86,12 @@ module PWN
63
86
  parent_id = opts[:parent_id].to_s
64
87
  raise 'ERROR: parent_id is required' if parent_id.empty?
65
88
 
66
- child = record(opts.merge(chain_parent_id: parent_id))
89
+ child = record(
90
+ opts.merge(
91
+ chain_parent_id: parent_id,
92
+ evidence: (opts[:evidence].to_s.length >= 40 ? opts[:evidence] : 'Chained finding reuses parent PoC evidence and raises composite impact.')
93
+ )
94
+ )
67
95
  ranks = { 'info' => 0, 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4 }
68
96
  parent = report.find { |r| r[:id].to_s == parent_id }
69
97
  sev = [parent&.[](:severity), child[:severity]].compact.max_by { |s| ranks[s.to_s] || 0 }
@@ -84,6 +112,27 @@ module PWN
84
112
  }
85
113
  end
86
114
 
115
+ private_class_method def self.evidence_anchor(opts = {})
116
+ row = opts[:row]
117
+ arts = Array(opts[:arts])
118
+ eng = (row[:engagement_id] || 'default').to_s
119
+ dir = File.join(Dir.home, '.pwn', 'engagements', eng)
120
+ FileUtils.mkdir_p(dir)
121
+ path = File.join(dir, 'evidence.jsonl')
122
+ arts.each do |art|
123
+ next unless File.file?(art.to_s)
124
+
125
+ rec = {
126
+ ts: Time.now.utc.iso8601,
127
+ finding_id: row[:id],
128
+ path: art,
129
+ sha256: Digest::SHA256.file(art).hexdigest,
130
+ size: File.size(art)
131
+ }
132
+ File.open(path, 'a') { |f| f.puts(JSON.generate(rec)) }
133
+ end
134
+ end
135
+
87
136
  public_class_method def self.authors
88
137
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
89
138
  end
@@ -134,6 +183,12 @@ module PWN
134
183
  report_name: 'optional - basename without extension (defaults to findings)'
135
184
  )
136
185
 
186
+ # Re-hash evidence files and report tampering.
187
+ #{self}.evidence_verify(
188
+ engagement_id: 'optional - engagement name (defaults to default)',
189
+ name: 'optional - alias for engagement_id'
190
+ )
191
+
137
192
  # Print the AUTHOR(S) string for this module.
138
193
  #{self}.authors
139
194
  "