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.
- checksums.yaml +4 -4
- data/etc/default_skills/pwn/ai/agent/engagement/SKILL.md +53 -0
- data/etc/default_skills/pwn/ai/agent/learning/SKILL.md +4 -0
- data/etc/default_skills/pwn/ai/agent/loop/SKILL.md +5 -2
- data/etc/default_skills/pwn/ai/agent/metrics/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/registry/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +3 -0
- data/etc/default_skills/pwn/ai/agent/tools/debug_lane/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/tools/engagement/SKILL.md +45 -0
- data/etc/default_skills/pwn/ai/agent/turn_finalizer/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/artifact_registry/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/bin_workspace/SKILL.md +49 -0
- data/etc/default_skills/pwn/plugins/binary_parser/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/capability/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/detonate/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/emulator/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/findings/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/gdb/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/preflight_checker/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/vault/SKILL.md +4 -0
- data/etc/default_skills/pwn/reports/engagement/SKILL.md +47 -0
- data/lib/pwn/ai/agent/dispatch.rb +69 -1
- data/lib/pwn/ai/agent/engagement.rb +167 -0
- data/lib/pwn/ai/agent/learning.rb +105 -5
- data/lib/pwn/ai/agent/loop.rb +83 -25
- data/lib/pwn/ai/agent/metrics.rb +35 -1
- data/lib/pwn/ai/agent/registry.rb +28 -0
- data/lib/pwn/ai/agent/reward.rb +80 -15
- data/lib/pwn/ai/agent/swarm.rb +36 -0
- data/lib/pwn/ai/agent/tool_guard.rb +93 -9
- data/lib/pwn/ai/agent/tools/debug_lane.rb +160 -0
- data/lib/pwn/ai/agent/tools/engagement.rb +40 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +84 -0
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/config.rb +18 -2
- data/lib/pwn/plugins/artifact_registry.rb +32 -2
- data/lib/pwn/plugins/bin_workspace.rb +81 -0
- data/lib/pwn/plugins/binary_parser.rb +27 -0
- data/lib/pwn/plugins/capability.rb +68 -0
- data/lib/pwn/plugins/detonate.rb +62 -0
- data/lib/pwn/plugins/emulator.rb +64 -0
- data/lib/pwn/plugins/exploit_dev.rb +33 -0
- data/lib/pwn/plugins/findings.rb +56 -1
- data/lib/pwn/plugins/gdb.rb +42 -0
- data/lib/pwn/plugins/ghidra.rb +20 -2
- data/lib/pwn/plugins/packet.rb +26 -0
- data/lib/pwn/plugins/preflight_checker.rb +23 -0
- data/lib/pwn/plugins/process_tube.rb +31 -1
- data/lib/pwn/plugins/repl.rb +25 -2
- data/lib/pwn/plugins/vault.rb +116 -0
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/reports/engagement.rb +63 -0
- data/lib/pwn/reports.rb +1 -0
- data/lib/pwn/sessions.rb +4 -0
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +9 -0
- data/spec/lib/pwn/ai/agent/engagement_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +23 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +44 -0
- data/spec/lib/pwn/ai/agent/reward_spec.rb +17 -1
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -0
- data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +9 -0
- data/spec/lib/pwn/ai/agent/tools/debug_lane_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/tools/engagement_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +18 -0
- data/spec/lib/pwn/plugins/bin_workspace_spec.rb +26 -0
- data/spec/lib/pwn/plugins/capability_spec.rb +19 -0
- data/spec/lib/pwn/plugins/detonate_spec.rb +24 -0
- data/spec/lib/pwn/plugins/emulator_spec.rb +18 -0
- data/spec/lib/pwn/plugins/findings_spec.rb +16 -3
- data/spec/lib/pwn/plugins/gdb_spec.rb +10 -0
- data/spec/lib/pwn/plugins/packet_spec.rb +6 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +1 -1
- data/spec/lib/pwn/plugins/vault_spec.rb +11 -0
- data/spec/lib/pwn/reports/engagement_spec.rb +30 -0
- data/third_party/pwn_rdoc.jsonl +73 -0
- 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
|
-
|
|
207
|
-
|
|
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
|
|
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
|
|
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'
|
data/lib/pwn/plugins/findings.rb
CHANGED
|
@@ -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(
|
|
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
|
"
|