Module: PWN::Plugins::MitmProxy
- Defined in:
- lib/pwn/plugins/mitm_proxy.rb
Overview
Native HTTP interception and opaque CONNECT tunnelling. CONNECT traffic is not decrypted: HAR explicitly marks the tunnel as metadata-only.
Defined Under Namespace
Classes: Server
Constant Summary collapse
- SESSIONS =
rubocop:disable Style/MutableConstant -- live process-local session registry
{}
- LOCK =
Mutex.new
- HOP_HEADERS =
%w[connection keep-alive proxy-authenticate proxy-authorization proxy-connection te trailer trailers transfer-encoding upgrade content-length host].freeze
Class Method Summary collapse
- .authors ⇒ Object
- .entries(opts = {}) ⇒ Object
- .exchange(opts = {}) ⇒ Object
- .help ⇒ Object
- .http_replay(opts = {}) ⇒ Object
- .rules(opts = {}) ⇒ Object
- .start(opts = {}) ⇒ Object
- .stop(opts = {}) ⇒ Object
Class Method Details
.authors ⇒ Object
235 236 237 |
# File 'lib/pwn/plugins/mitm_proxy.rb', line 235 public_class_method def self. "AUTHOR(S):\n 0day Inc. <[email protected]>\n" end |
.entries(opts = {}) ⇒ Object
88 89 90 91 |
# File 'lib/pwn/plugins/mitm_proxy.rb', line 88 public_class_method def self.entries(opts = {}) session = lookup(opts) session[:mutex].synchronize { Marshal.load(Marshal.dump(session[:entries])) } end |
.exchange(opts = {}) ⇒ Object
122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 |
# File 'lib/pwn/plugins/mitm_proxy.rb', line 122 public_class_method def self.exchange(opts = {}) session = lookup(opts) request = Marshal.load(Marshal.dump(opts.fetch(:request))) apply_rules(proxy: session, phase: 'request', message: request) uri = URI(request[:url]) raise ArgumentError, 'HTTP(S) URL without userinfo required' unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo method = request[:method].to_s.upcase raise ArgumentError, 'invalid HTTP method' unless method.match?(/\A[A-Z]+\z/) && method != 'CONNECT' headers = clean_headers(headers: request[:headers]) headers['accept-encoding'] ||= 'identity' client = Net::HTTP.new(uri.host, uri.port, nil) # Never inherit ambient proxies. client.use_ssl = uri.scheme == 'https' client.open_timeout = client.read_timeout = client.write_timeout = session[:timeout] wire = Net::HTTPGenericRequest.new(method, !request[:body].to_s.empty?, method != 'HEAD', uri.request_uri, headers) wire.body = request[:body].to_s unless request[:body].to_s.empty? started = Time.now.utc clock = Process.clock_gettime(Process::CLOCK_MONOTONIC) response = client.request(wire) = { headers: response.to_hash.transform_values { |v| v.join(', ') }, body: response.body.to_s } apply_rules(proxy: session, phase: 'response', message: ) elapsed = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - clock) * 1000 response_headers = clean_headers(headers: [:headers]).map { |k, v| { name: k, value: v } } response_headers.reject! { |h| h[:name] == 'set-cookie' } Array(response.get_fields('set-cookie')).each { |v| response_headers << { name: 'set-cookie', value: v } } entry = { _request_id: SecureRandom.hex(12), startedDateTime: started.iso8601(6), time: elapsed, request: { method: method, url: uri.to_s, httpVersion: 'HTTP/1.1', headers: headers.map { |k, v| { name: k, value: v } }, queryString: URI.decode_www_form(uri.query.to_s).map { |k, v| { name: k, value: v } }, cookies: [], headersSize: -1, bodySize: request[:body].to_s.bytesize, postData: encoded(body: request[:body].to_s).merge(mimeType: headers['content-type'].to_s) }, response: { status: response.code.to_i, statusText: response., httpVersion: 'HTTP/1.1', headers: response_headers, cookies: [], redirectURL: response['location'].to_s, headersSize: -1, bodySize: [:body].bytesize, content: encoded(body: [:body]).merge(size: [:body].bytesize, mimeType: response['content-type'].to_s) }, cache: {}, timings: { send: 0, wait: elapsed, receive: 0 } } persist_entry(proxy: session, entry: entry) entry end |
.help ⇒ Object
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/pwn/plugins/mitm_proxy.rb', line 239 public_class_method def self.help puts "USAGE: # Start a native HTTP capture proxy; CONNECT tunnels are metadata-only. #{self}.start( har_path: 'optional - HAR destination, contains sensitive raw traffic', host: 'optional - bind address, default 127.0.0.1', port: 'optional - bind port, default ephemeral', backend: 'optional - native (only supported backend)', rules: 'optional - literal phase/field/match/replace rules', timeout: 'optional - upstream timeout seconds, default 30' ) # Return a snapshot of captured HAR entries. #{self}.entries(proxy: 'required - descriptor or session id') # Replace the active request/response substitution rules. #{self}.rules( proxy: 'required - descriptor or session id', rules: 'required - array of phase/field/match/replace hashes' ) # Replay a captured HTTP request with optional mutations. #{self}.http_replay( proxy: 'required - descriptor or session id', request_id: 'required - captured _request_id', mutations: 'optional - method, url, path, query, headers, body' ) # Send and capture one HTTP request through the shared rule engine. #{self}.exchange( proxy: 'required - descriptor or session id', request: 'required - method, url, headers, body hash' ) # Stop the listener and flush its HAR. #{self}.stop(proxy: 'required - descriptor or session id') # Print the module authors. #{self}.authors " end |
.http_replay(opts = {}) ⇒ Object
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 |
# File 'lib/pwn/plugins/mitm_proxy.rb', line 100 public_class_method def self.http_replay(opts = {}) session = lookup(opts) entry = entries(proxy: session).find { |row| row[:_request_id] == opts[:request_id].to_s } raise ArgumentError, 'unknown request_id' unless entry raise ArgumentError, 'opaque CONNECT tunnels cannot be replayed as HTTP' if entry[:_capture] == 'opaque_connect' original = entry[:request] mutations = (opts[:mutations] || {}).transform_keys(&:to_sym) unknown = mutations.keys - i[method url path query headers body] raise ArgumentError, "unknown mutations: #{unknown.join(', ')}" unless unknown.empty? uri = URI(mutations[:url] || original[:url]) uri.path = mutations[:path] if mutations.key?(:path) uri.query = mutations[:query].is_a?(Hash) ? URI.encode_www_form(mutations[:query]) : mutations[:query] if mutations.key?(:query) headers = original[:headers].to_h { |h| [h[:name].downcase, h[:value]] } (mutations[:headers] || {}).each { |k, v| v.nil? ? headers.delete(k.downcase) : headers[k.downcase] = v } post = original[:postData] || {} body = post[:encoding] == 'base64' ? Base64.strict_decode64(post[:text]) : post[:text].to_s exchange(proxy: session, request: { method: mutations[:method] || original[:method], url: uri.to_s, headers: headers, body: mutations.fetch(:body, body) }) end |
.rules(opts = {}) ⇒ Object
93 94 95 96 97 98 |
# File 'lib/pwn/plugins/mitm_proxy.rb', line 93 public_class_method def self.rules(opts = {}) session = lookup(opts) rules = validate_rules(rules: opts[:rules]) session[:mutex].synchronize { session[:rules] = rules } { id: session[:id], rules: rules } end |
.start(opts = {}) ⇒ Object
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 |
# File 'lib/pwn/plugins/mitm_proxy.rb', line 44 public_class_method def self.start(opts = {}) raise ArgumentError, 'backend must be native' unless (opts[:backend] || 'native').to_s == 'native' id = SecureRandom.hex(12) path = File.(opts[:har_path] || File.join(Dir.tmpdir, "pwn-proxy-#{id}.har")) rules = validate_rules(rules: opts[:rules]) session = { id: id, backend: 'native', har_path: path, rules: rules, entries: [], mutex: Mutex.new, timeout: Float(opts[:timeout] || 30), https_capture: 'CONNECT metadata only; TLS is not decrypted' } server = Server.new(BindAddress: opts[:host] || '127.0.0.1', Port: Integer(opts[:port] || 0), Logger: WEBrick::Log.new(File::NULL), AccessLog: [], PWNSession: session, ProxyContentHandler: lambda { |req, res| next unless req.request_method == 'CONNECT' persist_entry(proxy: session, entry: { _request_id: SecureRandom.hex(12), _capture: 'opaque_connect', startedDateTime: Time.now.utc.iso8601, time: 0, request: { method: 'CONNECT', url: req.unparsed_uri, headers: [] }, response: { status: res.status, headers: [], content: { size: 0, mimeType: '', text: '' } }, cache: {}, timings: { send: 0, wait: 0, receive: 0 } }) }) session.merge!(host: server.config[:BindAddress], port: server.config[:Port], server: server) session[:url] = "http://#{session[:host]}:#{session[:port]}" LOCK.synchronize { SESSIONS[id] = session } session[:thread] = Thread.new { server.start } persist(proxy: session) descriptor(proxy: session) rescue StandardError server&.shutdown LOCK.synchronize { SESSIONS.delete(id) } if id raise end |
.stop(opts = {}) ⇒ Object
75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/pwn/plugins/mitm_proxy.rb', line 75 public_class_method def self.stop(opts = {}) session = lookup(opts) session[:server].shutdown thread = session[:thread] unless thread.join(5) thread.kill thread.join(1) end persist(proxy: session) LOCK.synchronize { SESSIONS.delete(session[:id]) } { stopped: true, id: session[:id], har_path: session[:har_path], entries: session[:entries].length } end |