Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LDAPS Channel Binding for NTLM and Kerberos #19132

Merged
merged 8 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 14 additions & 158 deletions lib/metasploit/framework/ldap/client.rb
Original file line number Diff line number Diff line change
@@ -1,112 +1,11 @@
# frozen_string_literal: true

require 'rex/proto/ldap/auth_adapter'

module Metasploit
module Framework
module LDAP

# Provide the ability to "wrap" LDAP comms in a Kerberos encryption routine
# The methods herein are set up with the auth_context_setup call below,
# and are called when reading or writing needs to occur.
class SpnegoKerberosEncryptor
include Rex::Proto::Gss::Asn1
# @param kerberos_authenticator [Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base] Kerberos authenticator
def initialize(kerberos_authenticator)
self.kerberos_authenticator = kerberos_authenticator
end

# The AP-REQ to send to the server
def get_initial_credential
self.kerberos_result = self.kerberos_authenticator.authenticate
self.kerberos_result[:security_blob]
end

# Configure our encryption, and tell the LDAP connection object that we now want to intercept its calls
# to read and write
# @param gssapi_response [String,nil] GSS token containing the AP-REP from the server if mutual auth was used, or nil otherwise
# @param ldap_connection [Net::LDAP::Connection]
def kerberos_setup(gssapi_response, ldap_connection)
spnego = Rex::Proto::Gss::SpnegoNegTokenTarg.parse(gssapi_response)
if spnego.response_token.nil?
# No mutual auth result
self.kerberos_encryptor = kerberos_authenticator.get_message_encryptor(self.kerberos_result[:session_key],
self.kerberos_result[:client_sequence_number],
nil,
use_acceptor_subkey: false)
else
mutual_auth_result = self.kerberos_authenticator.parse_gss_init_response(spnego.response_token, self.kerberos_result[:session_key])
self.kerberos_encryptor = kerberos_authenticator.get_message_encryptor(mutual_auth_result[:ap_rep_subkey],
self.kerberos_result[:client_sequence_number],
mutual_auth_result[:server_sequence_number],
use_acceptor_subkey: true)
end
ldap_connection.wrap_read_write(self.method(:read), self.method(:write))
end

# Decrypt the provided ciphertext
# @param ciphertext [String]
def read(ciphertext)
begin
plaintext = self.kerberos_encryptor.decrypt_and_verify(ciphertext)
rescue Rex::Proto::Kerberos::Model::Error::KerberosError => exception
raise Rex::Proto::LDAP::LdapException.new('Received invalid Kerberos message')
end
return plaintext
end

# Encrypt the provided plaintext
# @param data [String]
def write(data)
emessage, header_length, pad_length = self.kerberos_encryptor.encrypt_and_increment(data)

emessage
end

attr_accessor :kerberos_encryptor
attr_accessor :kerberos_authenticator
attr_accessor :kerberos_result
end

# Provide the ability to "wrap" LDAP comms in an NTLM encryption routine
# The methods herein are set up with the auth_context_setup call below,
# and are called when reading or writing needs to occur.
class NtlmEncryptor
def initialize(ntlm_client)
self.ntlm_client = ntlm_client
end

# Configure our encryption, and tell the LDAP connection object that we now want to intercept its calls
# to read and write
# @param ignore [String,nil] GSS token - not required by NTLM (should be nil)
# @param ldap_connection [Net::LDAP::Connection]
def ntlm_setup(ignore, ldap_connection)
ldap_connection.wrap_read_write(self.method(:read), self.method(:write))
end

# Decrypt the provided ciphertext
# @param ciphertext [String]
def read(ciphertext)
message = ntlm_client.session.unseal_message(ciphertext[16..-1])
if ntlm_client.session.verify_signature(ciphertext[0..15], message)
return message
else
# Some error
raise Rex::Proto::LDAP::LdapException.new('Received invalid NTLM message')
end
end

# Encrypt the provided plaintext
# @param data [String]
def write(data)
emessage = ntlm_client.session.seal_message(data)
signature = ntlm_client.session.sign_message(data)

signature + emessage
end

attr_accessor :ntlm_client
end


module Client
def ldap_connect_opts(rhost, rport, connect_timeout, ssl: true, opts: {})
connect_opts = {
Expand Down Expand Up @@ -168,75 +67,32 @@ def ldap_auth_opts_kerberos(opts, ssl)
ticket_storage: opts[:kerberos_ticket_storage],
offered_etypes: offered_etypes,
mutual_auth: true,
use_gss_checksum: sign_and_seal
use_gss_checksum: sign_and_seal || ssl
)

encryptor = SpnegoKerberosEncryptor.new(kerberos_authenticator)

auth_opts[:auth] = {
method: :sasl,
mechanism: 'GSS-SPNEGO',
initial_credential: proc do
encryptor.get_initial_credential
end,
challenge_response: true
method: :rex_kerberos,
kerberos_authenticator: kerberos_authenticator,
sign_and_seal: sign_and_seal
}

if sign_and_seal
auth_opts[:auth][:auth_context_setup] = encryptor.method(:kerberos_setup)
end

auth_opts
end

def ldap_auth_opts_ntlm(opts, ssl)
auth_opts = {}
flags = RubySMB::NTLM::NEGOTIATE_FLAGS[:UNICODE] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:REQUEST_TARGET] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:NTLM] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:ALWAYS_SIGN] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:EXTENDED_SECURITY] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:KEY_EXCHANGE] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:TARGET_INFO] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:VERSION_INFO]

sign_and_seal = opts.fetch(:sign_and_seal, !ssl)
if sign_and_seal
flags = flags |
RubySMB::NTLM::NEGOTIATE_FLAGS[:SIGN] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:SEAL] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:KEY128] |
RubySMB::NTLM::NEGOTIATE_FLAGS[:KEY56]
end
ntlm_client = RubySMB::NTLM::Client.new(
(opts[:username].nil? ? '' : opts[:username]),
(opts[:password].nil? ? '' : opts[:password]),
workstation: 'WORKSTATION',
domain: opts[:domain].blank? ? '.' : opts[:domain],
flags: flags
)

negotiate = proc do |challenge|
ntlmssp_offset = challenge.index('NTLMSSP')
type2_blob = challenge.slice(ntlmssp_offset..-1)
challenge = [type2_blob].pack('m')
type3_message = ntlm_client.init_context(challenge)
type3_message.serialize
end

encryptor = NtlmEncryptor.new(ntlm_client)

auth_opts[:auth] = {
method: :sasl,
mechanism: 'GSS-SPNEGO',
initial_credential: ntlm_client.init_context.serialize,
challenge_response: negotiate
# use the rex one provided by us to support TLS channel binding (see: ruby-ldap/ruby-net-ldap#407) and blank
# passwords (see: WinRb/rubyntlm#45)
method: :rex_ntlm,
username: opts[:username],
password: opts[:password],
domain: opts[:domain],
workstation: 'WORKSTATION',
sign_and_seal: opts.fetch(:sign_and_seal, !ssl)
}

if sign_and_seal
auth_opts[:auth][:auth_context_setup] = encryptor.method(:ntlm_setup)
end

auth_opts
end

Expand Down
37 changes: 20 additions & 17 deletions lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ def authenticate_via_kdc(options = {})
#
# @param [Rex::Proto::Kerberos::CredentialCache::Krb5CcacheCredential] credential
# @param [Hash] _options
def authenticate_via_krb5_ccache_credential_tgs(credential, _options = {})
def authenticate_via_krb5_ccache_credential_tgs(credential, options = {})
unless credential.is_a?(Rex::Proto::Kerberos::CredentialCache::Krb5CcacheCredential)
raise TypeError, 'credential must be a Krb5CcacheCredential instance'
end
Expand All @@ -667,7 +667,7 @@ def authenticate_via_krb5_ccache_credential_tgs(credential, _options = {})

## Service Authentication
checksum = nil
checksum = build_gss_ap_req_checksum_value(mutual_auth, dce_style, nil, nil, nil, nil, nil) if use_gss_checksum
checksum = build_gss_ap_req_checksum_value(options: options) if use_gss_checksum

sequence_number = rand(1 << 32)
service_ap_request = build_service_ap_request(
Expand Down Expand Up @@ -756,13 +756,10 @@ def authenticate_via_krb5_ccache_credential_tgt(credential, options = {})
checksum = nil
if use_gss_checksum
checksum = build_gss_ap_req_checksum_value(
mutual_auth,
dce_style,
delegated_tgs_ticket,
delegated_tgs_auth,
tgs_auth.key,
realm,
client_name
ticket: delegated_tgs_ticket,
decrypted_part: delegated_tgs_auth,
session_key: tgs_auth.key,
options: options
)
end

Expand Down Expand Up @@ -792,15 +789,21 @@ def authenticate_via_krb5_ccache_credential_tgt(credential, options = {})
}
end

def build_gss_ap_req_checksum_value(mutual_auth, dce_style, ticket, decrypted_part, session_key, realm, client_name)
def build_gss_ap_req_checksum_value(ticket: nil, decrypted_part: nil, session_key: nil, options: {})
# @see https://datatracker.ietf.org/doc/html/rfc4121#section-4.1.1
# No channel binding
channel_binding_info = "\x00" * 16

if options[:gss_channel_binding]
channel_binding_info = options[:gss_channel_binding].channel_binding_token
else
channel_binding_info = "\x00".b * 16
end
channel_binding_info_len = [channel_binding_info.length].pack('V')

flags = GSS_REPLAY_DETECT | GSS_SEQUENCE | GSS_CONFIDENTIAL | GSS_INTEGRITY
flags |= GSS_MUTUAL if mutual_auth
flags |= GSS_DCE_STYLE if dce_style
flags = GSS_REPLAY_DETECT | GSS_SEQUENCE
flags |= GSS_CONFIDENTIAL if options.fetch(:gss_flag_confidential, true)
flags |= GSS_INTEGRITY if options.fetch(:gss_flag_integrity, true)
flags |= GSS_MUTUAL if options.fetch(:gss_flag_mutual) { mutual_auth }
flags |= GSS_DCE_STYLE if options.fetch(:gss_flag_dce_style) { dce_style }
flags |= GSS_DELEGATE if ticket

flags = [flags].pack('V')
Expand All @@ -814,8 +817,8 @@ def build_gss_ap_req_checksum_value(mutual_auth, dce_style, ticket, decrypted_pa
krb_cred.tickets = [ticket]
ticket_info = Rex::Proto::Kerberos::Model::KrbCredInfo.new
ticket_info.key = decrypted_part.key
ticket_info.prealm = realm
ticket_info.pname = build_client_name(client_name: client_name)
ticket_info.prealm = options.fetch(:realm) { self.realm.upcase }
ticket_info.pname = build_client_name(client_name: options.fetch(:client_name) { username })
ticket_info.flags = decrypted_part.flags
ticket_info.auth_time = decrypted_part.auth_time
ticket_info.start_time = decrypted_part.start_time
Expand Down
5 changes: 3 additions & 2 deletions lib/msf/core/exploit/remote/ldap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ def get_connect_opts
ldap_krb_offered_enc_types: datastore['LDAP::KrbOfferedEncryptionTypes'],
ldap_krb5_cname: datastore['LDAP::Krb5Ccname'],
proxies: datastore['Proxies'],
framework_module: self
framework_module: self,
kerberos_ticket_storage: kerberos_ticket_storage
}
case datastore['LDAP::Signing']
when 'required'
Expand All @@ -103,7 +104,7 @@ def get_connect_opts

# Now that the options have been resolved (including auto possibly resolving to NTLM), check whether this is a valid config
if result[:auth] && datastore['LDAP::Signing'] == 'required'
unless (result[:auth][:method] == :sasl && result[:auth][:mechanism] == 'GSS-SPNEGO')
unless %i[ rex_kerberos rex_ntlm ].include?(result[:auth][:method]) || (result[:auth][:method] == :sasl && result[:auth][:mechanism] == 'GSS-SPNEGO')
fail_with(Msf::Module::Failure::BadConfig, 'The authentication configuration does not support signing. Change either LDAP::Auth or LDAP::Signing.')
end

Expand Down
1 change: 1 addition & 0 deletions lib/msf_autoload.rb
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,7 @@ def custom_inflections
'appapi' => 'AppApi',
'uds_errors' => 'UDSErrors',
'smb_hash_capture' => 'SMBHashCapture',
'rex_ntlm' => 'RexNTLM'
}
end

Expand Down
33 changes: 33 additions & 0 deletions lib/rex/proto/gss/channel_binding.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
require 'rubyntlm'

module Rex::Proto::Gss
class ChannelBinding < Net::NTLM::ChannelBinding
attr_reader :digest_algorithm
def initialize(channel_data, unique_prefix: 'tls-server-end-point', digest_algorithm: 'SHA256')
super(channel_data)
@unique_prefix = unique_prefix
@digest_algorithm = digest_algorithm
end

def channel_hash
@channel_hash ||= OpenSSL::Digest.new(@digest_algorithm, channel)
end

def self.create(peer_cert)
super(peer_cert.to_der)
end

def self.from_tls_cert(peer_cert)
digest_algorithm = 'SHA256'
if peer_cert.signature_algorithm
# see: https://learn.microsoft.com/en-us/archive/blogs/openspecification/ntlm-and-channel-binding-hash-aka-extended-protection-for-authentication
normalized_name = OpenSSL::Digest.new(peer_cert.signature_algorithm).name.upcase
unless %[ MD5 SHA1 ].include?(normalized_name)
digest_algorithm = normalized_name
end
end

new(peer_cert.to_der, unique_prefix: 'tls-server-end-point', digest_algorithm: digest_algorithm)
end
end
end
10 changes: 0 additions & 10 deletions lib/rex/proto/ldap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,16 +167,6 @@ def wrap_read_write(wrap_read, wrap_write)
@conn.extend(SocketSaslIO)
@conn.setup(wrap_read, wrap_write)
end

# Monkey patch the bind function, so that the caller can set up its encryption wrapper
def bind(auth)
result = super(auth)

auth_context_setup = auth[:auth_context_setup]
auth_context_setup.call(result.result[:serverSaslCreds], self) if auth_context_setup

result
end
end

# Initialize the LDAP connection using Rex::Socket::TCP,
Expand Down
11 changes: 11 additions & 0 deletions lib/rex/proto/ldap/auth_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require 'rex/proto/ldap/auth_adapter/rex_kerberos'
require 'rex/proto/ldap/auth_adapter/rex_ntlm'

module Rex
module Proto
module LDAP
module AuthAdapter
end
end
end
end
Loading