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