pwn 0.5.720 → 0.5.722

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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
  3. data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +4 -2
  4. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +2 -0
  5. data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
  6. data/etc/default_skills/pwn/ai/agent/registry/SKILL.md +3 -0
  7. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +2 -0
  8. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +2 -0
  9. data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +1 -0
  10. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  13. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  14. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  15. data/lib/pwn/ai/agent/dispatch.rb +15 -5
  16. data/lib/pwn/ai/agent/learning.rb +52 -1
  17. data/lib/pwn/ai/agent/loop.rb +74 -26
  18. data/lib/pwn/ai/agent/metrics.rb +53 -1
  19. data/lib/pwn/ai/agent/mistakes.rb +67 -1
  20. data/lib/pwn/ai/agent/registry.rb +28 -0
  21. data/lib/pwn/ai/agent/reward.rb +15 -10
  22. data/lib/pwn/ai/agent/tool_guard.rb +54 -9
  23. data/lib/pwn/ai/agent/tools/artifacts.rb +8 -3
  24. data/lib/pwn/ai/agent/turn_finalizer.rb +20 -1
  25. data/lib/pwn/plugins/artifact_registry.rb +54 -8
  26. data/lib/pwn/plugins/binary_parser.rb +4 -1
  27. data/lib/pwn/plugins/exploit_dev.rb +48 -0
  28. data/lib/pwn/plugins/findings.rb +9 -1
  29. data/lib/pwn/plugins/gdb.rb +17 -0
  30. data/lib/pwn/plugins/ghidra.rb +20 -2
  31. data/lib/pwn/plugins/jobs.rb +19 -0
  32. data/lib/pwn/plugins/preflight_checker.rb +23 -0
  33. data/lib/pwn/plugins/process_tube.rb +31 -1
  34. data/lib/pwn/plugins/repl.rb +6 -1
  35. data/lib/pwn/sessions.rb +4 -0
  36. data/lib/pwn/version.rb +1 -1
  37. data/spec/lib/pwn/ai/agent/dispatch_spec.rb +9 -0
  38. data/spec/lib/pwn/ai/agent/loop_spec.rb +44 -0
  39. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
  40. data/spec/lib/pwn/ai/agent/reward_spec.rb +13 -1
  41. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +9 -0
  42. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +9 -0
  43. data/spec/lib/pwn/plugins/findings_spec.rb +4 -4
  44. data/third_party/pwn_rdoc.jsonl +20 -0
  45. metadata +1 -1
@@ -127,6 +127,31 @@ module PWN
127
127
  'other'
128
128
  end
129
129
 
130
+ public_class_method def self.family(opts = {})
131
+ klass = error_class(opts)
132
+ case klass
133
+ when 'docker_registry_auth', 'auth_denied' then 'auth_denied'
134
+ when 'name_conflict' then 'name_conflict'
135
+ when 'parse_error' then 'parse_error'
136
+ when 'missing_path', 'enoent' then 'missing_path'
137
+ when 'perm_denied', 'socket_perm', 'eacces' then 'permission_capability'
138
+ when 'net_unreach' then 'network_unreachable'
139
+ when 'timeout' then 'timeout'
140
+ else 'other'
141
+ end
142
+ end
143
+
144
+ FAMILY_FIXES = {
145
+ 'auth_denied' => 'check registry credentials / image name; do NOT ls paths',
146
+ 'name_conflict' => 'rename the resource or remove the existing name; do NOT ls paths',
147
+ 'parse_error' => 'fix template/JSON syntax; do NOT ls paths',
148
+ 'missing_path' => 'Path missing. ls/test -e the parent first, then run. Do not retry the same missing path.',
149
+ 'permission_capability' => 'Raw sockets need CAP_NET_RAW. Do not retry open_sockraw. Use connect-scan or Sock.',
150
+ 'network_unreachable' => 'network unreachable; check routing/VPN, do not ls paths',
151
+ 'timeout' => 'deadline too short; retry same payload with timeout += 180',
152
+ 'other' => 'inspect the exact error class before retrying'
153
+ }.freeze
154
+
130
155
  # Supported Method Parameters::
131
156
  # entry = PWN::AI::Agent::Mistakes.find(
132
157
  # signature: 'optional - exact signature to fetch',
@@ -630,10 +655,38 @@ module PWN
630
655
 
631
656
  parts = ["seen #{m[:count]}× across #{Array(m[:sessions]).length} session(s), sig=#{m[:signature]}"]
632
657
  parts << 'REGRESSED (previous fix did not hold)' if m[:regressed]
633
- parts << "KNOWN FIX: #{m[:fix]}" if m[:fix].to_s.strip.length.positive?
658
+ fam = family(error: opts[:error] || m[:error])
659
+ fam_fix = FAMILY_FIXES[fam]
660
+ stored = m[:fix].to_s.strip
661
+ stored = '' if stored.match?(%r{ls/test -e the parent}) && fam != 'missing_path'
662
+ stored = '' if m[:hint_confidence].to_f.negative?
663
+ stored = '' if stored.include?('[UNVERIFIED]')
664
+ hint = stored.empty? ? fam_fix : stored
665
+ parts << "KNOWN FIX: #{hint}" unless hint.to_s.empty?
634
666
  "[pwn-ai/mistakes] #{parts.join(' | ')}"
635
667
  end
636
668
 
669
+ public_class_method def self.note_hint_outcome(opts = {})
670
+ sig = (opts[:signature] || signature(tool: opts[:tool], error: opts[:error])).to_s
671
+ return nil if sig.empty?
672
+
673
+ store = load
674
+ m = store[sig.to_sym]
675
+ return nil unless m
676
+
677
+ m[:hint_outcomes] = m[:hint_outcomes].to_i + 1
678
+ unless opts[:helped]
679
+ m[:hint_misses] = m[:hint_misses].to_i + 1
680
+ if m[:hint_misses].to_i >= 3
681
+ m[:fix] = "[UNVERIFIED] #{m[:fix]}"
682
+ m[:hint_confidence] = -1.0
683
+ end
684
+ end
685
+ store[sig.to_sym] = m
686
+ save(store: store)
687
+ m
688
+ end
689
+
637
690
  # Supported Method Parameters::
638
691
  # bool = PWN::AI::Agent::Mistakes.correction?(request: user_text)
639
692
 
@@ -988,6 +1041,19 @@ module PWN
988
1041
  signature: 'optional - signature value consumed by #effective_count'
989
1042
  )
990
1043
 
1044
+ # Map an error string onto a coarse family (auth vs path vs parse).
1045
+ #{self}.family(
1046
+ error: 'required - raw error text'
1047
+ )
1048
+
1049
+ # Record whether an injected KNOWN FIX helped; demote after 3 misses.
1050
+ #{self}.note_hint_outcome(
1051
+ tool: 'optional - tool name',
1052
+ error: 'optional - raw error text',
1053
+ signature: 'optional - explicit signature',
1054
+ helped: 'optional - true when the hint resolved the failure'
1055
+ )
1056
+
991
1057
  # Print the AUTHOR(S) string for this module.
992
1058
  #{self}.authors
993
1059
  "
@@ -113,6 +113,7 @@ module PWN
113
113
  # )
114
114
 
115
115
  public_class_method def self.definitions(opts = {})
116
+ eager_load!
116
117
  enabled = opts[:enabled]
117
118
  enabled = enabled.map(&:to_s) if enabled
118
119
  pool = @entries.values.select { |e| (enabled.nil? || enabled.include?(e.toolset)) && safe_check(entry: e) }
@@ -296,6 +297,10 @@ module PWN
296
297
  # )
297
298
 
298
299
  public_class_method def self.discover(opts = {})
300
+ eager_load!(opts)
301
+ end
302
+
303
+ public_class_method def self.eager_load!(opts = {})
299
304
  force = opts[:force] ||= false
300
305
  return @entries.keys if @discovered && !force
301
306
 
@@ -311,6 +316,19 @@ module PWN
311
316
  @entries.keys
312
317
  end
313
318
 
319
+ public_class_method def self.selftest(opts = {})
320
+ _dry = opts[:dry_run]
321
+ eager_load!
322
+ tools_dir = File.join(__dir__, 'tools')
323
+ files = Dir[File.join(tools_dir, '*.rb')]
324
+ rows = @entries.map do |name, e|
325
+ schema = e.schema.is_a?(Hash)
326
+ required = Array(e.schema&.dig(:parameters, :required) || e.schema&.dig('parameters', 'required'))
327
+ { name: name, schema_valid: schema, handler: !e.handler.nil?, required: required }
328
+ end
329
+ { tools: rows, files: files.length, registered: @entries.length, ok: rows.length >= files.length }
330
+ end
331
+
314
332
  private_class_method def self.safe_check(opts = {})
315
333
  entry = opts[:entry]
316
334
  entry.check.call
@@ -428,6 +446,16 @@ module PWN
428
446
  force: 'optional - re-require tool files even if already discovered (default false)'
429
447
  )
430
448
 
449
+ # Eager-load every tools/*.rb so Registry.definitions works in a cold REPL.
450
+ #{self}.eager_load!(
451
+ force: 'optional - re-require tool files even if already discovered (default false)'
452
+ )
453
+
454
+ # Schema/handler contract check for every registered tool.
455
+ #{self}.selftest(
456
+ dry_run: 'optional - reserved dry-run flag'
457
+ )
458
+
431
459
  # Print the AUTHOR(S) string for this module.
432
460
  #{self}.authors
433
461
  "
@@ -143,17 +143,21 @@ module PWN
143
143
  # confidence handles that in the bandit blend.
144
144
  eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase
145
145
  local = eng == 'ollama' || eng.empty?
146
- if v[:source].to_s == 'heuristic'
147
- v[:confidence] = local ? 0.35 : 0.5
146
+ if v[:source].to_s == 'heuristic' || v[:source].to_s.start_with?('heuristic')
147
+ toolbacked = Array(trace).any?
148
+ v[:heuristic_class] = toolbacked ? :toolbacked : :textual
149
+ v[:source] = :heuristic
150
+ v[:confidence] = if toolbacked
151
+ local ? 0.55 : 0.7
152
+ else
153
+ local ? 0.35 : 0.5
154
+ end
148
155
  raw = v[:score].to_f
149
156
  v[:score_raw] = raw
150
- # preserve empty / failure-language / polite floors exactly (raw <= 0.15)
151
- # and pass-through non-capped heuristics via the default arm.
152
- if local && raw > 0.15 && trace.empty? && raw >= 0.6 && final.length < 400
157
+ if !toolbacked && local && raw > 0.15 && trace.empty? && raw >= 0.6 && final.length < 400
153
158
  v[:score] = [raw, 0.45].min
154
159
  v[:rationale] = "#{v[:rationale]} | P1:local_no_trace_cap"
155
- elsif local && raw > 0.15 && trace.length < 2 && raw >= 0.85
156
- # thin-evidence local highs: mild shrink toward 0.5
160
+ elsif !toolbacked && local && raw > 0.15 && trace.length < 2 && raw >= 0.85
157
161
  v[:score] = (0.5 + ((raw - 0.5) * 0.7)).round(3).clamp(0.0, 1.0)
158
162
  else
159
163
  v[:score] = raw
@@ -203,10 +207,11 @@ module PWN
203
207
  v[:engine] = eng
204
208
  v[:task_class] = request.match?(/analy[sz]e|summar|strength|weakness|fitness/i) ? 'analysis' : 'operational'
205
209
  if pass
206
- v[:score] = [v[:score].to_f, 0.7].max
207
- v[:score] = [v[:score].to_f, 0.70].min
210
+ score = v[:score].to_f
211
+ v[:score] = [score, 0.6].max
208
212
  v[:success] = true if prec
209
213
  v[:verifier_verdict] ||= :pass
214
+ v[:verdict] = :solved
210
215
  end
211
216
  v[:score_components] ||= {
212
217
  judge: v[:score].to_f,
@@ -1099,7 +1104,7 @@ module PWN
1099
1104
  CHEAP_ORM_TEMP = 0.1
1100
1105
  CHEAP_ORM_TRACE_N = 12
1101
1106
  ORM_SAMPLE_WEIGHT = 1.0
1102
- HEURISTIC_SAMPLE_WEIGHT = 0.25
1107
+ HEURISTIC_SAMPLE_WEIGHT = 0.45
1103
1108
  ERROR_SAMPLE_WEIGHT = 0.15
1104
1109
  ENGINE_CHAT_MODS = {
1105
1110
  openai: 'PWN::AI::OpenAI',
@@ -5,6 +5,9 @@ require 'json'
5
5
  require 'fileutils'
6
6
  require 'securerandom'
7
7
  require 'time'
8
+ require 'openssl'
9
+ require 'uri'
10
+ require 'base64'
8
11
 
9
12
  module PWN
10
13
  module AI
@@ -41,7 +44,12 @@ module PWN
41
44
  end
42
45
 
43
46
  public_class_method def self.placeholder?(opts = {})
44
- PLACEHOLDER_RX.match?(opts[:text].to_s)
47
+ s = opts[:text].to_s.dup
48
+ s.gsub!(/<<[-~]?\s*(['"])(\w+)\1.*?^\2\s*$/m, ' ')
49
+ s.gsub!(/<<[-~]?\s*(\w+).*?^\1\s*$/m, ' ')
50
+ s.gsub!(/'[^']*'/, "''")
51
+ s.gsub!(/"([^"\\]|\\.)*"/, '""')
52
+ PLACEHOLDER_RX.match?(s)
45
53
  rescue StandardError
46
54
  false
47
55
  end
@@ -60,18 +68,36 @@ module PWN
60
68
  end
61
69
 
62
70
  public_class_method def self.mint_canary(opts = {})
63
- n = (opts[:bytes] || 8).to_i
64
- n = 8 if n <= 0
65
- tok = "PWNCANARY#{SecureRandom.hex(n)}"
71
+ _bytes = opts[:bytes]
72
+ turn = (opts[:turn] || Thread.current[:pwn_loop_iter] || 0).to_i
73
+ sid = (opts[:session_id] || Thread.current[:pwn_session_id] || 'sess').to_s
74
+ key = (Thread.current[:pwn_canary_key] ||= SecureRandom.hex(16))
75
+ tok = OpenSSL::HMAC.hexdigest('SHA256', key, "#{sid}:#{turn}")[0, 16]
76
+ hist = Thread.current[:pwn_canary_hist] ||= []
77
+ hist << tok
78
+ hist.shift while hist.length > 4
66
79
  Thread.current[:pwn_canary] = tok
67
80
  tok
68
81
  end
69
82
 
70
83
  public_class_method def self.canary_leak?(opts = {})
71
- tok = Thread.current[:pwn_canary].to_s
72
- return false if tok.empty?
73
-
74
- opts[:text].to_s.include?(tok)
84
+ text = opts[:text].to_s
85
+ toks = Array(Thread.current[:pwn_canary_hist])
86
+ toks << Thread.current[:pwn_canary].to_s
87
+ toks = toks.reject(&:empty?).uniq
88
+ return false if toks.empty?
89
+
90
+ require 'base64'
91
+ toks.any? do |tok|
92
+ next true if text.include?(tok)
93
+
94
+ b64 = Base64.strict_encode64(tok)
95
+ b64u = Base64.urlsafe_encode64(tok)
96
+ hex = tok.each_byte.map { |b| format('%02x', b) }.join
97
+ enc = URI.encode_www_form_component(tok)
98
+ rot = tok.tr('A-Za-z', 'N-ZA-Mn-za-m')
99
+ text.include?(b64) || text.include?(b64u) || text.include?(hex) || text.include?(enc) || text.include?(rot)
100
+ end
75
101
  end
76
102
 
77
103
  public_class_method def self.injection_score(opts = {})
@@ -475,6 +501,16 @@ module PWN
475
501
  nil
476
502
  end
477
503
 
504
+ public_class_method def self.scope_check!(opts = {})
505
+ return nil unless defined?(Engagement)
506
+
507
+ Engagement.deny_if_out_of_scope(
508
+ args: opts[:args],
509
+ command: opts[:command] || opts[:args].inspect,
510
+ text: opts[:text]
511
+ )
512
+ end
513
+
478
514
  public_class_method def self.rfc1918?(opts = {})
479
515
  oct = opts[:ip].to_s.split('.').map(&:to_i)
480
516
  return false unless oct.length == 4
@@ -568,7 +604,9 @@ module PWN
568
604
 
569
605
  # Mint a per-thread session canary token stored on Thread.current.
570
606
  #{self}.mint_canary(
571
- bytes: 'optional - hex length (defaults to 8)'
607
+ bytes: 'optional - unused reserved length (HMAC uses 16 hex chars)',
608
+ turn: 'optional - turn number mixed into the HMAC',
609
+ session_id: 'optional - session id mixed into the HMAC'
572
610
  )
573
611
 
574
612
  # True when text contains the current session canary.
@@ -729,6 +767,13 @@ module PWN
729
767
  args: 'required - Hash of tool arguments'
730
768
  )
731
769
 
770
+ # Block network args outside the active engagement scope.
771
+ #{self}.scope_check!(
772
+ args: 'optional - Hash of tool arguments',
773
+ command: 'optional - command string to scan for hosts',
774
+ text: 'optional - free-form blob to scan'
775
+ )
776
+
732
777
  # Print the AUTHOR(S) string for this module.
733
778
  #{self}.authors
734
779
  "
@@ -45,17 +45,22 @@ PWN::AI::Agent::Registry.register(
45
45
  path: { type: 'string' },
46
46
  offset: { type: 'integer' },
47
47
  length: { type: 'integer' },
48
- mode: { type: 'string', description: 'text|hex|base64' }
48
+ mode: { type: 'string', description: 'text|hex|base64' },
49
+ grep: { type: 'string' },
50
+ ref: { type: 'string' },
51
+ sha256: { type: 'string' }
49
52
  },
50
53
  required: %w[path]
51
54
  }
52
55
  },
53
56
  handler: lambda { |args|
54
57
  PWN::Plugins::ArtifactRegistry.read_page(
55
- path: args[:path] || args['path'],
58
+ path: args[:path] || args['path'] || args[:ref] || args['ref'],
56
59
  offset: args[:offset] || args['offset'],
57
60
  length: args[:length] || args['length'],
58
- mode: args[:mode] || args['mode']
61
+ mode: args[:mode] || args['mode'],
62
+ grep: args[:grep] || args['grep'],
63
+ sha256: args[:sha256] || args['sha256']
59
64
  )
60
65
  }
61
66
  )
@@ -227,6 +227,16 @@ module PWN
227
227
  ledger
228
228
  end
229
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
+
230
240
  public_class_method def self.authors
231
241
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
232
242
  end
@@ -279,7 +289,16 @@ module PWN
279
289
 
280
290
  # Build path → {write,read} ledger from tool messages.
281
291
  #{self}.evidence_ledger(
282
- messages: 'optional - Array of role/content hashes for this turn'
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'
283
302
  )
284
303
 
285
304
  # Print the AUTHOR(S) string for this module.
@@ -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
@@ -50,13 +51,26 @@ module PWN
50
51
  end
51
52
 
52
53
  public_class_method def self.read_page(opts = {})
53
- path = opts[:path].to_s
54
+ path = (opts[:path] || opts[:ref]).to_s
54
55
  raise 'ERROR: path is required' if path.empty?
55
56
  raise "ERROR: file not found: #{path}" unless File.file?(path)
56
57
 
58
+ raise 'ERROR: sha256 mismatch' if opts[:sha256].to_s != '' && Digest::SHA256.file(path).hexdigest != opts[:sha256].to_s
59
+
60
+ grep = opts[:grep].to_s
61
+ unless grep.empty?
62
+ rx = Regexp.new(grep, Regexp::IGNORECASE)
63
+ hits = []
64
+ File.foreach(path).with_index(1) do |ln, i|
65
+ hits << { line: i, text: ln.chomp } if ln.match?(rx)
66
+ break if hits.length >= 50
67
+ end
68
+ return { path: path, grep: grep, matches: hits }
69
+ end
70
+
57
71
  offset = opts[:offset].to_i
58
- length = (opts[:length] || 4_096).to_i
59
- length = 4_096 if length <= 0
72
+ length = (opts[:length] || 16_384).to_i
73
+ length = 16_384 if length <= 0
60
74
  mode = (opts[:mode] || 'text').to_s
61
75
  data = File.binread(path, length, offset).to_s
62
76
  case mode
@@ -70,6 +84,26 @@ module PWN
70
84
  end
71
85
  end
72
86
 
87
+ public_class_method def self.put(opts = {})
88
+ bytes = opts[:bytes]
89
+ src = opts[:path].to_s
90
+ bytes = File.binread(src) if bytes.nil? && File.file?(src)
91
+ raise 'ERROR: bytes or path is required' if bytes.nil?
92
+
93
+ sha = Digest::SHA256.hexdigest(bytes)
94
+ dir = File.join(ROOT, 'sha256', sha[0, 2])
95
+ FileUtils.mkdir_p(dir)
96
+ dest = File.join(dir, sha)
97
+ File.binwrite(dest, bytes) unless File.file?(dest)
98
+ meta = File.join(ROOT, 'manifest.jsonl')
99
+ FileUtils.mkdir_p(ROOT)
100
+ File.open(meta, 'a') do |f|
101
+ f.flock(File::LOCK_EX)
102
+ 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))
103
+ end
104
+ { sha256: sha, path: dest, size: bytes.bytesize }
105
+ end
106
+
73
107
  public_class_method def self.authors
74
108
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
75
109
  end
@@ -81,14 +115,14 @@ module PWN
81
115
 
82
116
  # Run register and return its result
83
117
  #{self}.register(
84
- session_id: 'optional - session id value consumed by #register',
118
+ session_id: 'optional - pwn-ai session id that produced this artifact',
85
119
  path: 'required - filesystem path to read or write',
86
120
  kind: 'optional - kind value consumed by #register'
87
121
  )
88
122
 
89
123
  # Run list and return its result
90
124
  #{self}.list(
91
- session_id: 'optional - session id value consumed by #list'
125
+ session_id: 'optional - pwn-ai session id whose artifacts to list'
92
126
  )
93
127
 
94
128
  # Read an artifact file and return sha256 plus a body cap.
@@ -96,12 +130,24 @@ module PWN
96
130
  path: 'required - filesystem path of a registered artifact'
97
131
  )
98
132
 
99
- # Page an artifact as text, hex, or base64 from an offset.
133
+ # Store bytes under artifacts/sha256/<h2>/<hash> and append manifest.jsonl.
134
+ #{self}.put(
135
+ bytes: 'optional - raw bytes to store',
136
+ path: 'optional - filesystem path to read when bytes is omitted',
137
+ tool: 'optional - tool name for provenance',
138
+ session_id: 'optional - pwn-ai session id for provenance',
139
+ kind: 'optional - artifact kind'
140
+ )
141
+
142
+ # Page an artifact; grep: searches lines, ref: alias for path.
100
143
  #{self}.read_page(
101
144
  path: 'required - filesystem path of the artifact',
145
+ ref: 'optional - alias for path',
102
146
  offset: 'optional - byte offset (defaults to 0)',
103
- length: 'optional - byte count (defaults to 4096)',
104
- mode: 'optional - text, hex, or base64 (defaults to text)'
147
+ length: 'optional - byte count (defaults to 16384)',
148
+ mode: 'optional - text, hex, or base64 (defaults to text)',
149
+ grep: 'optional - regex to search instead of paging',
150
+ sha256: 'optional - expected sha256 of the file'
105
151
  )
106
152
 
107
153
  # Print the AUTHOR(S) string for this module.
@@ -120,7 +120,10 @@ module PWN
120
120
  end
121
121
  body = summary.merge(path: path, sha256: sha)
122
122
  File.write(art, JSON.pretty_generate(body))
123
- body.merge(artifact: art, cached: false)
123
+ facts_dir = File.join(Dir.home, '.pwn', 'engagements', 'default', 'binaries', sha)
124
+ FileUtils.mkdir_p(facts_dir)
125
+ 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'))
124
127
  end
125
128
 
126
129
  public_class_method def self.diff(opts = {})
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'open3'
4
+ require 'fileutils'
5
+ require 'json'
4
6
 
5
7
  module PWN
6
8
  module Plugins
@@ -171,6 +173,39 @@ module PWN
171
173
  end
172
174
  end
173
175
 
176
+ public_class_method def self.scaffold(opts = {})
177
+ path = (opts[:path] || opts[:bin]).to_s
178
+ raise 'ERROR: path is required' if path.empty?
179
+
180
+ triage = {}
181
+ triage = PWN::Plugins::BinaryParser.triage(path: path) if defined?(PWN::Plugins::BinaryParser)
182
+ gadgets = []
183
+ gadgets = Array(self.gadgets(path: path)[:gadgets]) if File.file?(path)
184
+ body = <<~RB
185
+ # frozen_string_literal: true
186
+ # scaffold for #{path}
187
+ # arch=#{triage[:arch]} pie=#{triage.dig(:protections, :pie)} canary=#{triage.dig(:protections, :canary)}
188
+ require 'pwn'
189
+ bin = #{path.inspect}
190
+ cyclic = PWN::Plugins::ExploitDev.cyclic(length: 200)
191
+ io = PWN::Plugins::ExploitDev.io(cmd: bin)
192
+ PWN::Plugins::ProcessTube.write_line(id: io[:id], line: cyclic)
193
+ RB
194
+ out = opts[:out].to_s
195
+ out = File.join(Dir.home, '.pwn', 'artifacts', 'scaffolds', "#{File.basename(path)}.rb") if out.empty?
196
+ FileUtils.mkdir_p(File.dirname(out))
197
+ File.write(out, body)
198
+ { path: out, triage: triage, gadgets: gadgets.first(8) }
199
+ end
200
+
201
+ public_class_method def self.from_crash(opts = {})
202
+ path = (opts[:path] || opts[:from_crash]).to_s
203
+ raise 'ERROR: path is required' if path.empty?
204
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
205
+
206
+ JSON.parse(File.read(path), symbolize_names: true)
207
+ end
208
+
174
209
  public_class_method def self.authors
175
210
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
176
211
  end
@@ -294,6 +329,13 @@ module PWN
294
329
  port: 'optional - remote TCP port'
295
330
  )
296
331
 
332
+ # Emit a ruby exploit scaffold bound to triage output.
333
+ #{self}.scaffold(
334
+ path: 'required - binary path to scaffold against',
335
+ bin: 'optional - alias for path',
336
+ out: 'optional - output .rb path (defaults under ~/.pwn/artifacts/scaffolds)'
337
+ )
338
+
297
339
  # Run gadgets and return its result
298
340
  #{self}.gadgets(
299
341
  path: 'required - filesystem path of the binary to search for gadgets'
@@ -311,6 +353,12 @@ module PWN
311
353
  names: 'optional - Array of symbol names (defaults to system, execve, __libc_start_main)'
312
354
  )
313
355
 
356
+ # Load a crash.json produced by a fuzz campaign.
357
+ #{self}.from_crash(
358
+ path: 'required - filesystem path of crash.json',
359
+ from_crash: 'optional - alias for path'
360
+ )
361
+
314
362
  # Print the AUTHOR(S) string for this module.
315
363
  #{self}.authors
316
364
  "
@@ -23,6 +23,9 @@ module PWN
23
23
  arts << opts[:poc].to_s unless opts[:poc].to_s.empty?
24
24
  raise 'ERROR: poc_artifacts are required' if arts.empty?
25
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
+
26
29
  row = {
27
30
  id: SecureRandom.hex(6),
28
31
  title: title,
@@ -83,7 +86,12 @@ module PWN
83
86
  parent_id = opts[:parent_id].to_s
84
87
  raise 'ERROR: parent_id is required' if parent_id.empty?
85
88
 
86
- 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
+ )
87
95
  ranks = { 'info' => 0, 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4 }
88
96
  parent = report.find { |r| r[:id].to_s == parent_id }
89
97
  sev = [parent&.[](:severity), child[:severity]].compact.max_by { |s| ranks[s.to_s] || 0 }
@@ -90,6 +90,18 @@ module PWN
90
90
  crash_info(opts.merge(commands: script))
91
91
  end
92
92
 
93
+ public_class_method def self.ptrace_preflight(opts = {})
94
+ _target = opts[:target]
95
+ scope = 0
96
+ path = '/proc/sys/kernel/yama/ptrace_scope'
97
+ scope = File.read(path).to_i if File.file?(path)
98
+ {
99
+ ptrace_scope: scope,
100
+ attach_ok: scope.zero?,
101
+ hint: scope.positive? ? 'spawn instead of attach' : 'ok'
102
+ }
103
+ end
104
+
93
105
  public_class_method def self.authors
94
106
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
95
107
  end
@@ -147,6 +159,11 @@ module PWN
147
159
  script: 'optional - Array of gdb/MI commands'
148
160
  )
149
161
 
162
+ # Probe yama ptrace_scope before gdb/frida attach.
163
+ #{self}.ptrace_preflight(
164
+ target: 'optional - pid or binary path being attached'
165
+ )
166
+
150
167
  # Print the AUTHOR(S) string for this module.
151
168
  #{self}.authors
152
169
  "
@@ -3,6 +3,7 @@
3
3
  require 'open3'
4
4
  require 'fileutils'
5
5
  require 'json'
6
+ require 'digest'
6
7
 
7
8
  module PWN
8
9
  module Plugins
@@ -31,12 +32,28 @@ module PWN
31
32
  end
32
33
 
33
34
  public_class_method def self.decompile(opts = {})
34
- analyze(opts)
35
+ path = (opts[:bin] || opts[:path]).to_s
36
+ raise 'ERROR: bin is required' if path.empty?
37
+
38
+ sha = Digest::SHA256.file(path).hexdigest if File.file?(path)
39
+ cache = File.join(Dir.home, '.pwn', 'cache', 'decompile', "#{sha}.json") if sha
40
+ return JSON.parse(File.read(cache), symbolize_names: true).merge(cached: true) if cache && File.file?(cache)
41
+
42
+ row = analyze(opts)
43
+ fn = opts[:function].to_s
44
+ row[:function] = fn unless fn.empty?
45
+ FileUtils.mkdir_p(File.dirname(cache)) if cache
46
+ File.write(cache, JSON.generate(row)) if cache
47
+ row
35
48
  end
36
49
 
37
50
  private_class_method def self.r2_fallback(opts = {})
38
51
  path = opts[:path].to_s
39
52
  stdout, stderr, status = Open3.capture3('r2', '-q', '-c', 'aaa;s main;pdg', path)
53
+ if stdout.to_s.strip.empty?
54
+ stdout, stderr, status = Open3.capture3('r2', '-q', '-c', 'aaa;s main;pdc', path)
55
+ return { stdout: stdout, stderr: stderr, exit: status.exitstatus, engine: 'r2-pdc' }
56
+ end
40
57
  { stdout: stdout, stderr: stderr, exit: status.exitstatus, engine: 'r2' }
41
58
  rescue StandardError => e
42
59
  { error: e.message, engine: 'r2' }
@@ -62,7 +79,8 @@ module PWN
62
79
  #{self}.decompile(
63
80
  bin: 'required - filesystem path of the binary',
64
81
  path: 'optional - alias for bin',
65
- project_dir: 'optional - Ghidra project directory'
82
+ project_dir: 'optional - Ghidra project directory',
83
+ function: 'optional - function name to decompile instead of the whole program'
66
84
  )
67
85
 
68
86
  # Print the AUTHOR(S) string for this module.