pwn 0.5.737 → 0.5.739
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/.rubocop_todo.yml +0 -1
- data/Gemfile +2 -2
- data/documentation/Agent-Tool-Registry.md +17 -3
- data/documentation/Diagrams.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Plugin-Degradation.md +74 -0
- data/documentation/Swarm.md +2 -3
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-REPL.md +0 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/browser-exploitation/SKILL.md +10 -0
- data/etc/default_skills/firmware-unpack-emulate/SKILL.md +10 -0
- data/etc/default_skills/heap-exploitation/SKILL.md +11 -0
- data/etc/default_skills/kernel-pwn-triage/SKILL.md +11 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -1
- data/etc/default_skills/pwn/ai/agent/tools/sbom_scan/SKILL.md +45 -0
- data/etc/default_skills/pwn/corpus/SKILL.md +52 -0
- data/etc/default_skills/pwn/corpus/references/urls.md +3 -0
- data/etc/default_skills/pwn/engagement/SKILL.md +55 -0
- data/etc/default_skills/pwn/plugins/debugger/SKILL.md +62 -0
- data/etc/default_skills/pwn/plugins/fuzz/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/handler/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/mitm_proxy/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
- data/etc/default_skills/pwn/plugins/redb/SKILL.md +52 -0
- data/etc/default_skills/pwn/plugins/sbom/SKILL.md +48 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +1 -0
- data/etc/default_skills/rop-jop-chain-construction/SKILL.md +10 -0
- data/etc/default_skills/windows-internals-triage/SKILL.md +10 -0
- data/lib/pwn/ai/agent/loop.rb +80 -11
- data/lib/pwn/ai/agent/prompt_builder.rb +19 -1
- data/lib/pwn/ai/agent/registry.rb +34 -4
- data/lib/pwn/ai/agent/swarm.rb +4 -5
- data/lib/pwn/ai/agent/tool_guard.rb +11 -4
- data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +2 -1
- data/lib/pwn/ai/agent/tools/job_run.rb +9 -3
- data/lib/pwn/ai/agent/tools/pty_session.rb +7 -1
- data/lib/pwn/ai/agent/tools/sbom_scan.rb +25 -0
- data/lib/pwn/ai/agent/tools/shell.rb +3 -2
- data/lib/pwn/ai/agent/tools/swarm.rb +3 -6
- data/lib/pwn/ai/agent/turn_finalizer.rb +2 -2
- data/lib/pwn/corpus.rb +107 -0
- data/lib/pwn/engagement.rb +158 -0
- data/lib/pwn/plugins/debugger.rb +284 -0
- data/lib/pwn/plugins/findings.rb +14 -3
- data/lib/pwn/plugins/fuzz.rb +62 -0
- data/lib/pwn/plugins/handler.rb +171 -0
- data/lib/pwn/plugins/jobs.rb +55 -0
- data/lib/pwn/plugins/mitm_proxy.rb +37 -2
- data/lib/pwn/plugins/nmap_it.rb +32 -0
- data/lib/pwn/plugins/packet.rb +18 -0
- data/lib/pwn/plugins/preflight_checker.rb +6 -2
- data/lib/pwn/plugins/process_tube.rb +99 -8
- data/lib/pwn/plugins/redb.rb +210 -0
- data/lib/pwn/plugins/repl.rb +0 -3
- data/lib/pwn/plugins/sbom.rb +145 -0
- data/lib/pwn/plugins.rb +4 -0
- data/lib/pwn/redaction.rb +53 -0
- data/lib/pwn/reports/engagement.rb +2 -1
- data/lib/pwn/sessions.rb +3 -1
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +2 -0
- data/packer/provisioners/irc.sh +1 -1
- data/spec/lib/pwn/ai/agent/harness_2026_09_15_spec.rb +144 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +50 -0
- data/spec/lib/pwn/ai/agent/tools/mcp_spec.rb +31 -0
- data/spec/lib/pwn/ai/agent/tools/sbom_scan_spec.rb +10 -0
- data/spec/lib/pwn/corpus_spec.rb +23 -0
- data/spec/lib/pwn/engagement_spec.rb +27 -0
- data/spec/lib/pwn/plugins/debugger_spec.rb +27 -0
- data/spec/lib/pwn/plugins/handler_spec.rb +25 -0
- data/spec/lib/pwn/plugins/redb_spec.rb +33 -0
- data/spec/lib/pwn/plugins/repl_spec.rb +7 -1
- data/spec/lib/pwn/plugins/sbom_spec.rb +22 -0
- data/third_party/pwn_rdoc.jsonl +97 -4
- metadata +36 -9
- data/etc/default_skills/pwn/plugins/repl/irc/SKILL.md +0 -47
- data/lib/pwn/plugins/repl/irc.rb +0 -153
- data/spec/lib/pwn/plugins/repl/irc_spec.rb +0 -21
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'ipaddr'
|
|
6
|
+
|
|
7
|
+
module PWN
|
|
8
|
+
# Engagement scope, host state, and vault refs under ~/.pwn/engagements.
|
|
9
|
+
module Engagement
|
|
10
|
+
public_class_method def self.open(opts = {})
|
|
11
|
+
row = PWN::AI::Agent::Engagement.open(opts)
|
|
12
|
+
dir = File.join(PWN::AI::Agent::Engagement::ROOT, row[:name].to_s)
|
|
13
|
+
FileUtils.mkdir_p(File.join(dir, 'evidence'))
|
|
14
|
+
hosts = File.join(dir, 'hosts.json')
|
|
15
|
+
File.write(hosts, JSON.generate({})) unless File.file?(hosts)
|
|
16
|
+
row.merge(dir: dir, hosts: hosts)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
public_class_method def self.close(opts = {})
|
|
20
|
+
PWN::AI::Agent::Engagement.close(opts)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
public_class_method def self.status(opts = {})
|
|
24
|
+
PWN::AI::Agent::Engagement.status(opts).merge(hosts: hosts(opts))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
public_class_method def self.in_scope?(opts = {})
|
|
28
|
+
PWN::AI::Agent::Engagement.in_scope?(opts)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
public_class_method def self.warn_unless_in_scope(opts = {})
|
|
32
|
+
return { ok: true } if in_scope?(opts) || opts[:override] == true
|
|
33
|
+
|
|
34
|
+
{ ok: false, warning: "out of scope: #{opts[:host] || opts[:ip] || opts[:target]}", override_required: true }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
public_class_method def self.record_host(opts = {})
|
|
38
|
+
check = warn_unless_in_scope(opts)
|
|
39
|
+
return check.merge(recorded: false) unless check[:ok]
|
|
40
|
+
|
|
41
|
+
name = (opts[:engagement] || opts[:name] || PWN::AI::Agent::Engagement.current_name || 'default').to_s
|
|
42
|
+
dir = File.join(PWN::AI::Agent::Engagement::ROOT, name)
|
|
43
|
+
FileUtils.mkdir_p(dir)
|
|
44
|
+
path = File.join(dir, 'hosts.json')
|
|
45
|
+
store = File.file?(path) ? JSON.parse(File.read(path), symbolize_names: true) : {}
|
|
46
|
+
host = (opts[:host] || opts[:ip]).to_s
|
|
47
|
+
row = store[host.to_sym] || { host: host, ports: [], services: [], notes: [], creds: [] }
|
|
48
|
+
incoming = Array(opts[:ports]).map do |port|
|
|
49
|
+
port.is_a?(Hash) ? port.transform_keys(&:to_sym) : { port: port }
|
|
50
|
+
end
|
|
51
|
+
by_port = {}
|
|
52
|
+
(Array(row[:ports]) + incoming).each do |item|
|
|
53
|
+
item = item.is_a?(Hash) ? item.transform_keys(&:to_sym) : { port: item }
|
|
54
|
+
key = item[:port]
|
|
55
|
+
by_port[key] = (by_port[key] || {}).merge(item)
|
|
56
|
+
end
|
|
57
|
+
row[:ports] = by_port.values.map { |item| item.keys == [:port] ? item[:port] : item }
|
|
58
|
+
row[:services] = (Array(row[:services]) + Array(opts[:services])).uniq
|
|
59
|
+
row[:notes] = (Array(row[:notes]) + Array(opts[:notes])).uniq
|
|
60
|
+
row[:creds] = (Array(row[:creds]) + Array(opts[:creds])).uniq
|
|
61
|
+
store[host.to_sym] = row
|
|
62
|
+
File.write(path, JSON.pretty_generate(store))
|
|
63
|
+
row
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
public_class_method def self.hosts(opts = {})
|
|
67
|
+
name = (opts[:name] || PWN::AI::Agent::Engagement.current_name || 'default').to_s
|
|
68
|
+
path = File.join(PWN::AI::Agent::Engagement::ROOT, name, 'hosts.json')
|
|
69
|
+
return {} unless File.file?(path)
|
|
70
|
+
|
|
71
|
+
JSON.parse(File.read(path), symbolize_names: true)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
public_class_method def self.merge_scan(opts = {})
|
|
75
|
+
Array(opts[:hosts] || opts[:results]).each do |row|
|
|
76
|
+
row = row.transform_keys(&:to_sym) if row.respond_to?(:transform_keys)
|
|
77
|
+
record_host(
|
|
78
|
+
host: row[:host] || row['host'],
|
|
79
|
+
ports: [{ port: row[:port] || row['port'], proto: row[:proto], service: row[:service], version: row[:version], scripts: row[:scripts] }.compact],
|
|
80
|
+
services: [row[:service] || row['service']].compact,
|
|
81
|
+
override: opts[:override],
|
|
82
|
+
engagement: opts[:engagement] || opts[:name]
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
hosts(name: opts[:engagement] || opts[:name])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
public_class_method def self.authors
|
|
89
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
public_class_method def self.help
|
|
93
|
+
puts "USAGE:
|
|
94
|
+
# Open an engagement and ensure its host/evidence directories exist.
|
|
95
|
+
#{self}.open(
|
|
96
|
+
name: 'optional - engagement identifier (defaults to default)',
|
|
97
|
+
engagement: 'optional - alias for name',
|
|
98
|
+
scope_cidrs: 'optional - Array of CIDR strings',
|
|
99
|
+
scope_domains: 'optional - Array of DNS suffixes',
|
|
100
|
+
excluded: 'optional - Array of excluded hosts',
|
|
101
|
+
roe: 'optional - rules of engagement notes'
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Clear the active engagement pointer.
|
|
105
|
+
#{self}.close(
|
|
106
|
+
unused: 'optional - accepted so callers can pass a Hash'
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Return scope plus persisted host state.
|
|
110
|
+
#{self}.status(
|
|
111
|
+
name: 'optional - engagement identifier'
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
# True when a host or URL is inside the active scope.
|
|
115
|
+
#{self}.in_scope?(
|
|
116
|
+
host: 'optional - hostname or IP',
|
|
117
|
+
ip: 'optional - alias for host',
|
|
118
|
+
target: 'optional - alias for host',
|
|
119
|
+
url: 'optional - URL whose host is checked'
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Warn on out-of-scope targets unless override is true.
|
|
123
|
+
#{self}.warn_unless_in_scope(
|
|
124
|
+
host: 'optional - hostname or IP',
|
|
125
|
+
ip: 'optional - alias for host',
|
|
126
|
+
target: 'optional - alias for host',
|
|
127
|
+
override: 'optional - true continues despite a scope miss'
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Deep-merge ports, services, notes, and vault refs for a host.
|
|
131
|
+
#{self}.record_host(
|
|
132
|
+
host: 'required - hostname or IP',
|
|
133
|
+
ip: 'optional - alias for host',
|
|
134
|
+
ports: 'optional - Array of open ports',
|
|
135
|
+
services: 'optional - Array of service labels',
|
|
136
|
+
notes: 'optional - Array of freeform notes',
|
|
137
|
+
creds: 'optional - Array of vault references',
|
|
138
|
+
override: 'optional - true records an out-of-scope host'
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Return the host map for an engagement.
|
|
142
|
+
#{self}.hosts(
|
|
143
|
+
name: 'optional - engagement identifier'
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Merge structured scan rows into host state.
|
|
147
|
+
#{self}.merge_scan(
|
|
148
|
+
hosts: 'optional - Array of {host, port, service} hashes',
|
|
149
|
+
results: 'optional - alias for hosts',
|
|
150
|
+
override: 'optional - true records out-of-scope hosts'
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Print the AUTHOR(S) string for this module.
|
|
154
|
+
#{self}.authors
|
|
155
|
+
"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module PWN
|
|
6
|
+
module Plugins
|
|
7
|
+
# Structured GDB/MI driver with a handle registry that survives pwn_eval.
|
|
8
|
+
module Debugger
|
|
9
|
+
@sessions = {}
|
|
10
|
+
|
|
11
|
+
public_class_method def self.required_bins
|
|
12
|
+
%w[gdb]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
public_class_method def self.launch(opts = {})
|
|
16
|
+
bin = (opts[:bin] || opts[:binary]).to_s
|
|
17
|
+
raise ArgumentError, 'bin is required' if bin.empty?
|
|
18
|
+
|
|
19
|
+
args = Array(opts[:args]).map(&:to_s)
|
|
20
|
+
argv = ['gdb', '--interpreter=mi2', '--quiet', '--nx', '--args', bin, *args]
|
|
21
|
+
tube = PWN::Plugins::ProcessTube.spawn(cmd: argv, name: opts[:handle])
|
|
22
|
+
row = store_session(handle: tube[:id], tube: tube, bin: bin)
|
|
23
|
+
drain_gdb(handle: row[:handle])
|
|
24
|
+
mi(handle: row[:handle], cmd: '-gdb-set confirm off')
|
|
25
|
+
mi(handle: row[:handle], cmd: '-gdb-set pagination off')
|
|
26
|
+
mi(handle: row[:handle], cmd: '-exec-run') if opts[:run] == true
|
|
27
|
+
row
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
public_class_method def self.attach(opts = {})
|
|
31
|
+
if opts[:host] || opts[:port]
|
|
32
|
+
host = (opts[:host] || '127.0.0.1').to_s
|
|
33
|
+
port = (opts[:port] || 1234).to_i
|
|
34
|
+
argv = ['gdb', '--interpreter=mi2', '--quiet', '--nx']
|
|
35
|
+
tube = PWN::Plugins::ProcessTube.spawn(cmd: argv, name: opts[:handle])
|
|
36
|
+
row = store_session(handle: tube[:id], tube: tube, gdbstub: "#{host}:#{port}")
|
|
37
|
+
drain_gdb(handle: row[:handle])
|
|
38
|
+
parse_mi(raw: mi(handle: row[:handle], cmd: "-target-select remote #{host}:#{port}"))
|
|
39
|
+
return row
|
|
40
|
+
end
|
|
41
|
+
pid = opts[:pid].to_i
|
|
42
|
+
raise ArgumentError, 'pid is required' unless pid.positive?
|
|
43
|
+
|
|
44
|
+
argv = ['gdb', '--interpreter=mi2', '--quiet', '--nx', '-p', pid.to_s]
|
|
45
|
+
tube = PWN::Plugins::ProcessTube.spawn(cmd: argv, name: opts[:handle])
|
|
46
|
+
row = store_session(handle: tube[:id], tube: tube, pid: pid)
|
|
47
|
+
drain_gdb(handle: row[:handle])
|
|
48
|
+
row
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
public_class_method def self.run(opts = {})
|
|
52
|
+
parse_mi(raw: mi(opts.merge(cmd: '-exec-run')))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
public_class_method def self.break(opts = {})
|
|
56
|
+
loc = (opts[:addr_or_sym] || opts[:location] || opts[:addr] || opts[:symbol]).to_s
|
|
57
|
+
raise ArgumentError, 'addr_or_sym is required' if loc.empty?
|
|
58
|
+
|
|
59
|
+
parse_mi(raw: mi(opts.merge(cmd: "-break-insert #{loc}")))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
public_class_method def self.continue(opts = {})
|
|
63
|
+
parse_mi(raw: mi(opts.merge(cmd: '-exec-continue')))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
public_class_method def self.step(opts = {})
|
|
67
|
+
parse_mi(raw: mi(opts.merge(cmd: (opts[:into] ? '-exec-step' : '-exec-next'))))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
public_class_method def self.read_mem(opts = {})
|
|
71
|
+
addr = (opts[:addr] || opts[:address]).to_s
|
|
72
|
+
raise ArgumentError, 'addr is required' if addr.empty?
|
|
73
|
+
|
|
74
|
+
parse_mi(raw: mi(opts.merge(cmd: "-data-read-memory-bytes #{addr} #{(opts[:len] || opts[:length] || 64).to_i}")))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
public_class_method def self.write_mem(opts = {})
|
|
78
|
+
addr = (opts[:addr] || opts[:address]).to_s
|
|
79
|
+
data = opts[:data] || opts[:bytes]
|
|
80
|
+
raise ArgumentError, 'addr and data are required' if addr.empty? || data.nil?
|
|
81
|
+
|
|
82
|
+
hex = data.is_a?(String) ? data.unpack1('H*') : Array(data).pack('C*').unpack1('H*')
|
|
83
|
+
parse_mi(raw: mi(opts.merge(cmd: "-data-write-memory-bytes #{addr} #{hex}")))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
public_class_method def self.regs(opts = {})
|
|
87
|
+
parse_mi(raw: mi(opts.merge(cmd: '-data-list-register-values x')))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
public_class_method def self.backtrace(opts = {})
|
|
91
|
+
parse_mi(raw: mi(opts.merge(cmd: '-stack-list-frames')))
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
public_class_method def self.checksec(opts = {})
|
|
95
|
+
PWN::Plugins::GDB.mitigations(binary: opts[:bin] || opts[:binary] || session!(opts)[:bin])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
public_class_method def self.cyclic(opts = {})
|
|
99
|
+
PWN::Plugins::ExploitDev.cyclic(length: opts[:length] || opts[:n] || 200)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
public_class_method def self.cyclic_find(opts = {})
|
|
103
|
+
PWN::Plugins::ExploitDev.cyclic_find(value: opts[:value] || opts[:pattern], length: opts[:length] || 8_192)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
public_class_method def self.to_pwntools_offsets(opts = {})
|
|
107
|
+
{ cyclic: cyclic(opts), offset: cyclic_find(opts) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
public_class_method def self.close(opts = {})
|
|
111
|
+
handle = (opts[:handle] || opts[:id]).to_s
|
|
112
|
+
sess = @sessions.delete(handle)
|
|
113
|
+
PWN::Plugins::ProcessTube.close(id: handle) if sess
|
|
114
|
+
{ closed: handle }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
public_class_method def self.authors
|
|
118
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
public_class_method def self.help
|
|
122
|
+
puts "USAGE:
|
|
123
|
+
# List host binaries this module expects to be installed.
|
|
124
|
+
#{self}.required_bins
|
|
125
|
+
|
|
126
|
+
# Launch a binary under gdb MI and keep the handle in-process.
|
|
127
|
+
#{self}.launch(
|
|
128
|
+
bin: 'required - filesystem path of the binary to debug',
|
|
129
|
+
binary: 'optional - alias for bin',
|
|
130
|
+
args: 'optional - Array of argv strings after the binary',
|
|
131
|
+
handle: 'optional - ProcessTube name for the session',
|
|
132
|
+
run: 'optional - true issues -exec-run after gdb starts'
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Attach gdb MI to an existing pid or gdbstub.
|
|
136
|
+
#{self}.attach(
|
|
137
|
+
pid: 'optional - integer process id to attach',
|
|
138
|
+
host: 'optional - gdbstub host for remote attach',
|
|
139
|
+
port: 'optional - gdbstub TCP port',
|
|
140
|
+
handle: 'optional - ProcessTube name for the session'
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Run the inferior (-exec-run).
|
|
144
|
+
#{self}.run(
|
|
145
|
+
handle: 'required - session handle from launch or attach'
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Insert a breakpoint at a symbol or address.
|
|
149
|
+
#{self}.break(
|
|
150
|
+
handle: 'required - session handle from launch or attach',
|
|
151
|
+
addr_or_sym: 'required - symbol or address',
|
|
152
|
+
location: 'optional - alias for addr_or_sym',
|
|
153
|
+
addr: 'optional - alias for addr_or_sym',
|
|
154
|
+
symbol: 'optional - alias for addr_or_sym'
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
# Continue execution until the next stop.
|
|
158
|
+
#{self}.continue(
|
|
159
|
+
handle: 'required - session handle from launch or attach'
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Step one instruction or source line.
|
|
163
|
+
#{self}.step(
|
|
164
|
+
handle: 'required - session handle from launch or attach',
|
|
165
|
+
into: 'optional - true to step into rather than next'
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Read memory bytes at an address.
|
|
169
|
+
#{self}.read_mem(
|
|
170
|
+
handle: 'required - session handle from launch or attach',
|
|
171
|
+
addr: 'required - address to read',
|
|
172
|
+
address: 'optional - alias for addr',
|
|
173
|
+
len: 'optional - byte count (defaults to 64)',
|
|
174
|
+
length: 'optional - alias for len'
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Write raw bytes to inferior memory.
|
|
178
|
+
#{self}.write_mem(
|
|
179
|
+
handle: 'required - session handle from launch or attach',
|
|
180
|
+
addr: 'required - address to write',
|
|
181
|
+
address: 'optional - alias for addr',
|
|
182
|
+
data: 'required - String or byte Array to write',
|
|
183
|
+
bytes: 'optional - alias for data'
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Return register values as a parsed MI hash.
|
|
187
|
+
#{self}.regs(
|
|
188
|
+
handle: 'required - session handle from launch or attach'
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Return a parsed backtrace hash.
|
|
192
|
+
#{self}.backtrace(
|
|
193
|
+
handle: 'required - session handle from launch or attach'
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Return binary mitigations via GDB.mitigations.
|
|
197
|
+
#{self}.checksec(
|
|
198
|
+
handle: 'optional - session handle whose binary should be probed',
|
|
199
|
+
bin: 'optional - filesystem path of the binary',
|
|
200
|
+
binary: 'optional - alias for bin'
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Generate a cyclic de Bruijn pattern.
|
|
204
|
+
#{self}.cyclic(
|
|
205
|
+
length: 'optional - pattern length in bytes',
|
|
206
|
+
n: 'optional - alias for length'
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Find the offset of a packed register value in a cyclic pattern.
|
|
210
|
+
#{self}.cyclic_find(
|
|
211
|
+
value: 'required - leaked register value or substring',
|
|
212
|
+
pattern: 'optional - alias for value',
|
|
213
|
+
length: 'optional - haystack length (defaults to 8192)'
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Return both a cyclic pattern and the recovered offset.
|
|
217
|
+
#{self}.to_pwntools_offsets(
|
|
218
|
+
value: 'optional - leaked register value for cyclic_find',
|
|
219
|
+
length: 'optional - pattern length in bytes',
|
|
220
|
+
n: 'optional - alias for length'
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Close a debugger session handle.
|
|
224
|
+
#{self}.close(
|
|
225
|
+
handle: 'required - session handle from launch or attach',
|
|
226
|
+
id: 'optional - alias for handle'
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Print the AUTHOR(S) string for this module.
|
|
230
|
+
#{self}.authors
|
|
231
|
+
"
|
|
232
|
+
constants.sort
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
private_class_method def self.store_session(opts = {})
|
|
236
|
+
handle = opts[:handle].to_s
|
|
237
|
+
@sessions[handle] = { tube: opts[:tube], bin: opts[:bin], pid: opts[:pid] }
|
|
238
|
+
{ handle: handle, pid: opts[:tube][:pid], bin: opts[:bin] }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
private_class_method def self.session!(opts = {})
|
|
242
|
+
handle = (opts[:handle] || opts[:id]).to_s
|
|
243
|
+
sess = @sessions[handle]
|
|
244
|
+
raise ArgumentError, "unknown debugger handle #{handle}" unless sess
|
|
245
|
+
|
|
246
|
+
sess.merge(handle: handle)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
private_class_method def self.drain_gdb(opts = {})
|
|
250
|
+
PWN::Plugins::ProcessTube.expect(id: opts[:handle], pattern: /\(gdb\)/, timeout: opts[:timeout] || 8)
|
|
251
|
+
rescue StandardError
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
private_class_method def self.mi(opts = {})
|
|
256
|
+
sess = session!(opts)
|
|
257
|
+
PWN::Plugins::ProcessTube.write_line(id: sess[:handle], line: opts[:cmd].to_s)
|
|
258
|
+
row = PWN::Plugins::ProcessTube.expect(
|
|
259
|
+
id: sess[:handle],
|
|
260
|
+
pattern: /^\^done|^\^error|^\^running|^\*stopped/m,
|
|
261
|
+
timeout: opts[:timeout] || 8
|
|
262
|
+
)
|
|
263
|
+
row.is_a?(Hash) ? row[:matched].to_s : row.to_s
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
private_class_method def self.parse_mi(opts = {})
|
|
267
|
+
raw = opts[:raw].to_s
|
|
268
|
+
ok = raw.match?(/\^(?:done|running)|^\*stopped/m)
|
|
269
|
+
regs = {}
|
|
270
|
+
raw.scan(/name="([^"]+)",value="([^"]+)"/) { |name, val| regs[name] = val }
|
|
271
|
+
raw.scan(/number="(\d+)",value="([^"]+)"/) { |num, val| regs[num] ||= val }
|
|
272
|
+
mem = {}
|
|
273
|
+
if (m = raw.match(/memory=\[\{[^}]*begin="([^"]+)"[^}]*contents="([^"]+)"/))
|
|
274
|
+
mem = { addr: m[1], hex: m[2] }
|
|
275
|
+
end
|
|
276
|
+
bkpt = {}
|
|
277
|
+
if (m = raw.match(/bkpt=\{([^}]+)\}/))
|
|
278
|
+
m[1].scan(/(\w+)="([^"]+)"/) { |k, v| bkpt[k.to_sym] = v }
|
|
279
|
+
end
|
|
280
|
+
{ ok: ok, raw: raw, records: raw.lines.map(&:chomp), registers: regs, memory: mem, bkpt: bkpt }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
data/lib/pwn/plugins/findings.rb
CHANGED
|
@@ -19,6 +19,10 @@ module PWN
|
|
|
19
19
|
# Strict P10 boundary. Legacy record remains available for older callers.
|
|
20
20
|
public_class_method def self.record_structured(opts = {})
|
|
21
21
|
opts = opts.transform_keys(&:to_sym)
|
|
22
|
+
opts[:affected_asset] ||= opts[:target]
|
|
23
|
+
opts[:poc] ||= opts[:repro_cmd]
|
|
24
|
+
opts[:target] ||= opts[:affected_asset]
|
|
25
|
+
opts[:repro_cmd] ||= opts[:poc]
|
|
22
26
|
%i[title cwe cvss_vector affected_asset poc remediation].each do |key|
|
|
23
27
|
raise ArgumentError, "#{key} must be a non-empty string" unless opts[key].is_a?(String) && !opts[key].strip.empty?
|
|
24
28
|
end
|
|
@@ -53,7 +57,8 @@ module PWN
|
|
|
53
57
|
row = opts.slice(:title, :cwe, :cvss_vector, :cvss_score, :affected_asset, :evidence_paths, :poc,
|
|
54
58
|
:attack_chain_refs, :remediation, :confidence, :engagement_id, :session_id)
|
|
55
59
|
row = row.merge(id: SecureRandom.hex(6), severity: severity, status: 'open', verification_status: 'not_executed',
|
|
56
|
-
chain_refs: refs, host: opts[:affected_asset],
|
|
60
|
+
chain_refs: refs, host: opts[:affected_asset], target: opts[:affected_asset],
|
|
61
|
+
repro_cmd: opts[:poc], evidence: paths.map { |path| Digest::SHA256.file(path).hexdigest },
|
|
57
62
|
poc_artifacts: [], at: Time.now.utc.iso8601)
|
|
58
63
|
FileUtils.mkdir_p(File.dirname(FILE))
|
|
59
64
|
File.open(FILE, 'a') do |file|
|
|
@@ -378,7 +383,11 @@ module PWN
|
|
|
378
383
|
sha256: Digest::SHA256.file(art).hexdigest,
|
|
379
384
|
size: File.size(art)
|
|
380
385
|
}
|
|
381
|
-
File.
|
|
386
|
+
dest = File.join(dir, 'evidence', rec[:sha256])
|
|
387
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
388
|
+
FileUtils.cp(art, dest) unless File.file?(dest)
|
|
389
|
+
rec[:stored] = dest
|
|
390
|
+
File.open(path, 'a') { |file| file.puts(JSON.generate(rec)) }
|
|
382
391
|
end
|
|
383
392
|
end
|
|
384
393
|
|
|
@@ -404,7 +413,9 @@ module PWN
|
|
|
404
413
|
remediation: 'required - remediation instructions',
|
|
405
414
|
confidence: 'required - numeric 0..1',
|
|
406
415
|
engagement_id: 'optional - simple engagement identifier',
|
|
407
|
-
session_id: 'optional - session identifier'
|
|
416
|
+
session_id: 'optional - session identifier',
|
|
417
|
+
target: 'optional - alias for affected_asset',
|
|
418
|
+
repro_cmd: 'optional - alias for poc'
|
|
408
419
|
)
|
|
409
420
|
|
|
410
421
|
# Append a legacy finding row to ~/.pwn/findings.jsonl.
|
data/lib/pwn/plugins/fuzz.rb
CHANGED
|
@@ -190,6 +190,61 @@ module PWN
|
|
|
190
190
|
dict.map { |blob| seed + blob.to_s }
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
public_class_method def self.triage(opts = {})
|
|
194
|
+
require 'digest'
|
|
195
|
+
crashes = Array(opts[:crashes])
|
|
196
|
+
crashes = Dir.glob(File.join(opts[:dir].to_s, '*')) if crashes.empty?
|
|
197
|
+
files = crashes.select { |path| File.file?(path) }
|
|
198
|
+
groups = files.group_by { |path| crash_hash(path: path) }
|
|
199
|
+
groups.map do |hash, group|
|
|
200
|
+
mini = minimize_group(files: group, target: opts[:target])
|
|
201
|
+
body = File.binread(mini).to_s
|
|
202
|
+
score = exploitability(text: body, path: mini)
|
|
203
|
+
row = {
|
|
204
|
+
hash: hash,
|
|
205
|
+
count: group.length,
|
|
206
|
+
minimized: mini,
|
|
207
|
+
exploitability: score,
|
|
208
|
+
files: group
|
|
209
|
+
}
|
|
210
|
+
if opts[:record] == true && defined?(PWN::Plugins::Findings)
|
|
211
|
+
PWN::Plugins::Findings.record(
|
|
212
|
+
title: "fuzz crash #{hash}",
|
|
213
|
+
severity: score.include?('pc_control') ? 'high' : 'medium',
|
|
214
|
+
host: opts[:target].to_s,
|
|
215
|
+
evidence: mini,
|
|
216
|
+
poc: mini
|
|
217
|
+
)
|
|
218
|
+
end
|
|
219
|
+
row
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
private_class_method def self.crash_hash(opts = {})
|
|
224
|
+
body = File.binread(opts[:path]).to_s
|
|
225
|
+
if (m = body.match(/AddressSanitizer[^\n]*\n(?:.*\n){0,12}/))
|
|
226
|
+
Digest::SHA256.hexdigest(m[0])[0, 16]
|
|
227
|
+
elsif (m = body.scan(/#\d+\s+0x[0-9a-f]+.+/).first(6)).any?
|
|
228
|
+
Digest::SHA256.hexdigest(m.join)[0, 16]
|
|
229
|
+
else
|
|
230
|
+
Digest::SHA256.hexdigest(body)[0, 16]
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
private_class_method def self.minimize_group(opts = {})
|
|
235
|
+
files = Array(opts[:files])
|
|
236
|
+
files.min_by { |path| File.size(path) }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private_class_method def self.exploitability(opts = {})
|
|
240
|
+
text = opts[:text].to_s
|
|
241
|
+
return 'pc_control_candidate' if text.match?(/pc\s+0x|rip\s+0x|eip\s+0x|SEGV on unknown address 0x/i)
|
|
242
|
+
return 'writable_address_control' if text.match?(/WRITE of size|heap-buffer-overflow|stack-buffer-overflow/i)
|
|
243
|
+
return 'pc_control_candidate' if File.size(opts[:path].to_s).to_i >= 8
|
|
244
|
+
|
|
245
|
+
'unknown'
|
|
246
|
+
end
|
|
247
|
+
|
|
193
248
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
194
249
|
|
|
195
250
|
public_class_method def self.authors
|
|
@@ -232,6 +287,13 @@ module PWN
|
|
|
232
287
|
dictionary: 'optional - Array of binary/string mutations'
|
|
233
288
|
)
|
|
234
289
|
|
|
290
|
+
# Dedup crash files by content hash and pick the smallest reproducer.
|
|
291
|
+
#{self}.triage(
|
|
292
|
+
crashes: 'optional - Array of crash file paths',
|
|
293
|
+
dir: 'optional - directory globbed when crashes is empty',
|
|
294
|
+
record: 'optional - true records unique crashes via Findings.record'
|
|
295
|
+
)
|
|
296
|
+
|
|
235
297
|
# Print the AUTHOR(S) string for this module.
|
|
236
298
|
#{self}.authors
|
|
237
299
|
"
|