pwn 0.5.718 → 0.5.720

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 (70) 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 +1 -0
  5. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
  6. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -0
  7. data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
  8. data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
  9. data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +2 -0
  10. data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
  11. data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
  12. data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
  13. data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
  14. data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
  16. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +2 -0
  17. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  18. data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
  19. data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
  20. data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
  21. data/lib/pwn/ai/agent/dispatch.rb +67 -0
  22. data/lib/pwn/ai/agent/engagement.rb +167 -0
  23. data/lib/pwn/ai/agent/learning.rb +88 -4
  24. data/lib/pwn/ai/agent/loop.rb +20 -2
  25. data/lib/pwn/ai/agent/reward.rb +76 -12
  26. data/lib/pwn/ai/agent/swarm.rb +36 -0
  27. data/lib/pwn/ai/agent/tool_guard.rb +39 -0
  28. data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
  29. data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
  30. data/lib/pwn/ai/agent/turn_finalizer.rb +65 -0
  31. data/lib/pwn/ai/agent.rb +1 -0
  32. data/lib/pwn/config.rb +18 -2
  33. data/lib/pwn/plugins/bin_workspace.rb +81 -0
  34. data/lib/pwn/plugins/binary_parser.rb +27 -0
  35. data/lib/pwn/plugins/capability.rb +68 -0
  36. data/lib/pwn/plugins/detect_os.rb +1 -2
  37. data/lib/pwn/plugins/detonate.rb +62 -0
  38. data/lib/pwn/plugins/emulator.rb +64 -0
  39. data/lib/pwn/plugins/exploit_db.rb +33 -9
  40. data/lib/pwn/plugins/findings.rb +47 -0
  41. data/lib/pwn/plugins/gdb.rb +42 -0
  42. data/lib/pwn/plugins/packet.rb +26 -0
  43. data/lib/pwn/plugins/repl.rb +25 -2
  44. data/lib/pwn/plugins/vault.rb +116 -0
  45. data/lib/pwn/plugins.rb +4 -0
  46. data/lib/pwn/reports/engagement.rb +63 -0
  47. data/lib/pwn/reports.rb +1 -0
  48. data/lib/pwn/version.rb +1 -1
  49. data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
  50. data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
  51. data/spec/lib/pwn/ai/agent/loop_spec.rb +1 -1
  52. data/spec/lib/pwn/ai/agent/reward_spec.rb +16 -0
  53. data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
  54. data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
  55. data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
  56. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
  57. data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
  58. data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
  59. data/spec/lib/pwn/plugins/detect_os_spec.rb +0 -14
  60. data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
  61. data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
  62. data/spec/lib/pwn/plugins/exploit_db_spec.rb +38 -3
  63. data/spec/lib/pwn/plugins/findings_spec.rb +13 -0
  64. data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
  65. data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
  66. data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
  67. data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
  68. data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
  69. data/third_party/pwn_rdoc.jsonl +63 -0
  70. metadata +25 -1
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'debug_session',
7
+ toolset: 'pwn',
8
+ schema: {
9
+ name: 'debug_session',
10
+ description: 'Run a binary under gdb to crash and return typed crash_info.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ binary: { type: 'string' },
15
+ args: { type: 'array', items: { type: 'string' } },
16
+ script: { type: 'array', items: { type: 'string' } }
17
+ },
18
+ required: %w[binary]
19
+ }
20
+ },
21
+ handler: lambda { |args|
22
+ PWN::Plugins::GDB.debug_session(
23
+ binary: args[:binary] || args['binary'],
24
+ args: args[:args] || args['args'],
25
+ commands: args[:script] || args['script']
26
+ )
27
+ }
28
+ )
29
+ PWN::AI::Agent::Registry.register(
30
+ name: 'binary_diff',
31
+ toolset: 'pwn',
32
+ schema: {
33
+ name: 'binary_diff',
34
+ description: 'Diff two binaries (radiff2 -C) and rank changed functions.',
35
+ parameters: {
36
+ type: 'object',
37
+ properties: { a: { type: 'string' }, b: { type: 'string' } },
38
+ required: %w[a b]
39
+ }
40
+ },
41
+ handler: lambda { |args|
42
+ PWN::Plugins::BinaryParser.diff(a: args[:a] || args['a'], b: args[:b] || args['b'])
43
+ }
44
+ )
45
+ PWN::AI::Agent::Registry.register(
46
+ name: 'crash_triage',
47
+ toolset: 'pwn',
48
+ schema: {
49
+ name: 'crash_triage',
50
+ description: 'Dedupe AFL crashes, minimize, debug, and emit findings with PoCs.',
51
+ parameters: {
52
+ type: 'object',
53
+ properties: { campaign: { type: 'string' }, out_dir: { type: 'string' }, target: { type: 'string' } }
54
+ }
55
+ },
56
+ handler: lambda { |args|
57
+ dir = args[:out_dir] || args['out_dir'] || File.join(Dir.home, '.pwn', 'fuzz', (args[:campaign] || args['campaign'] || 'default').to_s)
58
+ PWN::Plugins::AFLplusplus.crash_triage(out_dir: dir)
59
+ }
60
+ )
61
+ PWN::AI::Agent::Registry.register(
62
+ name: 'capability_request',
63
+ toolset: 'sessions',
64
+ schema: {
65
+ name: 'capability_request',
66
+ description: 'Request a grantable capability (never auto-sudo).',
67
+ parameters: {
68
+ type: 'object',
69
+ properties: { cap: { type: 'string' }, reason: { type: 'string' }, operator_ack: { type: 'boolean' } },
70
+ required: %w[cap]
71
+ }
72
+ },
73
+ handler: lambda { |args|
74
+ PWN::Plugins::Capability.request(
75
+ cap: args[:cap] || args['cap'],
76
+ reason: args[:reason] || args['reason'],
77
+ operator_ack: args[:operator_ack] || args['operator_ack']
78
+ )
79
+ }
80
+ )
81
+ PWN::AI::Agent::Registry.register(
82
+ name: 'emulate',
83
+ toolset: 'pwn',
84
+ schema: {
85
+ name: 'emulate',
86
+ description: 'Emulate one function (Unicorn) and return registers/memory writes.',
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ binary: { type: 'string' },
91
+ addr: { type: 'string' },
92
+ args: { type: 'array' },
93
+ max_insns: { type: 'integer' }
94
+ },
95
+ required: %w[binary addr]
96
+ }
97
+ },
98
+ handler: lambda { |args|
99
+ PWN::Plugins::Emulator.emulate(
100
+ binary: args[:binary] || args['binary'],
101
+ addr: args[:addr] || args['addr'],
102
+ args: args[:args] || args['args'],
103
+ max_insns: args[:max_insns] || args['max_insns']
104
+ )
105
+ }
106
+ )
107
+ PWN::AI::Agent::Registry.register(
108
+ name: 'detonate',
109
+ toolset: 'pwn',
110
+ schema: {
111
+ name: 'detonate',
112
+ description: 'Run a sample in an isolated container. Refuses if isolation is missing.',
113
+ parameters: {
114
+ type: 'object',
115
+ properties: { path: { type: 'string' }, timeout: { type: 'integer' }, network: { type: 'string' } },
116
+ required: %w[path]
117
+ }
118
+ },
119
+ handler: lambda { |args|
120
+ PWN::Plugins::Detonate.detonate(
121
+ path: args[:path] || args['path'],
122
+ timeout: args[:timeout] || args['timeout'],
123
+ network: args[:network] || args['network']
124
+ )
125
+ }
126
+ )
127
+ PWN::AI::Agent::Registry.register(
128
+ name: 'budget_status',
129
+ toolset: 'sessions',
130
+ schema: {
131
+ name: 'budget_status',
132
+ description: 'Read-only remaining time/token/mutation budget for this loop.',
133
+ parameters: { type: 'object', properties: {} }
134
+ },
135
+ handler: ->(_args) { PWN::AI::Agent::Loop.budget_status }
136
+ )
137
+ PWN::AI::Agent::Registry.register(
138
+ name: 'pty_expect',
139
+ toolset: 'terminal',
140
+ schema: {
141
+ name: 'pty_expect',
142
+ description: 'Send optional data and wait for a regex on a PTY with timeout.',
143
+ parameters: {
144
+ type: 'object',
145
+ properties: {
146
+ session: { type: 'string' },
147
+ id: { type: 'string' },
148
+ send: { type: 'string' },
149
+ expect: { type: 'string' },
150
+ timeout: { type: 'integer' }
151
+ }
152
+ }
153
+ },
154
+ handler: lambda { |args|
155
+ id = args[:session] || args[:id] || args['session'] || args['id']
156
+ line = args[:send] || args['send']
157
+ PWN::Plugins::ProcessTube.write_line(id: id, line: line) if line.to_s != ''
158
+ PWN::Plugins::ProcessTube.expect(id: id, until: args[:expect] || args['expect'], timeout: args[:timeout])
159
+ }
160
+ )
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pwn/ai/agent/registry'
4
+
5
+ PWN::AI::Agent::Registry.register(
6
+ name: 'engagement_open',
7
+ toolset: 'sessions',
8
+ schema: {
9
+ name: 'engagement_open',
10
+ description: 'Open or update an engagement scope document and mark it active.',
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ name: { type: 'string' },
15
+ scope_cidrs: { type: 'array', items: { type: 'string' } },
16
+ scope_domains: { type: 'array', items: { type: 'string' } }
17
+ },
18
+ required: %w[name]
19
+ }
20
+ },
21
+ handler: lambda { |args|
22
+ PWN::AI::Agent::Engagement.open(
23
+ name: args[:name] || args['name'],
24
+ scope_cidrs: args[:scope_cidrs] || args['scope_cidrs'],
25
+ scope_domains: args[:scope_domains] || args['scope_domains']
26
+ )
27
+ }
28
+ )
29
+ PWN::AI::Agent::Registry.register(
30
+ name: 'engagement_close',
31
+ toolset: 'sessions',
32
+ schema: { name: 'engagement_close', description: 'Clear the active engagement.', parameters: { type: 'object', properties: {} } },
33
+ handler: ->(_args) { PWN::AI::Agent::Engagement.close }
34
+ )
35
+ PWN::AI::Agent::Registry.register(
36
+ name: 'engagement_status',
37
+ toolset: 'sessions',
38
+ schema: { name: 'engagement_status', description: 'Return the active engagement document.', parameters: { type: 'object', properties: { name: { type: 'string' } } } },
39
+ handler: ->(args) { PWN::AI::Agent::Engagement.status(name: args[:name] || args['name']) }
40
+ )
@@ -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,56 @@ 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
+
178
230
  public_class_method def self.authors
179
231
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
180
232
  end
@@ -217,6 +269,19 @@ module PWN
217
269
  # Run pending and return its result
218
270
  #{self}.pending
219
271
 
272
+ # Arbitrate a completion claim against the turn evidence ledger.
273
+ #{self}.arbitrate(
274
+ request: 'optional - original operator request',
275
+ messages: 'optional - Array of role/content hashes for this turn',
276
+ paths: 'optional - absolute paths that must exist',
277
+ session_t0: 'optional - session start Time (mtime-before-session is named, not silent)'
278
+ )
279
+
280
+ # Build path → {write,read} ledger from tool messages.
281
+ #{self}.evidence_ledger(
282
+ messages: 'optional - Array of role/content hashes for this turn'
283
+ )
284
+
220
285
  # Print the AUTHOR(S) string for this module.
221
286
  #{self}.authors
222
287
  "
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 },
@@ -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
@@ -182,8 +182,7 @@ module PWN
182
182
  end
183
183
  found << name if ok
184
184
  end
185
- owned = pwn_installed_bins(pwn_bins: opts[:pwn_bins])
186
- native = (found.map(&:to_s) - owned).reject(&:empty?).uniq.sort
185
+ native = found.map(&:to_s).reject(&:empty?).uniq.sort
187
186
  cap = if opts.key?(:cap_net_raw)
188
187
  opts[:cap_net_raw]
189
188
  else
@@ -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