pwn 0.5.716 → 0.5.718

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +2 -0
  4. data/etc/default_skills/pwn/ai/agent/tool_guard/SKILL.md +1 -0
  5. data/etc/default_skills/pwn/ai/agent/tools/verify/SKILL.md +45 -0
  6. data/etc/default_skills/pwn/plugins/aflplusplus/SKILL.md +1 -0
  7. data/etc/default_skills/pwn/plugins/exploit_db/SKILL.md +7 -0
  8. data/etc/default_skills/pwn/plugins/exploit_db/references/urls.md +4 -0
  9. data/etc/default_skills/pwn/plugins/exploit_dev/SKILL.md +2 -0
  10. data/etc/default_skills/pwn/plugins/findings/SKILL.md +2 -0
  11. data/etc/default_skills/pwn/plugins/jobs/SKILL.md +2 -0
  12. data/etc/default_skills/pwn/plugins/process_tube/SKILL.md +3 -0
  13. data/etc/default_skills/pwn/reports/sarif/SKILL.md +51 -0
  14. data/etc/default_skills/pwn/reports/sarif/references/urls.md +3 -0
  15. data/lib/pwn/ai/agent/dispatch.rb +1 -1
  16. data/lib/pwn/ai/agent/loop.rb +46 -12
  17. data/lib/pwn/ai/agent/mistakes.rb +5 -2
  18. data/lib/pwn/ai/agent/prompt_builder.rb +12 -1
  19. data/lib/pwn/ai/agent/reward.rb +33 -5
  20. data/lib/pwn/ai/agent/swarm.rb +45 -0
  21. data/lib/pwn/ai/agent/tool_guard.rb +38 -6
  22. data/lib/pwn/ai/agent/tools/finding_record.rb +32 -11
  23. data/lib/pwn/ai/agent/tools/fuzz_campaign.rb +3 -1
  24. data/lib/pwn/ai/agent/tools/intel_lookup.rb +7 -3
  25. data/lib/pwn/ai/agent/tools/pty_session.rb +43 -0
  26. data/lib/pwn/ai/agent/tools/session_export.rb +2 -2
  27. data/lib/pwn/ai/agent/tools/shell.rb +4 -1
  28. data/lib/pwn/ai/agent/tools/verify.rb +63 -0
  29. data/lib/pwn/memory.rb +56 -6
  30. data/lib/pwn/plugins/aflplusplus.rb +18 -0
  31. data/lib/pwn/plugins/binary_parser.rb +23 -5
  32. data/lib/pwn/plugins/detect_os.rb +2 -14
  33. data/lib/pwn/plugins/exploit_db.rb +155 -27
  34. data/lib/pwn/plugins/exploit_dev.rb +33 -5
  35. data/lib/pwn/plugins/findings.rb +41 -2
  36. data/lib/pwn/plugins/jobs.rb +43 -2
  37. data/lib/pwn/plugins/preflight_checker.rb +11 -1
  38. data/lib/pwn/plugins/process_tube.rb +44 -0
  39. data/lib/pwn/plugins/radare2.rb +9 -1
  40. data/lib/pwn/reports/sarif.rb +60 -0
  41. data/lib/pwn/reports.rb +1 -0
  42. data/lib/pwn/sessions.rb +22 -4
  43. data/lib/pwn/version.rb +1 -1
  44. data/spec/lib/pwn/ai/agent/fixtures/error_class_docker.json +1 -1
  45. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +2 -1
  46. data/spec/lib/pwn/ai/agent/reward_spec.rb +1 -1
  47. data/spec/lib/pwn/ai/agent/tool_guard_spec.rb +2 -1
  48. data/spec/lib/pwn/ai/agent/tools/pty_session_spec.rb +1 -0
  49. data/spec/lib/pwn/ai/agent/tools/verify_spec.rb +24 -0
  50. data/spec/lib/pwn/plugins/binary_parser_spec.rb +14 -0
  51. data/spec/lib/pwn/plugins/detect_os_spec.rb +0 -1
  52. data/spec/lib/pwn/plugins/exploit_db_spec.rb +45 -5
  53. data/spec/lib/pwn/plugins/findings_spec.rb +13 -1
  54. data/spec/lib/pwn/reports/sarif_spec.rb +28 -0
  55. data/third_party/pwn_rdoc.jsonl +28 -1
  56. metadata +12 -5
  57. data/etc/intel/files_exploits.csv +0 -2
@@ -13,12 +13,16 @@ PWN::AI::Agent::Registry.register(
13
13
  properties: {
14
14
  query: { type: 'string' },
15
15
  cve: { type: 'string' },
16
- cpe: { type: 'string' }
16
+ cpe: { type: 'string' },
17
+ kind: { type: 'string' },
18
+ binary: { type: 'string' },
19
+ technique: { type: 'string' }
17
20
  }
18
21
  }
19
22
  },
20
23
  handler: lambda { |args|
21
- q = args[:query] || args['query'] || args[:cve] || args['cve'] || args[:cpe] || args['cpe']
22
- PWN::Plugins::ExploitDB.search(query: q)
24
+ q = args[:query] || args['query'] || args[:cve] || args['cve'] || args[:cpe] || args['cpe'] || args[:binary] || args['binary']
25
+ kind = args[:kind] || args['kind'] || 'cve'
26
+ PWN::Plugins::ExploitDB.lookup(kind: kind, query: q, binary: args[:binary] || args['binary'], technique: args[:technique] || args['technique'])
23
27
  }
24
28
  )
@@ -71,3 +71,46 @@ PWN::AI::Agent::Registry.register(
71
71
  PWN::Plugins::ProcessTube.close(id: args[:id] || args['id'])
72
72
  }
73
73
  )
74
+ PWN::AI::Agent::Registry.register(
75
+ name: 'pty_session',
76
+ toolset: 'terminal',
77
+ schema: {
78
+ name: 'pty_session',
79
+ description: 'Interactive PTY: op spawn|expect|send|stream|close.',
80
+ parameters: {
81
+ type: 'object',
82
+ properties: {
83
+ op: { type: 'string' },
84
+ session_id: { type: 'string' },
85
+ id: { type: 'string' },
86
+ cmd: { type: 'string' },
87
+ command: { type: 'string' },
88
+ pattern: { type: 'string' },
89
+ until: { type: 'string' },
90
+ timeout: { type: 'integer' },
91
+ data: { type: 'string' },
92
+ line: { type: 'string' },
93
+ offset: { type: 'integer' }
94
+ },
95
+ required: %w[op]
96
+ }
97
+ },
98
+ handler: lambda { |args|
99
+ op = (args[:op] || args['op']).to_s
100
+ id = args[:id] || args[:session_id] || args['id'] || args['session_id']
101
+ case op
102
+ when 'spawn'
103
+ PWN::Plugins::ProcessTube.spawn(cmd: args[:cmd] || args[:command] || args['cmd'])
104
+ when 'expect'
105
+ PWN::Plugins::ProcessTube.expect(id: id, until: args[:pattern] || args[:until] || args['pattern'], timeout: args[:timeout])
106
+ when 'send'
107
+ PWN::Plugins::ProcessTube.write_line(id: id, line: args[:data] || args[:line] || args['data'])
108
+ when 'stream'
109
+ PWN::Plugins::ProcessTube.stream(id: id, offset: args[:offset])
110
+ when 'close'
111
+ PWN::Plugins::ProcessTube.close(id: id)
112
+ else
113
+ { error: "unknown op #{op}" }
114
+ end
115
+ }
116
+ )
@@ -10,11 +10,11 @@ PWN::AI::Agent::Registry.register(
10
10
  description: 'Export a session transcript plus attached artifacts as a tar.gz under ~/.pwn/exports.',
11
11
  parameters: {
12
12
  type: 'object',
13
- properties: { session_id: { type: 'string' } },
13
+ properties: { session_id: { type: 'string' }, engagement_id: { type: 'string' } },
14
14
  required: %w[session_id]
15
15
  }
16
16
  },
17
17
  handler: lambda { |args|
18
- PWN::Sessions.export(session_id: args[:session_id] || args['session_id'])
18
+ PWN::Sessions.export(session_id: args[:session_id] || args['session_id'], engagement_id: args[:engagement_id] || args['engagement_id'])
19
19
  }
20
20
  )
@@ -59,7 +59,10 @@ PWN::AI::Agent::Registry.register(
59
59
  if cmd.empty? || PWN::AI::Agent::ToolGuard.placeholder?(text: cmd)
60
60
  return PWN::AI::Agent::ToolGuard.invalid_payload(
61
61
  hint: 'command is required (string). Do not send ..., {...}, {…}, or empty. ' \
62
- 'Example: shell(command="uname -r").'
62
+ 'Example: shell(command="uname -r").',
63
+ offending_token: '...',
64
+ code: 'SYNTAX_DENY',
65
+ suggestion: 'replace ellipsis or placeholders with a concrete command'
63
66
  )
64
67
  end
65
68
 
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+ require 'socket'
6
+ require 'pwn/ai/agent/registry'
7
+
8
+ PWN::AI::Agent::Registry.register(
9
+ name: 'verify',
10
+ toolset: 'sessions',
11
+ schema: {
12
+ name: 'verify',
13
+ description: 'Run machine-checkable acceptance checks (file_exists, file_sha256, file_grep, port_open, http_status, cmd_exit0).',
14
+ parameters: {
15
+ type: 'object',
16
+ properties: {
17
+ checks: { type: 'array' }
18
+ },
19
+ required: %w[checks]
20
+ }
21
+ },
22
+ handler: lambda { |args|
23
+ checks = Array(args[:checks] || args['checks'])
24
+ results = checks.map do |row|
25
+ row = {} unless row.is_a?(Hash)
26
+ type = (row[:type] || row['type']).to_s
27
+ a = row[:args] || row['args'] || row
28
+ case type
29
+ when 'file_exists'
30
+ path = (a[:path] || a['path']).to_s
31
+ { type: type, passed: File.file?(path), path: path }
32
+ when 'file_sha256'
33
+ path = (a[:path] || a['path']).to_s
34
+ want = (a[:sha256] || a['sha256']).to_s
35
+ have = File.file?(path) ? Digest::SHA256.file(path).hexdigest : ''
36
+ { type: type, passed: !want.empty? && have == want, path: path }
37
+ when 'file_grep'
38
+ path = (a[:path] || a['path']).to_s
39
+ rx = (a[:pattern] || a['pattern']).to_s
40
+ body = File.file?(path) ? File.read(path) : ''
41
+ { type: type, passed: !rx.empty? && body.match?(Regexp.new(rx)), path: path }
42
+ when 'port_open'
43
+ host = (a[:host] || a['host'] || '127.0.0.1').to_s
44
+ port = (a[:port] || a['port']).to_i
45
+ ok = begin
46
+ TCPSocket.new(host, port).close
47
+ true
48
+ rescue StandardError
49
+ false
50
+ end
51
+ { type: type, passed: ok, host: host, port: port }
52
+ when 'http_status'
53
+ { type: type, passed: false, error: 'use TransparentBrowser for HTTP checks' }
54
+ when 'cmd_exit0'
55
+ cmd = (a[:command] || a['command']).to_s
56
+ { type: type, passed: system(cmd) == true }
57
+ else
58
+ { type: type, passed: false, error: 'unknown check type' }
59
+ end
60
+ end
61
+ { passed: results.all? { |r| r[:passed] }, results: results }
62
+ }
63
+ )
data/lib/pwn/memory.rb CHANGED
@@ -15,6 +15,7 @@ module PWN
15
15
  # via ruby code blocks during execution loops.
16
16
  module Memory
17
17
  MEMORY_FILE = File.join(Dir.home, '.pwn', 'memory.json')
18
+ ARCHIVE_FILE = File.join(Dir.home, '.pwn', 'memory.archive.json')
18
19
  # Lean retention — keep RL-quality signal, drop ephemeral bulk.
19
20
  VALUE_MAX_CHARS = 2_000
20
21
  PROTECT_KEY_PREFIXES = %w[operator_pref_ process_sop_ mistake_fix_ memory_].freeze
@@ -111,7 +112,10 @@ module PWN
111
112
  source: (opts[:source] || 'pwn-ai').to_s,
112
113
  confidence: opts[:confidence]&.to_f&.clamp(0.0, 1.0),
113
114
  importance: opts[:importance]&.to_f&.clamp(0.0, 1.0),
114
- ttl: opts[:ttl]&.to_i
115
+ ttl: opts[:ttl]&.to_i,
116
+ namespace: (opts[:namespace] || 'default').to_s,
117
+ last_hit: Time.now.utc.iso8601,
118
+ hits: 1
115
119
  }.compact
116
120
  mem[key.to_sym] = entry
117
121
  save(mem: mem)
@@ -510,17 +514,62 @@ module PWN
510
514
  # context = PWN::Memory.to_context(limit: 20)
511
515
  # (used internally by pwn-ai hook to inject into system prompt)
512
516
  public_class_method def self.to_context(opts = {})
513
- limit = opts[:limit] || 20
514
- mem = recall(limit: limit, include_session: false)
515
- return '' if mem.empty?
517
+ limit = (opts[:limit] || 12).to_i
518
+ limit = 12 if limit <= 0
519
+ mem = load
520
+ now = Time.now.utc
521
+ live = {}
522
+ expired = {}
523
+ mem.each do |k, v|
524
+ next unless v.is_a?(Hash)
525
+
526
+ ttl = v[:ttl].to_i
527
+ ts = begin
528
+ Time.parse(v[:timestamp].to_s)
529
+ rescue StandardError
530
+ now
531
+ end
532
+ if ttl.positive? && (now - ts) > ttl
533
+ expired[k] = v
534
+ else
535
+ live[k] = v
536
+ end
537
+ end
538
+ archive_expired!(rows: expired) unless expired.empty?
539
+ ranked = live.sort_by do |_k, v|
540
+ hits = v[:hits].to_i
541
+ hits = 1 if hits <= 0
542
+ rec = begin
543
+ Time.parse((v[:last_hit] || v[:timestamp]).to_s).to_f
544
+ rescue StandardError
545
+ 0
546
+ end
547
+ -(hits * rec)
548
+ end.first(limit)
549
+ return '' if ranked.empty?
516
550
 
517
551
  ctx = "\n\nPERSISTENT MEMORY (cross-session facts, prefs, lessons - use PWN::Memory.remember to store new ones):\n"
518
- mem.each do |k, v|
552
+ ranked.each do |k, v|
519
553
  ctx += "- #{k} [#{v[:category]} @ #{v[:timestamp]}]: #{v[:value].to_s[0, 300]}\n"
520
554
  end
521
555
  ctx
522
556
  end
523
557
 
558
+ private_class_method def self.archive_expired!(opts = {})
559
+ rows = opts[:rows]
560
+ return if rows.nil? || rows.empty?
561
+
562
+ arch = File.file?(ARCHIVE_FILE) ? JSON.parse(File.read(ARCHIVE_FILE), symbolize_names: true) : {}
563
+ arch.merge!(rows)
564
+ FileUtils.mkdir_p(File.dirname(ARCHIVE_FILE))
565
+ File.write(ARCHIVE_FILE, JSON.pretty_generate(arch))
566
+ mem = load
567
+ rows.each_key { |k| mem.delete(k) }
568
+ save(mem: mem, force: mem.empty?)
569
+ rescue StandardError
570
+ nil
571
+ end
572
+
524
573
  # True when a memory key must survive cap eviction / age GC.
525
574
  public_class_method def self.protected_entry?(opts = {})
526
575
  key = opts[:key].to_s
@@ -618,7 +667,8 @@ module PWN
618
667
  source: 'optional - :human | :reflect | :heuristic | :resolve | :consolidate (M3 provenance)',
619
668
  confidence: 'optional - 0.0..1.0 how sure the writer was (M3)',
620
669
  importance: 'optional - 0.0..1.0 retrieval/eviction weight (M2/M3)',
621
- ttl: 'optional - seconds until stale (M3; consolidate evicts stale low-conf first)'
670
+ ttl: 'optional - seconds until stale (M3; consolidate evicts stale low-conf first)',
671
+ namespace: 'optional - memory namespace string (defaults to default)'
622
672
  )
623
673
 
624
674
  # Run recall and return its result
@@ -60,6 +60,17 @@ module PWN
60
60
  }
61
61
  end
62
62
 
63
+ public_class_method def self.minimize(opts = {})
64
+ crash = opts[:crash].to_s
65
+ out = opts[:out].to_s
66
+ target = opts[:target].to_s
67
+ raise 'ERROR: crash, out, and target are required' if crash.empty? || out.empty? || target.empty?
68
+ return { error: 'afl-tmin missing', hint: 'pwn setup --profile re' } unless PWN::Plugins::PreflightChecker.bin?(name: 'afl-tmin')
69
+
70
+ stdout, stderr, status = Open3.capture3('afl-tmin', '-i', crash, '-o', out, '--', target)
71
+ { stdout: stdout, stderr: stderr, exit: status.exitstatus, out: out }
72
+ end
73
+
63
74
  public_class_method def self.authors
64
75
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
65
76
  end
@@ -91,6 +102,13 @@ module PWN
91
102
  output: 'optional - alias for out_dir'
92
103
  )
93
104
 
105
+ # Minimize a crash input with afl-tmin when present.
106
+ #{self}.minimize(
107
+ crash: 'required - path to a crashing input',
108
+ out: 'required - path to write the minimized input',
109
+ target: 'required - fuzz target binary'
110
+ )
111
+
94
112
  # Print the AUTHOR(S) string for this module.
95
113
  #{self}.authors
96
114
  "
@@ -91,17 +91,35 @@ module PWN
91
91
  format: inf[:format],
92
92
  arch: inf[:cpu],
93
93
  bits: inf[:cpu].to_s[/\d+/],
94
+ endian: inf[:endian] || inf[:ei_data],
95
+ stripped: inf[:stripped],
96
+ static: inf[:static],
97
+ protections: {
98
+ nx: inf[:nx],
99
+ pie: inf[:pie],
100
+ relro: inf[:relro],
101
+ canary: inf[:canary],
102
+ fortify: inf[:fortify]
103
+ },
104
+ sections: secs,
94
105
  imports: imps,
95
106
  exports: exps,
107
+ strings_top: Array(inf[:strings]).first(20),
108
+ entry: inf[:entry] || inf[:entrypoint],
96
109
  function_count: Array(symbols(opts)).length,
97
- section_entropy_map: secs.to_h { |s| [s[:name], s[:size]] }
110
+ notes: []
98
111
  }
99
112
  sha = Digest::SHA256.file(path).hexdigest
100
- dir = File.join(Dir.home, '.pwn', 'artifacts', 'triage')
113
+ dir = File.join(Dir.home, '.pwn', 'cache', 'triage')
101
114
  FileUtils.mkdir_p(dir)
102
- art = File.join(dir, "#{sha[0, 16]}.json")
103
- File.write(art, JSON.pretty_generate(summary.merge(path: path, sha256: sha)))
104
- summary.merge(artifact: art)
115
+ art = File.join(dir, "#{sha}.json")
116
+ if File.file?(art)
117
+ cached = JSON.parse(File.read(art), symbolize_names: true)
118
+ return cached.merge(cached: true, artifact: art)
119
+ end
120
+ body = summary.merge(path: path, sha256: sha)
121
+ File.write(art, JSON.pretty_generate(body))
122
+ body.merge(artifact: art, cached: false)
105
123
  end
106
124
 
107
125
  public_class_method def self.authors
@@ -27,16 +27,6 @@ module PWN
27
27
  'mswin' => :windows
28
28
  }.freeze
29
29
 
30
- PATH_NOISE = %w[
31
- sh bash zsh fish csh tcsh dash true false echo test pwd ls cat cp mv rm
32
- mkdir rmdir chmod chown chgrp ln touch head tail more less grep egrep fgrep
33
- sed awk sort uniq wc find xargs tar gzip gunzip zcat bzip2 xz make
34
- python python2 python3 ruby perl php node java git svn hg
35
- vi vim nvi nano ed ex
36
- apt apt-get dpkg rpm yum dnf pacman brew port pkg pkg_add
37
- sudo doas su login id whoami uname hostname env export
38
- ].freeze
39
-
40
30
  public_class_method def self.type
41
31
  os = :cygwin if OS.cygwin?
42
32
  os = :freebsd if OS.freebsd?
@@ -193,8 +183,7 @@ module PWN
193
183
  found << name if ok
194
184
  end
195
185
  owned = pwn_installed_bins(pwn_bins: opts[:pwn_bins])
196
- native = (found.map(&:to_s) - owned - PATH_NOISE).reject(&:empty?).uniq.sort
197
- native = native.first(256)
186
+ native = (found.map(&:to_s) - owned).reject(&:empty?).uniq.sort
198
187
  cap = if opts.key?(:cap_net_raw)
199
188
  opts[:cap_net_raw]
200
189
  else
@@ -205,8 +194,7 @@ module PWN
205
194
  else
206
195
  docker_live
207
196
  end
208
- sample = native.first(24).join(',')
209
- summary = "#{flavor} #{ver} #{cpu} native=#{sample}"
197
+ summary = "#{flavor} #{ver} #{cpu}"
210
198
  {
211
199
  type: os_type,
212
200
  distro: flavor,
@@ -4,11 +4,19 @@ require 'json'
4
4
  require 'open3'
5
5
  require 'csv'
6
6
  require 'fileutils'
7
+ require 'net/http'
8
+ require 'uri'
9
+ require 'stringio'
10
+ require 'zlib'
11
+ require 'rubygems/package'
7
12
 
8
13
  module PWN
9
14
  module Plugins
10
15
  # searchsploit / ExploitDB plus a CPE string builder for nmap versions.
11
16
  module ExploitDB
17
+ EXPLOITDB_CSV_URL = 'https://gitlab.com/exploit-database/exploitdb/-/raw/main/files_exploits.csv'
18
+ GTFOBINS_TAR_URL = 'https://codeload.github.com/GTFOBins/GTFOBins.github.io/tar.gz/master'
19
+
12
20
  public_class_method def self.required_bins
13
21
  %w[searchsploit]
14
22
  end
@@ -41,37 +49,44 @@ module PWN
41
49
  search(query: q).merge(cpe: cpe, product: product, version: version)
42
50
  end
43
51
 
44
- private_class_method def self.local_hits(opts = {})
45
- q = opts[:query].to_s.downcase
46
- path = intel_csv
47
- return [] unless File.file?(path)
48
-
49
- CSV.read(path, headers: true).filter_map do |row|
50
- blob = row.to_h.values.join(' ').downcase
51
- next unless blob.include?(q) || q.split.any? { |tok| tok.length > 2 && blob.include?(tok) }
52
+ public_class_method def self.gtfobins(opts = {})
53
+ name = (opts[:binary] || opts[:query] || opts[:q]).to_s.downcase
54
+ raise 'ERROR: binary is required' if name.empty?
52
55
 
53
- {
54
- cve: row['cve'],
55
- edb_id: row['id'],
56
- description: row['description'],
57
- local_path: row['file'],
58
- cvss: row['cvss']
59
- }
60
- end
61
- rescue StandardError
62
- []
56
+ path = File.join(intel_dir, 'gtfobins.json')
57
+ data = File.file?(path) ? JSON.parse(File.read(path)) : {}
58
+ row = data[name] || data.values.find { |v| v.is_a?(Hash) && v['binary'].to_s.downcase == name }
59
+ { binary: name, hit: row, source: 'local' }
63
60
  end
64
61
 
65
- private_class_method def self.intel_csv
66
- dest = File.join(Dir.home, '.pwn', 'intel', 'files_exploits.csv')
67
- return dest if File.file?(dest)
62
+ public_class_method def self.lookup(opts = {})
63
+ kind = (opts[:kind] || 'cve').to_s
64
+ q = (opts[:query] || opts[:cve] || opts[:binary] || opts[:technique]).to_s
65
+ return gtfobins(binary: q) if %w[binary gtfobins].include?(kind)
68
66
 
69
- bundled = File.expand_path('../../../etc/intel/files_exploits.csv', __dir__)
70
- if File.file?(bundled)
71
- FileUtils.mkdir_p(File.dirname(dest))
72
- FileUtils.cp(bundled, dest)
73
- end
74
- dest
67
+ search(query: q).merge(kind: kind)
68
+ end
69
+
70
+ public_class_method def self.sync(opts = {})
71
+ dest = opts[:dir].to_s
72
+ dest = intel_dir if dest.empty?
73
+ FileUtils.mkdir_p(dest)
74
+ fetched = []
75
+ csv_url = (opts[:csv_url] || EXPLOITDB_CSV_URL).to_s
76
+ bins_url = (opts[:gtfobins_url] || GTFOBINS_TAR_URL).to_s
77
+ return { dest: dest, dry_run: true, urls: [csv_url, bins_url], fetched: [] } if opts[:dry_run]
78
+
79
+ csv_body = http_get(url: csv_url, get: opts[:get], timeout: opts[:timeout])
80
+ csv_path = File.join(dest, 'files_exploits.csv')
81
+ File.binwrite(csv_path, csv_body)
82
+ fetched << 'files_exploits.csv'
83
+
84
+ bins_body = http_get(url: bins_url, get: opts[:get], timeout: opts[:timeout])
85
+ json_path = File.join(dest, 'gtfobins.json')
86
+ File.write(json_path, gtfobins_json(body: bins_body))
87
+ fetched << 'gtfobins.json'
88
+
89
+ { dest: dest, fetched: fetched, dry_run: false }
75
90
  end
76
91
 
77
92
  public_class_method def self.authors
@@ -96,11 +111,124 @@ module PWN
96
111
  version: 'optional - version string from nmap_it'
97
112
  )
98
113
 
114
+ # Look up a local GTFOBins-style primitive by binary name.
115
+ #{self}.gtfobins(
116
+ binary: 'required - host binary name (defaults to query/q)',
117
+ query: 'optional - alias for binary',
118
+ q: 'optional - alias for binary'
119
+ )
120
+
121
+ # Offline intel lookup by kind (cve, binary, technique).
122
+ #{self}.lookup(
123
+ kind: 'optional - cve|binary|technique (defaults to cve)',
124
+ query: 'optional - search text',
125
+ cve: 'optional - CVE id',
126
+ binary: 'optional - GTFOBins binary name',
127
+ technique: 'optional - technique keyword'
128
+ )
129
+
130
+ # Fetch latest ExploitDB CSV and GTFOBins into ~/.pwn/intel (not the pwn tree).
131
+ #{self}.sync(
132
+ dir: 'optional - destination directory (defaults to ~/.pwn/intel)',
133
+ dry_run: 'optional - true to list source URLs without writing',
134
+ timeout: 'optional - HTTP timeout seconds',
135
+ csv_url: 'optional - ExploitDB files_exploits.csv URL override',
136
+ gtfobins_url: 'optional - GTFOBins tarball or JSON URL override',
137
+ get: 'optional - proc taking a URL string and returning the response body'
138
+ )
139
+
99
140
  # Print the AUTHOR(S) string for this module.
100
141
  #{self}.authors
101
142
  "
102
143
  constants.sort
103
144
  end
145
+
146
+ private_class_method def self.local_hits(opts = {})
147
+ q = opts[:query].to_s.downcase
148
+ path = File.join(intel_dir, 'files_exploits.csv')
149
+ return [] unless File.file?(path)
150
+
151
+ CSV.read(path, headers: true).filter_map do |row|
152
+ blob = row.to_h.values.join(' ').downcase
153
+ next unless blob.include?(q) || q.split.any? { |tok| tok.length > 2 && blob.include?(tok) }
154
+
155
+ {
156
+ cve: row['cve'],
157
+ edb_id: row['id'],
158
+ description: row['description'],
159
+ local_path: row['file'],
160
+ cvss: row['cvss']
161
+ }
162
+ end
163
+ rescue StandardError
164
+ []
165
+ end
166
+
167
+ private_class_method def self.intel_dir(opts = {})
168
+ custom = opts[:dir].to_s
169
+ return custom unless custom.empty?
170
+
171
+ File.join(Dir.home, '.pwn', 'intel')
172
+ end
173
+
174
+ private_class_method def self.http_get(opts = {})
175
+ url = opts[:url].to_s
176
+ getter = opts[:get]
177
+ return getter.call(url) if getter.respond_to?(:call)
178
+
179
+ timeout = (opts[:timeout] || 120).to_i
180
+ uri = URI.parse(url)
181
+ http_get_uri(uri: uri, timeout: timeout, hops: 0)
182
+ end
183
+
184
+ private_class_method def self.http_get_uri(opts = {})
185
+ uri = opts[:uri]
186
+ timeout = opts[:timeout].to_i
187
+ hops = opts[:hops].to_i
188
+ raise 'ERROR: too many HTTP redirects' if hops > 8
189
+
190
+ http = Net::HTTP.new(uri.host, uri.port)
191
+ http.use_ssl = uri.scheme == 'https'
192
+ http.open_timeout = timeout
193
+ http.read_timeout = timeout
194
+ req = Net::HTTP::Get.new(uri.request_uri)
195
+ req['User-Agent'] = 'pwn-exploitdb-sync'
196
+ res = http.request(req)
197
+ case res
198
+ when Net::HTTPRedirection
199
+ nxt = URI.parse(res['location'].to_s)
200
+ nxt = uri + res['location'].to_s unless nxt.absolute?
201
+ http_get_uri(uri: nxt, timeout: timeout, hops: hops + 1)
202
+ else
203
+ raise "ERROR: fetch #{uri} failed: #{res.code}" unless res.is_a?(Net::HTTPSuccess)
204
+
205
+ res.body
206
+ end
207
+ end
208
+
209
+ private_class_method def self.gtfobins_json(opts = {})
210
+ body = opts[:body].to_s
211
+ stripped = body.lstrip
212
+ return body if stripped.start_with?('{', '[')
213
+
214
+ bins = {}
215
+ Zlib::GzipReader.wrap(StringIO.new(body)) do |gz|
216
+ Gem::Package::TarReader.new(gz) do |tar|
217
+ tar.each do |entry|
218
+ next unless entry.file?
219
+ next unless entry.full_name.include?('_gtfobins/') && entry.full_name.end_with?('.md')
220
+
221
+ name = File.basename(entry.full_name, '.md')
222
+ text = entry.read.to_s
223
+ funcs = text.scan(/^##\s+(.+)$/).flatten.map(&:strip)
224
+ bins[name] = { 'binary' => name, 'functions' => funcs }
225
+ end
226
+ end
227
+ end
228
+ JSON.generate(bins)
229
+ rescue StandardError
230
+ body.start_with?('{') ? body : JSON.generate({})
231
+ end
104
232
  end
105
233
  end
106
234
  end
@@ -149,11 +149,7 @@ module PWN
149
149
 
150
150
  if PWN::Plugins::PreflightChecker.bin?(name: 'r2')
151
151
  sid = PWN::Plugins::Radare2.open(path: path)
152
- begin
153
- PWN::Plugins::Radare2.cmd(session: sid, cmd: '/R')
154
- ensure
155
- PWN::Plugins::Radare2.close(session: sid)
156
- end
152
+ PWN::Plugins::Radare2.cmd(session: sid, cmd: '/R')
157
153
  elsif PWN::Plugins::PreflightChecker.bin?(name: 'ROPgadget')
158
154
  `ROPgadget --binary #{path}`.to_s
159
155
  else
@@ -161,6 +157,20 @@ module PWN
161
157
  end
162
158
  end
163
159
 
160
+ public_class_method def self.fmtstr(opts = {})
161
+ fmt_writes(opts)
162
+ end
163
+
164
+ public_class_method def self.io(opts = {})
165
+ kind = (opts[:kind] || opts[:mode] || 'process').to_s
166
+ case kind
167
+ when 'remote'
168
+ PWN::Plugins::ProcessTube.connect(host: opts[:host] || opts[:target], port: opts[:port])
169
+ else
170
+ PWN::Plugins::ProcessTube.spawn(cmd: opts[:cmd] || opts[:command])
171
+ end
172
+ end
173
+
164
174
  public_class_method def self.authors
165
175
  "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
166
176
  end
@@ -266,6 +276,24 @@ module PWN
266
276
  offset: 'optional - offset value consumed by #fmt_writes'
267
277
  )
268
278
 
279
+ # Alias of fmt_writes for pwntools-style fmtstr helpers.
280
+ #{self}.fmtstr(
281
+ addr: 'optional - address or flag (e.g. main or 0x401000)',
282
+ value: 'optional - integer or string to pack/encode',
283
+ offset: 'optional - offset value consumed by #fmt_writes'
284
+ )
285
+
286
+ # Open a local process or remote TCP tube for exploit IO.
287
+ #{self}.io(
288
+ kind: 'optional - process|remote|pty_session (defaults to process)',
289
+ mode: 'optional - alias for kind',
290
+ cmd: 'optional - command for process/pty_session',
291
+ command: 'optional - alias for cmd',
292
+ host: 'optional - remote host',
293
+ target: 'optional - alias for host',
294
+ port: 'optional - remote TCP port'
295
+ )
296
+
269
297
  # Run gadgets and return its result
270
298
  #{self}.gadgets(
271
299
  path: 'required - filesystem path of the binary to search for gadgets'