pwn 0.5.709 → 0.5.710

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Installation.md +1 -1
  4. data/etc/default_skills/exploitdev/SKILL.md +38 -0
  5. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +1 -0
  6. data/etc/default_skills/pwn/plugins/credential_attack/SKILL.md +7 -0
  7. data/etc/default_skills/pwn/plugins/credential_attack/references/urls.md +3 -0
  8. data/etc/default_skills/pwn/plugins/exploit_db/SKILL.md +49 -0
  9. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +2 -0
  10. data/etc/default_skills/pwn/plugins/frida/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/plugins/fuzz/SKILL.md +2 -0
  12. data/etc/default_skills/pwn/plugins/gdb/SKILL.md +1 -0
  13. data/etc/default_skills/pwn/plugins/k8s/SKILL.md +51 -0
  14. data/etc/default_skills/pwn/plugins/nuclei/SKILL.md +2 -0
  15. data/etc/default_skills/pwn/plugins/{doctor → preflight_checker}/SKILL.md +13 -11
  16. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +1 -0
  17. data/etc/default_skills/pwn/plugins/recon/SKILL.md +2 -0
  18. data/etc/default_skills/pwn/plugins/recon/references/urls.md +1 -0
  19. data/etc/default_skills/pwn/plugins/semgrep/SKILL.md +48 -0
  20. data/etc/default_skills/pwn/plugins/volatility/SKILL.md +1 -0
  21. data/lib/pwn/ai/agent/prompt_builder.rb +1 -1
  22. data/lib/pwn/ai/agent/tools/sessions.rb +8 -1
  23. data/lib/pwn/ffi/stdio.rb +3 -1
  24. data/lib/pwn/plugins/aflplusplus.rb +21 -1
  25. data/lib/pwn/plugins/android.rb +9 -3
  26. data/lib/pwn/plugins/credential_attack.rb +62 -3
  27. data/lib/pwn/plugins/exploit_db.rb +69 -0
  28. data/lib/pwn/plugins/exploit_dev.rb +69 -3
  29. data/lib/pwn/plugins/frida.rb +60 -9
  30. data/lib/pwn/plugins/fuzz.rb +33 -0
  31. data/lib/pwn/plugins/gdb.rb +25 -5
  32. data/lib/pwn/plugins/jobs.rb +1 -0
  33. data/lib/pwn/plugins/k8s.rb +73 -0
  34. data/lib/pwn/plugins/nuclei.rb +36 -3
  35. data/lib/pwn/plugins/packet.rb +1 -1
  36. data/lib/pwn/plugins/{doctor.rb → preflight_checker.rb} +22 -5
  37. data/lib/pwn/plugins/process_tube.rb +20 -1
  38. data/lib/pwn/plugins/radare2.rb +1 -1
  39. data/lib/pwn/plugins/recon.rb +39 -4
  40. data/lib/pwn/plugins/semgrep.rb +61 -0
  41. data/lib/pwn/plugins/sock.rb +1 -0
  42. data/lib/pwn/plugins/sqlmap.rb +1 -1
  43. data/lib/pwn/plugins/volatility.rb +21 -2
  44. data/lib/pwn/plugins.rb +4 -1
  45. data/lib/pwn/setup.rb +13 -1
  46. data/lib/pwn/version.rb +1 -1
  47. data/spec/integration/help_contract_spec.rb +2 -0
  48. data/spec/lib/pwn/plugins/exploit_db_spec.rb +13 -0
  49. data/spec/lib/pwn/plugins/k8s_spec.rb +13 -0
  50. data/spec/lib/pwn/plugins/{doctor_spec.rb → preflight_checker_spec.rb} +1 -1
  51. data/spec/lib/pwn/plugins/semgrep_spec.rb +13 -0
  52. data/spec/lib/pwn/req_queue_spec.rb +168 -0
  53. data/third_party/pwn_rdoc.jsonl +43 -9
  54. metadata +20 -8
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module PWN
7
+ module Plugins
8
+ # searchsploit / ExploitDB plus a CPE string builder for nmap versions.
9
+ module ExploitDB
10
+ public_class_method def self.required_bins
11
+ %w[searchsploit]
12
+ end
13
+
14
+ public_class_method def self.search(opts = {})
15
+ q = (opts[:query] || opts[:q]).to_s
16
+ raise 'ERROR: query is required' if q.empty?
17
+
18
+ return { error: 'searchsploit missing', query: q } unless PWN::Plugins::PreflightChecker.bin?(name: 'searchsploit')
19
+
20
+ stdout, stderr, status = Open3.capture3('searchsploit', '--json', q)
21
+ parsed = begin
22
+ JSON.parse(stdout)
23
+ rescue JSON::ParserError
24
+ {}
25
+ end
26
+ { query: q, results: parsed['RESULTS_EXPLOIT'] || parsed['results'] || [], stderr: stderr, exit: status.exitstatus }
27
+ end
28
+
29
+ public_class_method def self.cve_for_cpe(opts = {})
30
+ cpe = opts[:cpe].to_s
31
+ product = opts[:product].to_s
32
+ version = opts[:version].to_s
33
+ q = cpe
34
+ q = "#{product} #{version}".strip if q.empty?
35
+ raise 'ERROR: cpe or product is required' if q.empty?
36
+
37
+ search(query: q).merge(cpe: cpe, product: product, version: version)
38
+ end
39
+
40
+ public_class_method def self.authors
41
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
42
+ end
43
+
44
+ public_class_method def self.help
45
+ puts "USAGE:
46
+ # List host binaries this module expects to be installed.
47
+ #{self}.required_bins
48
+
49
+ # Search local ExploitDB via searchsploit --json.
50
+ #{self}.search(
51
+ query: 'required - product, CVE, or keyword',
52
+ q: 'optional - alias for query'
53
+ )
54
+
55
+ # Build a search from an nmap CPE/product/version tuple.
56
+ #{self}.cve_for_cpe(
57
+ cpe: 'optional - CPE string (e.g. cpe:/a:apache:http_server:2.4.49)',
58
+ product: 'optional - product name when no CPE',
59
+ version: 'optional - version string from nmap_it'
60
+ )
61
+
62
+ # Print the AUTHOR(S) string for this module.
63
+ #{self}.authors
64
+ "
65
+ constants.sort
66
+ end
67
+ end
68
+ end
69
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'open3'
4
+
3
5
  module PWN
4
6
  module Plugins
5
7
  # p8/p16/p32/p64, cyclic, flat, fmt writes, gadget search.
@@ -83,6 +85,8 @@ module PWN
83
85
  public_class_method def self.shellcode(opts = {})
84
86
  arch = (opts[:arch] || 'x86_64').to_s
85
87
  kind = (opts[:kind] || opts[:payload] || 'nop').to_s
88
+ return execve_bin_sh_bytes(arch: arch) if kind == 'execve_bin_sh'
89
+
86
90
  asm = case kind
87
91
  when 'nop' then 'nop'
88
92
  when 'ret' then 'ret'
@@ -93,6 +97,40 @@ module PWN
93
97
  PWN::Plugins::Assembly.asm_to_opcodes(asm: asm, arch: arch, endian: opts[:endian])
94
98
  end
95
99
 
100
+ public_class_method def self.one_gadget(opts = {})
101
+ path = (opts[:path] || opts[:libc]).to_s
102
+ raise 'ERROR: path is required' if path.empty?
103
+
104
+ return { error: 'one_gadget missing', hint: 'pwn setup --profile re', path: path } unless PWN::Plugins::PreflightChecker.bin?(name: 'one_gadget')
105
+
106
+ stdout, stderr, status = Open3.capture3('one_gadget', path)
107
+ {
108
+ path: path,
109
+ stdout: stdout,
110
+ stderr: stderr,
111
+ exit: status.exitstatus,
112
+ gadgets: stdout.scan(/0x[0-9a-fA-F]+/)
113
+ }
114
+ end
115
+
116
+ public_class_method def self.libc_offsets(opts = {})
117
+ path = opts[:path].to_s
118
+ raise 'ERROR: path is required' if path.empty?
119
+
120
+ wanted = Array(opts[:names] || %w[system execve __libc_start_main])
121
+ syms = PWN::Plugins::BinaryParser.symbols(path: path, limit: 20_000)
122
+ map = {}
123
+ Array(syms).each do |s|
124
+ n = s[:name].to_s
125
+ next if n.empty?
126
+
127
+ wanted.each do |w|
128
+ map[w.to_s] = s[:value] if n == w.to_s || n.end_with?(w.to_s)
129
+ end
130
+ end
131
+ { path: path, symbols: map }
132
+ end
133
+
96
134
  public_class_method def self.fmt_writes(opts = {})
97
135
  addr = (opts[:addr] || 0).to_i
98
136
  value = (opts[:value] || 0).to_i
@@ -109,14 +147,14 @@ module PWN
109
147
  path = opts[:path].to_s
110
148
  raise 'ERROR: path is required' if path.empty?
111
149
 
112
- if PWN::Plugins::Doctor.bin?(name: 'r2')
150
+ if PWN::Plugins::PreflightChecker.bin?(name: 'r2')
113
151
  sid = PWN::Plugins::Radare2.open(path: path)
114
152
  begin
115
153
  PWN::Plugins::Radare2.cmd(session: sid, cmd: '/R')
116
154
  ensure
117
155
  PWN::Plugins::Radare2.close(session: sid)
118
156
  end
119
- elsif PWN::Plugins::Doctor.bin?(name: 'ROPgadget')
157
+ elsif PWN::Plugins::PreflightChecker.bin?(name: 'ROPgadget')
120
158
  `ROPgadget --binary #{path}`.to_s
121
159
  else
122
160
  { error: 'r2 and ROPgadget missing', hint: 'pwn setup --profile re' }
@@ -230,7 +268,19 @@ module PWN
230
268
 
231
269
  # Run gadgets and return its result
232
270
  #{self}.gadgets(
233
- path: 'required - filesystem path to read or write'
271
+ path: 'required - filesystem path of the binary to search for gadgets'
272
+ )
273
+
274
+ # Resolve one_gadget offsets in a libc (needs the one_gadget gem/bin).
275
+ #{self}.one_gadget(
276
+ path: 'required - filesystem path to libc.so',
277
+ libc: 'optional - alias for path'
278
+ )
279
+
280
+ # Map named libc symbols to virtual addresses via BinaryParser.
281
+ #{self}.libc_offsets(
282
+ path: 'required - filesystem path to libc or any ELF',
283
+ names: 'optional - Array of symbol names (defaults to system, execve, __libc_start_main)'
234
284
  )
235
285
 
236
286
  # Print the AUTHOR(S) string for this module.
@@ -259,6 +309,22 @@ module PWN
259
309
  template = { 1 => 'C', 2 => little ? 'v' : 'n', 4 => little ? 'V' : 'N', 8 => little ? 'Q<' : 'Q>' }[bytes]
260
310
  buf.unpack1(template)
261
311
  end
312
+
313
+ private_class_method def self.execve_bin_sh_bytes(opts = {})
314
+ arch = opts[:arch].to_s
315
+ if arch.match?(/64|amd64/i)
316
+ return [
317
+ 0x48, 0x31, 0xd2, 0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e,
318
+ 0x2f, 0x73, 0x68, 0x00, 0x53, 0x48, 0x89, 0xe7, 0x50,
319
+ 0x57, 0x48, 0x89, 0xe6, 0xb0, 0x3b, 0x0f, 0x05
320
+ ].pack('C*')
321
+ end
322
+
323
+ [
324
+ 0x31, 0xc0, 0x50, 0x68, 0x2f, 0x2f, 0x73, 0x68, 0x68, 0x2f, 0x62, 0x69, 0x6e,
325
+ 0x89, 0xe3, 0x50, 0x53, 0x89, 0xe1, 0xb0, 0x0b, 0xcd, 0x80
326
+ ].pack('C*')
327
+ end
262
328
  end
263
329
  end
264
330
  end
@@ -6,18 +6,39 @@ module PWN
6
6
  module Plugins
7
7
  # Frida attach/spawn + script injection.
8
8
  module Frida
9
+ SSL_PINNING = <<~JS
10
+ Java.perform(function () {
11
+ try {
12
+ var TrustManager = Java.use('javax.net.ssl.X509TrustManager');
13
+ var SSLContext = Java.use('javax.net.ssl.SSLContext');
14
+ var TrustManagers = [Java.registerClass({
15
+ name: 'pwn.TrustAll',
16
+ implements: [TrustManager],
17
+ methods: {
18
+ checkClientTrusted: function () {},
19
+ checkServerTrusted: function () {},
20
+ getAcceptedIssuers: function () { return []; }
21
+ }
22
+ }).$new()];
23
+ var ctx = SSLContext.getInstance('TLS');
24
+ ctx.init(null, TrustManagers, null);
25
+ SSLContext.getDefault.implementation = function () { return ctx; };
26
+ } catch (e) {}
27
+ });
28
+ JS
29
+
9
30
  public_class_method def self.required_bins
10
31
  %w[frida]
11
32
  end
12
33
 
13
34
  public_class_method def self.ps(opts = {})
14
- PWN::Plugins::Doctor.require_bin!(name: 'frida')
35
+ PWN::Plugins::PreflightChecker.require_bin!(name: 'frida')
15
36
  stdout, stderr, status = Open3.capture3('frida-ps', *Array(opts[:extra]))
16
37
  { stdout: stdout, stderr: stderr, exit: status.exitstatus }
17
38
  end
18
39
 
19
40
  public_class_method def self.attach(opts = {})
20
- PWN::Plugins::Doctor.require_bin!(name: 'frida')
41
+ PWN::Plugins::PreflightChecker.require_bin!(name: 'frida')
21
42
  target = opts[:target] || opts[:name]
22
43
  script = opts[:script].to_s
23
44
  raise 'ERROR: target is required' if target.to_s.empty?
@@ -29,6 +50,23 @@ module PWN
29
50
  { stdout: stdout, stderr: stderr, exit: status.exitstatus }
30
51
  end
31
52
 
53
+ public_class_method def self.spawn(opts = {})
54
+ PWN::Plugins::PreflightChecker.require_bin!(name: 'frida')
55
+ path = (opts[:path] || opts[:file]).to_s
56
+ raise 'ERROR: path is required' if path.empty?
57
+
58
+ cmd = ['frida', '-f', path]
59
+ cmd += ['-l', opts[:script_path].to_s] if opts[:script_path]
60
+ cmd += ['-e', opts[:script].to_s] unless opts[:script].to_s.empty?
61
+ stdout, stderr, status = Open3.capture3(*cmd)
62
+ { stdout: stdout, stderr: stderr, exit: status.exitstatus }
63
+ end
64
+
65
+ public_class_method def self.ssl_pinning_script(opts = {})
66
+ opts[:android]
67
+ SSL_PINNING
68
+ end
69
+
32
70
  public_class_method def self.authors
33
71
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
34
72
  end
@@ -38,17 +76,30 @@ module PWN
38
76
  # List host binaries this module expects to be installed.
39
77
  #{self}.required_bins
40
78
 
41
- # Run ps and return its result
79
+ # List processes via frida-ps.
42
80
  #{self}.ps(
43
- extra: 'optional - Array extra value consumed by #ps'
81
+ extra: 'optional - extra frida-ps argv'
44
82
  )
45
83
 
46
- # Run attach and return its result
84
+ # Attach to a running process and optionally inject a script.
47
85
  #{self}.attach(
48
- target: 'required - hostname, IP, or CIDR to scan (defaults to opts[:name])',
49
- name: 'optional - binary or identifier name',
50
- script: 'required - script value consumed by #attach',
51
- script_path: 'optional - script path value consumed by #attach'
86
+ target: 'required - process name (defaults to opts[:name])',
87
+ name: 'optional - alias for target',
88
+ script: 'optional - JavaScript source to pass with -e',
89
+ script_path: 'optional - path to a .js file for -l'
90
+ )
91
+
92
+ # Spawn a binary under Frida and inject a script.
93
+ #{self}.spawn(
94
+ path: 'required - filesystem path to the binary',
95
+ file: 'optional - alias for path',
96
+ script: 'optional - JavaScript source to pass with -e',
97
+ script_path: 'optional - path to a .js file for -l'
98
+ )
99
+
100
+ # Return an SSL-pinning bypass JavaScript template.
101
+ #{self}.ssl_pinning_script(
102
+ android: 'optional - currently always returns the Java TrustManager template'
52
103
  )
53
104
 
54
105
  # Print the AUTHOR(S) string for this module.
@@ -171,6 +171,25 @@ module PWN
171
171
  sock_obj = PWN::Plugins::Sock.disconnect(sock_obj: sock_obj) unless sock_obj.nil?
172
172
  end
173
173
 
174
+ public_class_method def self.http(opts = {})
175
+ template = opts[:request].to_s
176
+ raise 'ERROR: request is required' if template.empty?
177
+
178
+ token = (opts[:token] || 'FUZZ').to_s
179
+ Array(opts[:dictionary] || opts[:payloads] || ['A']).map do |p|
180
+ template.gsub(token, p.to_s)
181
+ end
182
+ end
183
+
184
+ public_class_method def self.file_format(opts = {})
185
+ path = opts[:path].to_s
186
+ raise 'ERROR: path is required' if path.empty?
187
+
188
+ seed = File.binread(path)
189
+ dict = Array(opts[:dictionary] || ["\xff"])
190
+ dict.map { |blob| seed + blob.to_s }
191
+ end
192
+
174
193
  # Author(s):: 0day Inc. <support@0dayinc.com>
175
194
 
176
195
  public_class_method def self.authors
@@ -199,6 +218,20 @@ module PWN
199
218
  char_encoding: 'optional - character encoding returned by PWN::Plugins::Char.list_encoders (defaults to UTF-8)'
200
219
  )
201
220
 
221
+ # Expand an HTTP request template by substituting FUZZ with dictionary payloads.
222
+ #{self}.http(
223
+ request: 'required - HTTP request template containing FUZZ (or token:)',
224
+ dictionary: 'optional - Array of payload strings',
225
+ payloads: 'optional - alias for dictionary',
226
+ token: 'optional - placeholder to replace (defaults to FUZZ)'
227
+ )
228
+
229
+ # Append dictionary blobs to a seed file for coverage-guided or dumb file fuzzing.
230
+ #{self}.file_format(
231
+ path: 'required - filesystem path to the seed file',
232
+ dictionary: 'optional - Array of binary/string mutations'
233
+ )
234
+
202
235
  # Print the AUTHOR(S) string for this module.
203
236
  #{self}.authors
204
237
  "
@@ -11,7 +11,7 @@ module PWN
11
11
  end
12
12
 
13
13
  public_class_method def self.batch(opts = {})
14
- PWN::Plugins::Doctor.require_bin!(name: 'gdb')
14
+ PWN::Plugins::PreflightChecker.require_bin!(name: 'gdb')
15
15
  binary = opts[:binary].to_s
16
16
  cmds = Array(opts[:commands] || opts[:cmds])
17
17
  args = Array(opts[:args])
@@ -38,7 +38,7 @@ module PWN
38
38
  batch(opts.merge(commands: ['checksec']))
39
39
  rescue StandardError
40
40
  path = opts[:binary].to_s
41
- return { error: 'gdb missing' } unless PWN::Plugins::Doctor.bin?(name: 'gdb')
41
+ return { error: 'gdb missing' } unless PWN::Plugins::PreflightChecker.bin?(name: 'gdb')
42
42
 
43
43
  { hint: 'pwndbg/gef checksec not loaded', binary: path }
44
44
  end
@@ -48,7 +48,18 @@ module PWN
48
48
  binary = opts[:binary].to_s
49
49
  raise 'ERROR: core is required' if core.empty?
50
50
 
51
- batch(opts.merge(commands: ['bt', 'info registers'], binary: binary, args: []))
51
+ PWN::Plugins::PreflightChecker.require_bin!(name: 'gdb')
52
+ binary = opts[:binary].to_s
53
+ argv = ['gdb', '--batch', '--quiet', '-c', core, '-ex', 'set pagination off', '-ex', 'bt', '-ex', 'info registers']
54
+ argv << binary unless binary.empty?
55
+ stdout, stderr, status = Open3.capture3(*argv)
56
+ { stdout: stdout, stderr: stderr, exit: status.exitstatus }
57
+ end
58
+
59
+ public_class_method def self.breakpoints(opts = {})
60
+ bps = Array(opts[:breakpoints] || opts[:bp])
61
+ cmds = bps.map { |b| "break #{b}" } + Array(opts[:commands]) + %w[run bt]
62
+ batch(opts.merge(commands: cmds))
52
63
  end
53
64
 
54
65
  public_class_method def self.authors
@@ -81,8 +92,17 @@ module PWN
81
92
 
82
93
  # Run core and return its result
83
94
  #{self}.core(
84
- core: 'required - core value consumed by #core',
85
- binary: 'optional - binary value consumed by #core'
95
+ core: 'required - filesystem path to the core dump',
96
+ binary: 'optional - matching binary for symbols'
97
+ )
98
+
99
+ # Plant breakpoints then run and dump a backtrace.
100
+ #{self}.breakpoints(
101
+ binary: 'optional - binary to debug',
102
+ breakpoints: 'optional - Array of break specs (e.g. main or *0x401000)',
103
+ bp: 'optional - alias for breakpoints',
104
+ commands: 'optional - extra gdb commands after the breaks',
105
+ args: 'optional - Array of inferior argv'
86
106
  )
87
107
 
88
108
  # Print the AUTHOR(S) string for this module.
@@ -34,6 +34,7 @@ module PWN
34
34
  started_at: Time.now.utc.iso8601
35
35
  }
36
36
  File.write(meta, JSON.generate(row))
37
+ PWN::Plugins::ArtifactRegistry.register(session_id: opts[:session_id], path: log, kind: 'job-log') if opts[:session_id] && defined?(PWN::Plugins::ArtifactRegistry) && File.file?(log)
37
38
  row
38
39
  end
39
40
 
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module PWN
6
+ module Plugins
7
+ # trivy / kube-hunter wrappers plus docker-socket preflight.
8
+ module K8s
9
+ public_class_method def self.required_bins
10
+ %w[trivy]
11
+ end
12
+
13
+ public_class_method def self.trivy(opts = {})
14
+ PWN::Plugins::PreflightChecker.require_bin!(name: 'trivy')
15
+ target = opts[:target] || opts[:image] || opts[:path]
16
+ raise 'ERROR: target is required' if target.to_s.empty?
17
+
18
+ mode = (opts[:mode] || :image).to_s
19
+ cmd = ['trivy', mode, '--format', 'json', target.to_s]
20
+ stdout, stderr, status = Open3.capture3(*cmd)
21
+ { stdout: stdout, stderr: stderr, exit: status.exitstatus }
22
+ end
23
+
24
+ public_class_method def self.kube_hunter(opts = {})
25
+ return { error: 'kube-hunter missing', hint: 'pip install kube-hunter' } unless PWN::Plugins::PreflightChecker.bin?(name: 'kube-hunter')
26
+
27
+ cmd = ['kube-hunter']
28
+ cmd += ['--remote', opts[:remote].to_s] if opts[:remote]
29
+ cmd << '--report' << 'json'
30
+ stdout, stderr, status = Open3.capture3(*cmd)
31
+ { stdout: stdout, stderr: stderr, exit: status.exitstatus }
32
+ end
33
+
34
+ public_class_method def self.docker_socket?(opts = {})
35
+ path = (opts[:path] || '/var/run/docker.sock').to_s
36
+ PWN::Plugins::PreflightChecker.service?(name: 'docker', path: path)
37
+ end
38
+
39
+ public_class_method def self.authors
40
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
41
+ end
42
+
43
+ public_class_method def self.help
44
+ puts "USAGE:
45
+ # List host binaries this module expects to be installed.
46
+ #{self}.required_bins
47
+
48
+ # Scan a container image or filesystem with trivy.
49
+ #{self}.trivy(
50
+ target: 'required - image name or path',
51
+ image: 'optional - alias for target',
52
+ path: 'optional - alias for target',
53
+ mode: 'optional - trivy subcommand (defaults to image)'
54
+ )
55
+
56
+ # Run kube-hunter (remote cluster optional).
57
+ #{self}.kube_hunter(
58
+ remote: 'optional - API server host for --remote'
59
+ )
60
+
61
+ # True when the docker unix socket is present.
62
+ #{self}.docker_socket?(
63
+ path: 'optional - socket path (defaults to /var/run/docker.sock)'
64
+ )
65
+
66
+ # Print the AUTHOR(S) string for this module.
67
+ #{self}.authors
68
+ "
69
+ constants.sort
70
+ end
71
+ end
72
+ end
73
+ end
@@ -12,7 +12,7 @@ module PWN
12
12
  end
13
13
 
14
14
  public_class_method def self.scan(opts = {})
15
- PWN::Plugins::Doctor.require_bin!(name: 'nuclei')
15
+ PWN::Plugins::PreflightChecker.require_bin!(name: 'nuclei')
16
16
  target = opts[:target] || opts[:url]
17
17
  raise 'ERROR: target is required' if target.to_s.empty?
18
18
 
@@ -26,7 +26,28 @@ module PWN
26
26
  rescue JSON::ParserError
27
27
  nil
28
28
  end
29
- { findings: findings, stderr: stderr, exit: status.exitstatus }
29
+ { findings: to_findings(rows: findings), raw: findings, stderr: stderr, exit: status.exitstatus }
30
+ end
31
+
32
+ public_class_method def self.to_findings(opts = {})
33
+ Array(opts[:rows] || opts[:findings]).map do |r|
34
+ r = r.transform_keys(&:to_s) if r.is_a?(Hash)
35
+ info = r['info'] || {}
36
+ info = info.transform_keys(&:to_s) if info.is_a?(Hash)
37
+ {
38
+ title: info['name'] || r['template-id'] || 'nuclei finding',
39
+ severity: (info['severity'] || 'info').to_s,
40
+ url: r['matched-at'] || r['host'],
41
+ template: r['template-id'],
42
+ description: info['description']
43
+ }
44
+ end
45
+ end
46
+
47
+ public_class_method def self.to_defectdojo(opts = {})
48
+ to_findings(opts).map do |f|
49
+ { title: f[:title], severity: f[:severity], description: "#{f[:url]} #{f[:template]}".strip }
50
+ end
30
51
  end
31
52
 
32
53
  public_class_method def self.authors
@@ -44,7 +65,19 @@ module PWN
44
65
  url: 'optional - HTTP(S) URL',
45
66
  severity: 'optional - severity value consumed by #scan',
46
67
  templates: 'optional - templates value consumed by #scan',
47
- rate_limit: 'optional - rate limit value consumed by #scan'
68
+ rate_limit: 'optional - requests per second passed to nuclei -rate-limit'
69
+ )
70
+
71
+ # Map nuclei JSONL rows into report-shaped finding hashes.
72
+ #{self}.to_findings(
73
+ rows: 'optional - Array of parsed nuclei JSONL objects',
74
+ findings: 'optional - alias for rows'
75
+ )
76
+
77
+ # Shape findings for a DefectDojo import payload.
78
+ #{self}.to_defectdojo(
79
+ rows: 'optional - Array of parsed nuclei JSONL objects',
80
+ findings: 'optional - alias for rows'
48
81
  )
49
82
 
50
83
  # Print the AUTHOR(S) string for this module.
@@ -1085,7 +1085,7 @@ module PWN
1085
1085
  # )
1086
1086
 
1087
1087
  public_class_method def self.send(opts = {})
1088
- PWN::Plugins::Doctor.require_cap_net_raw! if defined?(PWN::Plugins::Doctor)
1088
+ PWN::Plugins::PreflightChecker.require_cap_net_raw! if defined?(PWN::Plugins::PreflightChecker)
1089
1089
  pkt = opts[:pkt]
1090
1090
 
1091
1091
  if opts[:iface]
@@ -5,9 +5,10 @@ module PWN
5
5
  # Plugin/binary/capability preflight. Plugins declare required_bins /
6
6
  # required_caps; HOST summaries list degraded modules without autoloading
7
7
  # the whole plugin tree.
8
- module Doctor
8
+ module PreflightChecker
9
9
  class MissingBinary < StandardError; end
10
10
  class MissingCapability < StandardError; end
11
+ class MissingService < StandardError; end
11
12
 
12
13
  PLUGIN_DEPS = {
13
14
  'PWN::Plugins::Radare2' => { bins: %w[r2] },
@@ -21,7 +22,8 @@ module PWN
21
22
  'PWN::Plugins::Metasploit' => { bins: %w[msfconsole] },
22
23
  'PWN::Plugins::BurpSuite' => { bins: %w[burpsuite] },
23
24
  'PWN::Plugins::Zaproxy' => { bins: %w[zaproxy] },
24
- 'PWN::Plugins::Packet' => { bins: [], caps: %w[CAP_NET_RAW] }
25
+ 'PWN::Plugins::Packet' => { bins: [], caps: %w[CAP_NET_RAW] },
26
+ 'PWN::Plugins::K8s' => { bins: %w[trivy], services: [{ name: 'docker', path: '/var/run/docker.sock' }] }
25
27
  }.freeze
26
28
 
27
29
  public_class_method def self.required_bins
@@ -65,6 +67,13 @@ module PWN
65
67
  'or run as root. Fallback: PWN::Plugins::Sock.connect (no raw).'
66
68
  end
67
69
 
70
+ public_class_method def self.service?(opts = {})
71
+ path = opts[:path].to_s
72
+ return File.socket?(path) unless path.empty?
73
+
74
+ false
75
+ end
76
+
68
77
  public_class_method def self.check(opts = {})
69
78
  deps = PLUGIN_DEPS
70
79
  if opts[:names]
@@ -74,17 +83,19 @@ module PWN
74
83
  deps.map do |plugin, dep|
75
84
  bins = Array(dep[:bins])
76
85
  caps = Array(dep[:caps])
86
+ services = Array(dep[:services])
77
87
  missing_bins = bins.reject { |b| bin?(name: b) }
78
88
  missing_caps = []
79
89
  missing_caps << 'CAP_NET_RAW' if caps.include?('CAP_NET_RAW') && !cap_net_raw?
80
- status = if missing_bins.empty? && missing_caps.empty?
90
+ missing_services = services.reject { |s| service?(name: s[:name] || s['name'], path: s[:path] || s['path']) }.map { |s| s[:name] || s['name'] }
91
+ status = if missing_bins.empty? && missing_caps.empty? && missing_services.empty?
81
92
  :ok
82
93
  elsif bins.any? && missing_bins.length == bins.length
83
94
  :dead
84
95
  else
85
96
  :degraded
86
97
  end
87
- { plugin: plugin, status: status, missing_bins: missing_bins, missing_caps: missing_caps }
98
+ { plugin: plugin, status: status, missing_bins: missing_bins, missing_caps: missing_caps, missing_services: missing_services }
88
99
  end
89
100
  end
90
101
 
@@ -95,7 +106,7 @@ module PWN
95
106
  degraded = rows.select { |r| r[:status] == :degraded }
96
107
  lines = ["HOST plugins ok=#{rows.count { |r| r[:status] == :ok }} degraded=#{degraded.length} dead=#{dead.length}"]
97
108
  (degraded + dead).first(cap).each do |r|
98
- miss = (r[:missing_bins] + r[:missing_caps]).join(',')
109
+ miss = (Array(r[:missing_bins]) + Array(r[:missing_caps]) + Array(r[:missing_services])).join(',')
99
110
  lines << " #{r[:status]} #{r[:plugin]} missing=#{miss}"
100
111
  end
101
112
  lines.join("\n")
@@ -128,6 +139,12 @@ module PWN
128
139
  # Run require cap net raw and return its result
129
140
  #{self}.require_cap_net_raw!
130
141
 
142
+ # True when a unix socket/service path exists.
143
+ #{self}.service?(
144
+ name: 'optional - service name for the report row',
145
+ path: 'required - unix socket path (e.g. /var/run/docker.sock)'
146
+ )
147
+
131
148
  # Run check and return its result
132
149
  #{self}.check(
133
150
  names: 'optional - Array names value consumed by #check'
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'pty'
4
4
  require 'timeout'
5
+ require 'socket'
5
6
 
6
7
  module PWN
7
8
  module Plugins
@@ -24,6 +25,17 @@ module PWN
24
25
  { id: id, pid: pid }
25
26
  end
26
27
 
28
+ public_class_method def self.connect(opts = {})
29
+ host = (opts[:host] || opts[:target]).to_s
30
+ port = opts[:port].to_i
31
+ raise 'ERROR: host and port are required' if host.empty? || port <= 0
32
+
33
+ sock = TCPSocket.new(host, port)
34
+ id = "sock_#{sock.object_id}"
35
+ @tubes[id] = { r: sock, w: sock, pid: nil, buf: +'' }
36
+ { id: id, host: host, port: port }
37
+ end
38
+
27
39
  public_class_method def self.write_line(opts = {})
28
40
  t = tube!(opts)
29
41
  line = opts[:line] || opts[:data] || ''
@@ -59,7 +71,7 @@ module PWN
59
71
  t = tube!(opts)
60
72
  t[:w].close
61
73
  t[:r].close
62
- Process.kill('TERM', t[:pid])
74
+ Process.kill('TERM', t[:pid]) if t[:pid]
63
75
  @tubes.delete(opts[:id].to_s)
64
76
  true
65
77
  rescue StandardError
@@ -82,6 +94,13 @@ module PWN
82
94
  command: 'optional - command value consumed by #spawn'
83
95
  )
84
96
 
97
+ # Connect a TCP tube with the same write_line/recvuntil API as spawn.
98
+ #{self}.connect(
99
+ host: 'required - hostname or IP address (defaults to opts[:target])',
100
+ target: 'optional - hostname, IP, or CIDR to scan',
101
+ port: 'required - TCP/UDP port number'
102
+ )
103
+
85
104
  # Run write line and return its result
86
105
  #{self}.write_line(
87
106
  line: 'optional - line value consumed by #write_line',