pwn 0.5.721 → 0.5.723

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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/bin/pwn_setup +5 -5
  4. data/documentation/AI-Integration.md +34 -1
  5. data/documentation/Policy-Benchmark.md +151 -0
  6. data/documentation/Reinforcement-Learning.md +27 -1
  7. data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +1 -0
  8. data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +2 -0
  9. data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +4 -0
  10. data/etc/default_skills/pwn/ai/agent/mistakes/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/ai/agent/policy/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/ai/agent/reward/SKILL.md +2 -0
  13. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +6 -0
  14. data/etc/default_skills/pwn/ai/agent/tools/capabilities/SKILL.md +45 -0
  15. data/etc/default_skills/pwn/ai/agent/tools/context/SKILL.md +45 -0
  16. data/etc/default_skills/pwn/ai/context/SKILL.md +50 -0
  17. data/etc/default_skills/pwn/ai/http_retry/SKILL.md +7 -0
  18. data/etc/default_skills/pwn/ai/http_retry/references/urls.md +4 -0
  19. data/etc/default_skills/pwn/ai/open_ai/SKILL.md +1 -0
  20. data/etc/default_skills/pwn/ai/open_ai/references/urls.md +1 -0
  21. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
  22. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
  24. data/etc/default_skills/pwn/plugins/gdbmi/SKILL.md +55 -0
  25. data/etc/default_skills/pwn/plugins/ghidra_headless/SKILL.md +49 -0
  26. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +6 -0
  27. data/etc/default_skills/pwn/plugins/packet/SKILL.md +3 -0
  28. data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
  29. data/etc/default_skills/pwn/plugins/radare2/SKILL.md +1 -0
  30. data/etc/default_skills/pwn/plugins/transparent_browser/SKILL.md +3 -0
  31. data/etc/default_skills/pwn/reports/engagement/SKILL.md +3 -2
  32. data/lib/pwn/ai/agent/curriculum.rb +37 -45
  33. data/lib/pwn/ai/agent/dispatch.rb +9 -0
  34. data/lib/pwn/ai/agent/engagement.rb +59 -0
  35. data/lib/pwn/ai/agent/learning.rb +111 -44
  36. data/lib/pwn/ai/agent/loop.rb +50 -8
  37. data/lib/pwn/ai/agent/metrics.rb +70 -2
  38. data/lib/pwn/ai/agent/mistakes.rb +67 -1
  39. data/lib/pwn/ai/agent/policy.rb +142 -16
  40. data/lib/pwn/ai/agent/registry.rb +1 -1
  41. data/lib/pwn/ai/agent/reward.rb +153 -53
  42. data/lib/pwn/ai/agent/swarm.rb +235 -35
  43. data/lib/pwn/ai/agent/tool_guard.rb +13 -1
  44. data/lib/pwn/ai/agent/tools/artifacts.rb +50 -3
  45. data/lib/pwn/ai/agent/tools/capabilities.rb +19 -0
  46. data/lib/pwn/ai/agent/tools/context.rb +38 -0
  47. data/lib/pwn/ai/agent/tools/finding_record.rb +18 -0
  48. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +10 -1
  49. data/lib/pwn/ai/agent/tools/job_run.rb +32 -0
  50. data/lib/pwn/ai/agent/tools/learning.rb +5 -6
  51. data/lib/pwn/ai/agent/tools/metrics.rb +16 -0
  52. data/lib/pwn/ai/agent/tools/pty_session.rb +4 -4
  53. data/lib/pwn/ai/agent/tools/ruby_eval.rb +6 -5
  54. data/lib/pwn/ai/agent/tools/shell.rb +10 -1
  55. data/lib/pwn/ai/agent/tools/skills.rb +30 -0
  56. data/lib/pwn/ai/agent/tools/swarm.rb +8 -2
  57. data/lib/pwn/ai/context.rb +193 -0
  58. data/lib/pwn/ai/http_retry.rb +53 -7
  59. data/lib/pwn/ai/open_ai.rb +302 -45
  60. data/lib/pwn/ai.rb +1 -0
  61. data/lib/pwn/migrate.rb +10 -1
  62. data/lib/pwn/plugins/artifact_registry.rb +40 -17
  63. data/lib/pwn/plugins/binary_parser.rb +4 -1
  64. data/lib/pwn/plugins/exploit_dev.rb +15 -0
  65. data/lib/pwn/plugins/findings.rb +48 -8
  66. data/lib/pwn/plugins/gdb.rb +17 -0
  67. data/lib/pwn/plugins/gdbmi.rb +128 -0
  68. data/lib/pwn/plugins/ghidra_headless.rb +104 -0
  69. data/lib/pwn/plugins/jobs.rb +72 -0
  70. data/lib/pwn/plugins/packet.rb +51 -0
  71. data/lib/pwn/plugins/preflight_checker.rb +29 -0
  72. data/lib/pwn/plugins/process_tube.rb +24 -7
  73. data/lib/pwn/plugins/radare2.rb +14 -2
  74. data/lib/pwn/plugins/repl.rb +6 -1
  75. data/lib/pwn/plugins/transparent_browser.rb +64 -0
  76. data/lib/pwn/plugins.rb +2 -0
  77. data/lib/pwn/reports/engagement.rb +19 -0
  78. data/lib/pwn/sessions.rb +3 -1
  79. data/lib/pwn/version.rb +1 -1
  80. data/scripts/benchmark_policy.rb +276 -0
  81. data/spec/documentation/installation_md_spec.rb +18 -4
  82. data/spec/integration/reinforced_feedback_loop_spec.rb +20 -13
  83. data/spec/lib/pwn/ai/agent/curriculum_spec.rb +267 -0
  84. data/spec/lib/pwn/ai/agent/engagement_spec.rb +12 -0
  85. data/spec/lib/pwn/ai/agent/learning_spec.rb +81 -3
  86. data/spec/lib/pwn/ai/agent/loop_spec.rb +51 -0
  87. data/spec/lib/pwn/ai/agent/metrics_spec.rb +44 -0
  88. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +10 -0
  89. data/spec/lib/pwn/ai/agent/policy_spec.rb +103 -0
  90. data/spec/lib/pwn/ai/agent/registry_spec.rb +62 -0
  91. data/spec/lib/pwn/ai/agent/reward_spec.rb +175 -12
  92. data/spec/lib/pwn/ai/agent/swarm_spec.rb +121 -1
  93. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +6 -0
  94. data/spec/lib/pwn/ai/agent/tools/capabilities_spec.rb +14 -0
  95. data/spec/lib/pwn/ai/agent/tools/context_spec.rb +14 -0
  96. data/spec/lib/pwn/ai/agent/tools/job_run_spec.rb +2 -0
  97. data/spec/lib/pwn/ai/agent/tools/learning_spec.rb +25 -0
  98. data/spec/lib/pwn/ai/context_spec.rb +48 -0
  99. data/spec/lib/pwn/ai/http_retry_spec.rb +27 -0
  100. data/spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb +245 -0
  101. data/spec/lib/pwn/ai/open_ai_spec.rb +150 -0
  102. data/spec/lib/pwn/migrate_spec.rb +24 -0
  103. data/spec/lib/pwn/plugins/artifact_registry_spec.rb +19 -0
  104. data/spec/lib/pwn/plugins/findings_spec.rb +2 -0
  105. data/spec/lib/pwn/plugins/gdbmi_spec.rb +17 -0
  106. data/spec/lib/pwn/plugins/ghidra_headless_spec.rb +17 -0
  107. data/third_party/pwn_rdoc.jsonl +86 -2
  108. metadata +24 -5
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'open3'
4
4
  require 'fileutils'
5
+ require 'json'
5
6
 
6
7
  module PWN
7
8
  module Plugins
@@ -197,6 +198,14 @@ module PWN
197
198
  { path: out, triage: triage, gadgets: gadgets.first(8) }
198
199
  end
199
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
+
200
209
  public_class_method def self.authors
201
210
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
202
211
  end
@@ -344,6 +353,12 @@ module PWN
344
353
  names: 'optional - Array of symbol names (defaults to system, execve, __libc_start_main)'
345
354
  )
346
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
+
347
362
  # Print the AUTHOR(S) string for this module.
348
363
  #{self}.authors
349
364
  "
@@ -20,22 +20,37 @@ module PWN
20
20
  raise 'ERROR: title is required' if title.empty?
21
21
 
22
22
  arts = Array(opts[:poc_artifacts]).map(&:to_s).reject(&:empty?)
23
- arts << opts[:poc].to_s unless opts[:poc].to_s.empty?
23
+ if opts[:poc].is_a?(Hash)
24
+ arts << opts[:poc][:path].to_s unless opts[:poc][:path].to_s.empty?
25
+ else
26
+ arts << opts[:poc].to_s unless opts[:poc].to_s.empty?
27
+ end
24
28
  raise 'ERROR: poc_artifacts are required' if arts.empty?
25
29
 
26
- ev = opts[:evidence].to_s
27
- raise 'ERROR: evidence must be at least 40 characters citing the PoC' if ev.length < 40
30
+ ev = opts[:evidence]
31
+ ev_list = Array(ev).map(&:to_s)
32
+ ev_text = ev_list.join(' ')
33
+ raise 'ERROR: evidence must be at least 40 characters citing the PoC' if ev_text.length < 40
34
+
35
+ sha_ev = arts.filter_map do |p|
36
+ next unless File.file?(p)
28
37
 
38
+ Digest::SHA256.file(p).hexdigest
39
+ end
40
+ proven = sha_ev.any?
29
41
  row = {
30
42
  id: SecureRandom.hex(6),
31
43
  title: title,
32
- severity: (opts[:severity] || 'info').to_s,
44
+ severity: proven ? (opts[:severity] || 'info').to_s : 'unproven',
45
+ cvss_vector: (opts[:cvss_vector] || opts[:cvss]).to_s,
46
+ affected_asset: (opts[:affected_asset] || opts[:host]).to_s,
33
47
  host: opts[:host].to_s,
34
- evidence: opts[:evidence].to_s,
35
- poc: opts[:poc].to_s,
48
+ evidence: sha_ev.any? ? sha_ev : ev_list,
49
+ poc: opts[:poc].is_a?(Hash) ? opts[:poc] : { type: 'file', path: arts.first, reproduction_steps: opts[:reproduction_steps].to_s },
36
50
  poc_artifacts: arts,
51
+ chain_refs: Array(opts[:chain_refs] || opts[:chain_parent_id]).map(&:to_s).reject(&:empty?),
37
52
  cvss: opts[:cvss].to_s,
38
- status: (opts[:status] || 'open').to_s,
53
+ status: proven ? (opts[:status] || 'open').to_s : 'unproven',
39
54
  engagement_id: opts[:engagement_id].to_s,
40
55
  chain_parent_id: opts[:chain_parent_id].to_s,
41
56
  session_id: opts[:session_id].to_s,
@@ -98,6 +113,21 @@ module PWN
98
113
  child.merge(composite_severity: sev)
99
114
  end
100
115
 
116
+ public_class_method def self.chain_score(opts = {})
117
+ ids = Array(opts[:ids] || opts[:chain_refs]).map(&:to_s)
118
+ rows = report.select { |r| ids.include?(r[:id].to_s) || ids.include?(r[:chain_parent_id].to_s) }
119
+ rows = report if rows.empty? && ids.empty?
120
+ ranks = { 'info' => 0, 'low' => 1, 'medium' => 2, 'high' => 3, 'critical' => 4, 'unproven' => 0 }
121
+ peak = rows.map { |r| ranks[r[:severity].to_s] || 0 }.max || 0
122
+ linked = rows.length >= 2
123
+ sev = if linked && peak >= 2
124
+ 'critical'
125
+ else
126
+ %w[info low medium high critical][peak] || 'info'
127
+ end
128
+ { chain_refs: rows.map { |r| r[:id] }, score: sev, combined_severity: sev, n: rows.length }
129
+ end
130
+
101
131
  public_class_method def self.render(opts = {})
102
132
  dir = opts[:dir_path].to_s
103
133
  dir = File.join(Dir.home, '.pwn', 'exports') if dir.empty?
@@ -148,9 +178,13 @@ module PWN
148
178
  severity: 'optional - info|low|medium|high|critical (defaults to info)',
149
179
  host: 'optional - affected host or URL',
150
180
  evidence: 'optional - proof text or path',
151
- poc: 'optional - filesystem path of a PoC',
181
+ poc: 'optional - filesystem path of a PoC or Hash with type/path/reproduction_steps',
152
182
  poc_artifacts: 'required - Array of artifact paths proving the issue',
153
183
  cvss: 'optional - CVSS vector or score string',
184
+ cvss_vector: 'optional - CVSS 3.1 vector string (defaults to cvss)',
185
+ affected_asset: 'optional - host or URL the finding applies to (defaults to host)',
186
+ reproduction_steps: 'optional - how to replay the PoC',
187
+ chain_refs: 'optional - Array of related finding ids',
154
188
  status: 'optional - open|closed (defaults to open)',
155
189
  engagement_id: 'optional - engagement identifier',
156
190
  session_id: 'optional - pwn-ai session id',
@@ -189,6 +223,12 @@ module PWN
189
223
  name: 'optional - alias for engagement_id'
190
224
  )
191
225
 
226
+ # Recompute combined severity for chained findings.
227
+ #{self}.chain_score(
228
+ ids: 'optional - Array of finding ids to score together',
229
+ chain_refs: 'optional - alias for ids'
230
+ )
231
+
192
232
  # Print the AUTHOR(S) string for this module.
193
233
  #{self}.authors
194
234
  "
@@ -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
  "
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module PWN
6
+ module Plugins
7
+ # GDB machine-interface bridge: breakpoints, stepping, registers,
8
+ # memory, backtraces, checksec. Pairs with ProcessTube for interactive
9
+ # sessions.
10
+ module GDBMI
11
+ public_class_method def self.required_bins
12
+ %w[gdb]
13
+ end
14
+
15
+ public_class_method def self.open(opts = {})
16
+ binary = opts[:binary].to_s
17
+ PWN::Plugins::ProcessTube.spawn(cmd: ['gdb', '--interpreter=mi2', '--quiet', binary].reject(&:empty?))
18
+ end
19
+
20
+ public_class_method def self.break(opts = {})
21
+ loc = (opts[:location] || opts[:addr] || opts[:symbol]).to_s
22
+ raise 'ERROR: location is required' if loc.empty?
23
+
24
+ mi(opts.merge(cmd: "-break-insert #{loc}"))
25
+ end
26
+
27
+ public_class_method def self.step(opts = {})
28
+ mi(opts.merge(cmd: (opts[:into] ? '-exec-step' : '-exec-next')))
29
+ end
30
+
31
+ public_class_method def self.registers(opts = {})
32
+ mi(opts.merge(cmd: '-data-list-register-values x'))
33
+ end
34
+
35
+ public_class_method def self.read_memory(opts = {})
36
+ addr = (opts[:addr] || opts[:address]).to_s
37
+ raise 'ERROR: addr is required' if addr.empty?
38
+
39
+ n = (opts[:length] || 64).to_i
40
+ mi(opts.merge(cmd: "-data-read-memory-bytes #{addr} #{n}"))
41
+ end
42
+
43
+ public_class_method def self.backtrace(opts = {})
44
+ mi(opts.merge(cmd: '-stack-list-frames'))
45
+ end
46
+
47
+ public_class_method def self.checksec(opts = {})
48
+ PWN::Plugins::GDB.mitigations(opts)
49
+ end
50
+
51
+ public_class_method def self.mi(opts = {})
52
+ cmd = opts[:cmd].to_s
53
+ raise 'ERROR: cmd is required' if cmd.empty?
54
+
55
+ if opts[:id]
56
+ PWN::Plugins::ProcessTube.write_line(id: opts[:id], line: cmd)
57
+ PWN::Plugins::ProcessTube.recvuntil(id: opts[:id], until: "\n", timeout: opts[:timeout] || 5)
58
+ else
59
+ PWN::Plugins::GDB.batch(opts.merge(commands: [cmd.sub(/\A-/, '')]))
60
+ end
61
+ end
62
+
63
+ public_class_method def self.authors
64
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
65
+ end
66
+
67
+ public_class_method def self.help
68
+ puts "USAGE:
69
+ # List host binaries this module expects to be installed.
70
+ #{self}.required_bins
71
+
72
+ # Open gdb --interpreter=mi2 via ProcessTube.
73
+ #{self}.open(
74
+ binary: 'optional - filesystem path of the binary to debug'
75
+ )
76
+
77
+ # Insert a breakpoint (MI -break-insert).
78
+ #{self}.break(
79
+ location: 'required - symbol or address',
80
+ addr: 'optional - alias for location',
81
+ symbol: 'optional - alias for location',
82
+ id: 'optional - ProcessTube id from #open'
83
+ )
84
+
85
+ # Step (into when into: true, otherwise next).
86
+ #{self}.step(
87
+ into: 'optional - true to step into (defaults to next)',
88
+ id: 'optional - ProcessTube id from #open'
89
+ )
90
+
91
+ # Read registers via MI.
92
+ #{self}.registers(
93
+ id: 'optional - ProcessTube id from #open'
94
+ )
95
+
96
+ # Read memory bytes at addr.
97
+ #{self}.read_memory(
98
+ addr: 'required - address to read',
99
+ address: 'optional - alias for addr',
100
+ length: 'optional - byte count (defaults to 64)',
101
+ id: 'optional - ProcessTube id from #open'
102
+ )
103
+
104
+ # Backtrace via MI -stack-list-frames.
105
+ #{self}.backtrace(
106
+ id: 'optional - ProcessTube id from #open'
107
+ )
108
+
109
+ # checksec / mitigations via GDB.mitigations.
110
+ #{self}.checksec(
111
+ binary: 'optional - filesystem path of the binary'
112
+ )
113
+
114
+ # Send a raw MI command.
115
+ #{self}.mi(
116
+ cmd: 'required - MI command string',
117
+ id: 'optional - ProcessTube id from #open',
118
+ timeout: 'optional - seconds to wait for a reply'
119
+ )
120
+
121
+ # Print the AUTHOR(S) string for this module.
122
+ #{self}.authors
123
+ "
124
+ constants.sort
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'digest'
6
+ require 'open3'
7
+ require 'tmpdir'
8
+
9
+ module PWN
10
+ module Plugins
11
+ # analyzeHeadless wrapper that exports decompiled C plus the symbol
12
+ # table as JSON, cached by binary SHA-256.
13
+ module GhidraHeadless
14
+ public_class_method def self.required_bins
15
+ %w[analyzeHeadless]
16
+ end
17
+
18
+ public_class_method def self.analyze(opts = {})
19
+ path = (opts[:bin] || opts[:path]).to_s
20
+ raise 'ERROR: bin is required' if path.empty?
21
+ raise "ERROR: file not found: #{path}" unless File.file?(path)
22
+
23
+ sha = Digest::SHA256.file(path).hexdigest
24
+ cache = File.join(Dir.home, '.pwn', 'cache', 'ghidra_headless', "#{sha}.json")
25
+ return JSON.parse(File.read(cache), symbolize_names: true).merge(cached: true) if File.file?(cache)
26
+
27
+ row = if PWN::Plugins::PreflightChecker.bin?(name: 'analyzeHeadless')
28
+ run_headless(path: path, sha: sha, function: opts[:function])
29
+ else
30
+ PWN::Plugins::Ghidra.decompile(opts.merge(bin: path))
31
+ end
32
+ FileUtils.mkdir_p(File.dirname(cache))
33
+ File.write(cache, JSON.generate(row))
34
+ row.merge(sha256: sha, cached: false)
35
+ end
36
+
37
+ public_class_method def self.decompile(opts = {})
38
+ analyze(opts)
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
+ # List host binaries this module expects to be installed.
48
+ #{self}.required_bins
49
+
50
+ # Run analyzeHeadless and cache decompiled C + symbols as JSON.
51
+ #{self}.analyze(
52
+ bin: 'required - filesystem path of the binary',
53
+ path: 'optional - alias for bin',
54
+ function: 'optional - function name to decompile'
55
+ )
56
+
57
+ # Alias of #analyze.
58
+ #{self}.decompile(
59
+ bin: 'required - filesystem path of the binary',
60
+ path: 'optional - alias for bin',
61
+ function: 'optional - function name to decompile'
62
+ )
63
+
64
+ # Print the AUTHOR(S) string for this module.
65
+ #{self}.authors
66
+ "
67
+ constants.sort
68
+ end
69
+
70
+ private_class_method def self.run_headless(opts = {})
71
+ path = opts[:path].to_s
72
+ Dir.mktmpdir('pwn-ghidra') do |dir|
73
+ script = File.join(dir, 'export.py')
74
+ out = File.join(dir, 'export.json')
75
+ File.write(script, <<~PY)
76
+ # @runtime PyGhidra
77
+ import json, os
78
+ from ghidra.app.decompiler import DecompInterface
79
+ from ghidra.util.task import ConsoleTaskMonitor
80
+ decomp = DecompInterface()
81
+ decomp.openProgram(currentProgram)
82
+ mon = ConsoleTaskMonitor()
83
+ fns = []
84
+ fm = currentProgram.getFunctionManager()
85
+ it = fm.getFunctions(True)
86
+ while it.hasNext():
87
+ fn = it.next()
88
+ res = decomp.decompileFunction(fn, 30, mon)
89
+ c = res.getDecompiledFunction().getC() if res and res.getDecompiledFunction() else ''
90
+ fns.append({'name': fn.getName(), 'entry': str(fn.getEntryPoint()), 'c': c})
91
+ open(#{out.inspect}, 'w').write(json.dumps({'functions': fns, 'symbols': [s.getName() for s in currentProgram.getSymbolTable().getAllSymbols(True)]}))
92
+ PY
93
+ stdout, stderr, status = Open3.capture3(
94
+ 'analyzeHeadless', dir, 'pwn', '-import', path, '-postScript', script, '-deleteProject'
95
+ )
96
+ payload = File.file?(out) ? JSON.parse(File.read(out), symbolize_names: true) : {}
97
+ { stdout: stdout, stderr: stderr, exit: status.exitstatus, engine: 'ghidra', decompiled: payload, sha256: opts[:sha] }
98
+ end
99
+ rescue StandardError => e
100
+ { error: e.message, engine: 'ghidra', sha256: opts[:sha] }
101
+ end
102
+ end
103
+ end
104
+ end
@@ -40,6 +40,39 @@ module PWN
40
40
  row
41
41
  end
42
42
 
43
+ public_class_method def self.run(opts = {})
44
+ start(opts)
45
+ end
46
+
47
+ public_class_method def self.job_run(opts = {})
48
+ start(opts)
49
+ end
50
+
51
+ public_class_method def self.job_status(opts = {})
52
+ status(id: opts[:handle] || opts[:id])
53
+ end
54
+
55
+ public_class_method def self.job_tail(opts = {})
56
+ tail(id: opts[:handle] || opts[:id], lines: opts[:lines])
57
+ end
58
+
59
+ public_class_method def self.job_kill(opts = {})
60
+ stop(id: opts[:handle] || opts[:id])
61
+ end
62
+
63
+ public_class_method def self.watch(opts = {})
64
+ row = load_job(opts)
65
+ pattern = opts[:pattern].to_s
66
+ raise 'ERROR: pattern is required' if pattern.empty?
67
+
68
+ log = row[:log].to_s
69
+ return { id: row[:id], hits: [] } unless File.file?(log)
70
+
71
+ rx = Regexp.new(pattern)
72
+ hits = File.readlines(log).grep(rx).last(20)
73
+ { id: row[:id], hits: hits.map(&:chomp), pattern: pattern }
74
+ end
75
+
43
76
  public_class_method def self.status(opts = {})
44
77
  row = load_job(opts)
45
78
  alive = begin
@@ -144,6 +177,45 @@ module PWN
144
177
  # Run stop and return its result
145
178
  #{self}.stop
146
179
 
180
+ # Tail a job log for a regex (AFL new-crash lines).
181
+ #{self}.watch(
182
+ id: 'required - job id from #start',
183
+ pattern: 'required - regex to match in the job log'
184
+ )
185
+
186
+ # Alias of start that returns a job handle immediately.
187
+ #{self}.run(
188
+ command: 'required - command string to run',
189
+ cmd: 'optional - alias for command',
190
+ session_id: 'optional - pwn-ai session id',
191
+ max_runtime: 'optional - seconds after which a live job is killed'
192
+ )
193
+
194
+ # Alias of #run.
195
+ #{self}.job_run(
196
+ command: 'required - command string to run',
197
+ cmd: 'optional - alias for command'
198
+ )
199
+
200
+ # Status by handle or id.
201
+ #{self}.job_status(
202
+ handle: 'optional - job handle from #run',
203
+ id: 'optional - job id alias for handle'
204
+ )
205
+
206
+ # Tail a job log by handle.
207
+ #{self}.job_tail(
208
+ handle: 'optional - job handle from #run',
209
+ id: 'optional - job id alias for handle',
210
+ lines: 'optional - number of trailing lines'
211
+ )
212
+
213
+ # Kill a job by handle.
214
+ #{self}.job_kill(
215
+ handle: 'optional - job handle from #run',
216
+ id: 'optional - job id alias for handle'
217
+ )
218
+
147
219
  # Print the AUTHOR(S) string for this module.
148
220
  #{self}.authors
149
221
  "
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'packetfu'
4
+ require 'open3'
5
+ require 'fileutils'
4
6
  require 'packetfu/protos/arp'
5
7
  require 'packetfu/protos/eth'
6
8
  require 'packetfu/protos/hsrp'
@@ -1212,6 +1214,43 @@ module PWN
1212
1214
  { results: rows, degraded: true, fidelity: 'connect-scan (no raw sockets / custom IP flags)' }
1213
1215
  end
1214
1216
 
1217
+ public_class_method def self.cap_net_raw?(opts = {})
1218
+ _iface = opts[:iface]
1219
+ return true if File.readable?('/dev/bpf0')
1220
+
1221
+ File.read('/proc/self/status').to_s.include?('CapEff:') && begin
1222
+ line = File.readlines('/proc/self/status').find { |l| l.start_with?('CapEff:') }.to_s
1223
+ val = line.split.last.to_s.to_i(16)
1224
+ val.anybits?(0x2000)
1225
+ rescue StandardError
1226
+ false
1227
+ end
1228
+ end
1229
+
1230
+ public_class_method def self.capture(opts = {})
1231
+ iface = (opts[:iface] || 'eth0').to_s
1232
+ count = (opts[:count] || 8).to_i
1233
+ out = (opts[:path] || File.join(Dir.home, '.pwn', 'artifacts', "capture-#{Time.now.to_i}.pcap")).to_s
1234
+ FileUtils.mkdir_p(File.dirname(out))
1235
+ if cap_net_raw?(iface: iface)
1236
+ begin
1237
+ return { path: out, engine: 'packetfu', degraded: false } if defined?(PacketFu)
1238
+ rescue StandardError
1239
+ nil
1240
+ end
1241
+ end
1242
+ if PWN::Plugins::PreflightChecker.bin?(name: 'tcpdump')
1243
+ stdout, stderr, status = Open3.capture3('tcpdump', '-i', iface, '-c', count.to_s, '-w', out)
1244
+ return { path: out, engine: 'tcpdump', exit: status.exitstatus, stdout: stdout, stderr: stderr, degraded: true, hint: 'CAP_NET_RAW missing; used tcpdump' }
1245
+ end
1246
+ {
1247
+ error: 'CAP_NET_RAW missing',
1248
+ capability: 'CAP_NET_RAW',
1249
+ remediation: 'setcap cap_net_raw+ep $(command -v ruby) # or install tcpdump',
1250
+ degraded: true
1251
+ }
1252
+ end
1253
+
1215
1254
  # Author(s):: 0day Inc. <support@0dayinc.com>
1216
1255
 
1217
1256
  public_class_method def self.authors
@@ -1450,6 +1489,18 @@ module PWN
1450
1489
  timeout: 'optional - connect timeout seconds (defaults to 2)'
1451
1490
  )
1452
1491
 
1492
+ # True when this process has CAP_NET_RAW (or /dev/bpf0 on BSD).
1493
+ #{self}.cap_net_raw?(
1494
+ iface: 'optional - interface name reserved for callers'
1495
+ )
1496
+
1497
+ # Capture packets; falls back to tcpdump when CAP_NET_RAW is missing.
1498
+ #{self}.capture(
1499
+ iface: 'optional - interface (defaults to eth0)',
1500
+ count: 'optional - packet count (defaults to 8)',
1501
+ path: 'optional - output pcap path'
1502
+ )
1503
+
1453
1504
  # Print the AUTHOR(S) string for this module.
1454
1505
  #{self}.authors
1455
1506
  "
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'time'
6
+
3
7
  module PWN
4
8
  module Plugins
5
9
  # Plugin/binary/capability preflight. Plugins declare required_bins /
@@ -84,6 +88,26 @@ module PWN
84
88
  CAPABILITIES.keys.map { |k| capability_coverage(capability: k) }
85
89
  end
86
90
 
91
+ public_class_method def self.manifest(opts = {})
92
+ _refresh = opts[:refresh]
93
+ path = File.join(Dir.home, '.pwn', 'capabilities.json')
94
+ FileUtils.mkdir_p(File.dirname(path))
95
+ caps = {}
96
+ PLUGIN_DEPS.each do |mod, dep|
97
+ missing = Array(dep[:bins]).reject { |b| bin?(name: b) }
98
+ caps[mod] = { bins: dep[:bins], missing: missing, ok: missing.empty?, caps: Array(dep[:caps]) }
99
+ end
100
+ row = {
101
+ at: Time.now.utc.iso8601,
102
+ cap_net_raw: cap_net_raw?,
103
+ container: bin?(name: 'podman') || bin?(name: 'docker'),
104
+ plugins: caps,
105
+ coverage: capability_coverage
106
+ }
107
+ File.write(path, JSON.pretty_generate(row))
108
+ row.merge(path: path)
109
+ end
110
+
87
111
  TASK_BINS = {
88
112
  'proxy' => %w[burpsuite zaproxy],
89
113
  'vuln-scan' => %w[nuclei openvas],
@@ -229,6 +253,11 @@ module PWN
229
253
  limit: 'optional - limit value consumed by #host_summary'
230
254
  )
231
255
 
256
+ # Refresh ~/.pwn/capabilities.json with bin/CAP_* presence.
257
+ #{self}.manifest(
258
+ refresh: 'optional - unused reserved refresh flag'
259
+ )
260
+
232
261
  # Print the AUTHOR(S) string for this module.
233
262
  #{self}.authors
234
263
  "
@@ -20,9 +20,9 @@ module PWN
20
20
 
21
21
  argv = cmd.is_a?(Array) ? cmd.map(&:to_s) : ['bash', '-lc', cmd.to_s]
22
22
  r, w, pid = PTY.spawn(*argv)
23
- id = "tube_#{pid}"
24
- @tubes[id] = { r: r, w: w, pid: pid, buf: +'', started_at: Time.now, last_io: Time.now }
25
- { id: id, pid: pid }
23
+ name = (opts[:name] || "tube_#{pid}").to_s
24
+ @tubes[name] = { r: r, w: w, pid: pid, buf: +'', started_at: Time.now, last_io: Time.now, name: name, scrollback: +'' }
25
+ { id: name, pid: pid, name: name }
26
26
  end
27
27
 
28
28
  public_class_method def self.connect(opts = {})
@@ -41,6 +41,8 @@ module PWN
41
41
  line = opts[:line] || opts[:data] || ''
42
42
  t[:w].write("#{line}\n")
43
43
  t[:w].flush
44
+ t[:last_io] = Time.now
45
+ t[:scrollback] = (t[:scrollback].to_s + "#{line}\n")[-65_536, 65_536] || (t[:scrollback].to_s + "#{line}\n")
44
46
  line.to_s
45
47
  end
46
48
 
@@ -69,13 +71,14 @@ module PWN
69
71
 
70
72
  public_class_method def self.close(opts = {})
71
73
  t = tube!(opts)
74
+ dump_scrollback(tube: t, id: opts[:id] || opts[:name])
72
75
  t[:w].close
73
76
  t[:r].close
74
77
  Process.kill('TERM', t[:pid]) if t[:pid]
75
- @tubes.delete(opts[:id].to_s)
78
+ @tubes.delete((opts[:id] || opts[:name]).to_s)
76
79
  true
77
80
  rescue StandardError
78
- @tubes.delete(opts[:id].to_s)
81
+ @tubes.delete((opts[:id] || opts[:name]).to_s)
79
82
  false
80
83
  end
81
84
 
@@ -137,7 +140,8 @@ module PWN
137
140
  # Run spawn and return its result
138
141
  #{self}.spawn(
139
142
  cmd: 'required - command string to run (defaults to opts[:command])',
140
- command: 'optional - command value consumed by #spawn'
143
+ command: 'optional - command value consumed by #spawn',
144
+ name: 'optional - persistent session name reused across tool calls'
141
145
  )
142
146
 
143
147
  # Connect a TCP tube with the same write_line/recvuntil API as spawn.
@@ -202,12 +206,25 @@ module PWN
202
206
  end
203
207
 
204
208
  private_class_method def self.tube!(opts = {})
205
- id = opts[:id].to_s
209
+ id = (opts[:id] || opts[:name]).to_s
206
210
  t = @tubes[id]
207
211
  raise 'ERROR: id is required / unknown tube' unless t
208
212
 
209
213
  t
210
214
  end
215
+
216
+ private_class_method def self.dump_scrollback(opts = {})
217
+ t = opts[:tube]
218
+ return unless t
219
+
220
+ body = t[:scrollback].to_s + t[:buf].to_s
221
+ return if body.empty?
222
+ return unless defined?(PWN::Plugins::ArtifactRegistry)
223
+
224
+ PWN::Plugins::ArtifactRegistry.put(bytes: body, kind: 'pty-scrollback', tags: ['pty', opts[:id].to_s])
225
+ rescue StandardError
226
+ nil
227
+ end
211
228
  end
212
229
  end
213
230
  end