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.
- 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 +1 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -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 +2 -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/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/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 +67 -0
- data/lib/pwn/ai/agent/engagement.rb +167 -0
- data/lib/pwn/ai/agent/learning.rb +88 -4
- data/lib/pwn/ai/agent/loop.rb +20 -2
- data/lib/pwn/ai/agent/reward.rb +76 -12
- data/lib/pwn/ai/agent/swarm.rb +36 -0
- data/lib/pwn/ai/agent/tool_guard.rb +39 -0
- 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 +65 -0
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/config.rb +18 -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/detect_os.rb +1 -2
- data/lib/pwn/plugins/detonate.rb +62 -0
- data/lib/pwn/plugins/emulator.rb +64 -0
- data/lib/pwn/plugins/exploit_db.rb +33 -9
- data/lib/pwn/plugins/findings.rb +47 -0
- data/lib/pwn/plugins/gdb.rb +42 -0
- data/lib/pwn/plugins/packet.rb +26 -0
- 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/version.rb +1 -1
- 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 +1 -1
- data/spec/lib/pwn/ai/agent/reward_spec.rb +16 -0
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +11 -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/detect_os_spec.rb +0 -14
- 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/exploit_db_spec.rb +38 -3
- data/spec/lib/pwn/plugins/findings_spec.rb +13 -0
- 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 +63 -0
- 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
|
-
|
|
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 },
|
|
@@ -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
|
-
|
|
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
|