287 lines
12 KiB
Ruby
287 lines
12 KiB
Ruby
# -*- coding: binary -*-
|
|
|
|
#
|
|
# This mixin is a wrapper around Net::LDAP
|
|
#
|
|
|
|
require 'rex/proto/ldap'
|
|
|
|
module Msf
|
|
module Exploit::Remote::LDAP
|
|
include Msf::Exploit::Remote::Kerberos::Ticket::Storage
|
|
|
|
def initialize(info = {})
|
|
super
|
|
|
|
register_options([
|
|
Opt::RHOST,
|
|
Opt::RPORT(389),
|
|
OptBool.new('SSL', [false, 'Enable SSL on the LDAP connection', false]),
|
|
Msf::OptString.new('DOMAIN', [false, 'The domain to authenticate to']),
|
|
Msf::OptString.new('USERNAME', [false, 'The username to authenticate with'], aliases: ['BIND_DN']),
|
|
Msf::OptString.new('PASSWORD', [false, 'The password to authenticate with'], aliases: ['BIND_PW']),
|
|
])
|
|
|
|
register_advanced_options([
|
|
OptEnum.new('LDAPAuth', [true, 'The Authentication mechanism to use', Msf::Exploit::Remote::AuthOption::AUTO, Msf::Exploit::Remote::AuthOption::LDAP_OPTIONS]),
|
|
OptString.new('LdapRhostname', [false, 'The rhostname which is required for kerberos']),
|
|
OptPath.new('LdapKrb5Ccname', [false, 'The ccache file to use for kerberos authentication', ENV.fetch('LDAPKRB5CCNAME', ENV.fetch('KRB5CCNAME', nil))], conditions: %w[ LDAPAuth == kerberos ]),
|
|
OptAddress.new('DomainControllerRhost', [false, 'The resolvable rhost for the Domain Controller']),
|
|
OptFloat.new('LDAP::ConnectTimeout', [true, 'Timeout for LDAP connect', 10.0])
|
|
])
|
|
end
|
|
|
|
def rhost
|
|
datastore['RHOST']
|
|
end
|
|
|
|
def rport
|
|
datastore['RPORT']
|
|
end
|
|
|
|
def peer
|
|
"#{rhost}:#{rport}"
|
|
end
|
|
|
|
def get_connect_opts
|
|
connect_opts = {
|
|
host: rhost,
|
|
port: rport,
|
|
connect_timeout: datastore['LDAP::ConnectTimeout']
|
|
}
|
|
|
|
if datastore['SSL']
|
|
connect_opts[:encryption] = {
|
|
method: :simple_tls,
|
|
tls_options: {
|
|
verify_mode: OpenSSL::SSL::VERIFY_NONE
|
|
}
|
|
}
|
|
end
|
|
|
|
case datastore['LDAPAuth']
|
|
when Msf::Exploit::Remote::AuthOption::KERBEROS
|
|
fail_with(Msf::Exploit::Failure::BadConfig, 'The LdapRhostname option is required when using Kerberos authentication.') if datastore['LdapRhostname'].blank?
|
|
fail_with(Msf::Exploit::Failure::BadConfig, 'The DOMAIN option is required when using Kerberos authentication.') if datastore['DOMAIN'].blank?
|
|
fail_with(Msf::Exploit::Failure::BadConfig, 'The DomainControllerRhost is required when using Kerberos authentication.') if datastore['DomainControllerRhost'].blank?
|
|
|
|
kerberos_authenticator = Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::LDAP.new(
|
|
host: datastore['DomainControllerRhost'],
|
|
hostname: datastore['LdapRhostname'],
|
|
realm: datastore['DOMAIN'],
|
|
username: datastore['USERNAME'],
|
|
password: datastore['PASSWORD'],
|
|
framework: framework,
|
|
framework_module: self,
|
|
cache_file: datastore['LdapKrb5Ccname'].blank? ? nil : datastore['LdapKrb5Ccname'],
|
|
ticket_storage: kerberos_ticket_storage
|
|
)
|
|
|
|
kerberos_result = kerberos_authenticator.authenticate
|
|
|
|
connect_opts[:auth] = {
|
|
method: :sasl,
|
|
mechanism: 'GSS-SPNEGO',
|
|
initial_credential: kerberos_result[:security_blob],
|
|
challenge_response: true
|
|
}
|
|
when Msf::Exploit::Remote::AuthOption::NTLM
|
|
ntlm_client = RubySMB::NTLM::Client.new(
|
|
datastore['USERNAME'],
|
|
datastore['PASSWORD'],
|
|
workstation: 'WORKSTATION',
|
|
domain: datastore['DOMAIN'].blank? ? '.' : datastore['DOMAIN'],
|
|
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]
|
|
)
|
|
|
|
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
|
|
|
|
connect_opts[:auth] = {
|
|
method: :sasl,
|
|
mechanism: 'GSS-SPNEGO',
|
|
initial_credential: ntlm_client.init_context.serialize,
|
|
challenge_response: negotiate
|
|
}
|
|
when Msf::Exploit::Remote::AuthOption::PLAINTEXT
|
|
connect_opts[:auth] = {
|
|
method: :simple,
|
|
username: datastore['USERNAME'],
|
|
password: datastore['PASSWORD']
|
|
}
|
|
when Msf::Exploit::Remote::AuthOption::AUTO
|
|
unless datastore['USERNAME'].blank? # plaintext if specified
|
|
connect_opts[:auth] = {
|
|
method: :simple,
|
|
username: datastore['USERNAME'],
|
|
password: datastore['PASSWORD']
|
|
}
|
|
end
|
|
end
|
|
|
|
connect_opts
|
|
end
|
|
|
|
def ldap_connect(opts = {}, &block)
|
|
Net::LDAP.open(get_connect_opts.merge(opts), &block)
|
|
end
|
|
|
|
def ldap_new(opts = {})
|
|
ldap = Net::LDAP.new(get_connect_opts.merge(opts))
|
|
|
|
# NASTY, but required
|
|
# monkey patch ldap object in order to ignore bind errors
|
|
# Some servers (e.g. OpenLDAP) return result even after a bind
|
|
# has failed, e.g. with LDAP_INAPPROPRIATE_AUTH - anonymous bind disallowed.
|
|
# See: https://www.openldap.org/doc/admin23/security.html#Authentication%20Methods
|
|
# "Note that disabling the anonymous bind mechanism does not prevent anonymous
|
|
# access to the directory."
|
|
#
|
|
# Bug created for Net:LDAP https://github.com/ruby-ldap/ruby-net-ldap/issues/375
|
|
#
|
|
def ldap.use_connection(args)
|
|
if @open_connection
|
|
yield @open_connection
|
|
else
|
|
begin
|
|
conn = new_connection
|
|
conn.bind(args[:auth] || @auth)
|
|
# Commented out vs. original
|
|
# result = conn.bind(args[:auth] || @auth)
|
|
# return result unless result.result_code == Net::LDAP::ResultCodeSuccess
|
|
yield conn
|
|
ensure
|
|
conn.close if conn
|
|
end
|
|
end
|
|
end
|
|
yield ldap
|
|
end
|
|
|
|
def get_naming_contexts(ldap)
|
|
vprint_status("#{peer} Getting root DSE")
|
|
|
|
unless (root_dse = ldap.search_root_dse)
|
|
print_error("#{peer} Could not retrieve root DSE")
|
|
return
|
|
end
|
|
|
|
vprint_line(root_dse.to_ldif)
|
|
|
|
naming_contexts = root_dse[:namingcontexts]
|
|
|
|
# NOTE: Net::LDAP converts attribute names to lowercase
|
|
if naming_contexts.empty?
|
|
print_error("#{peer} Empty namingContexts attribute")
|
|
return
|
|
end
|
|
|
|
naming_contexts
|
|
end
|
|
|
|
def discover_base_dn(ldap)
|
|
naming_contexts = get_naming_contexts(ldap)
|
|
|
|
unless naming_contexts
|
|
print_error("#{peer} Base DN cannot be determined")
|
|
return
|
|
end
|
|
|
|
# NOTE: We assume the first namingContexts value is the base DN
|
|
base_dn = naming_contexts.first
|
|
|
|
print_good("#{peer} Discovered base DN: #{base_dn}")
|
|
base_dn
|
|
end
|
|
|
|
def validate_bind_success!(ldap)
|
|
bind_result = ldap.as_json['result']['ldap_result']
|
|
|
|
# Codes taken from https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes
|
|
case bind_result['resultCode']
|
|
when 0
|
|
vprint_good('Successfully bound to the LDAP server!')
|
|
when 1
|
|
fail_with(Msf::Exploit::Remote::Failure::NoAccess, "An operational error occurred, perhaps due to lack of authorization. The error was: #{bind_result['errorMessage'].strip}")
|
|
when 7
|
|
fail_with(Msf::Exploit::Remote::Failure::NoTarget, 'Target does not support the simple authentication mechanism!')
|
|
when 8
|
|
fail_with(Msf::Exploit::Remote::Failure::NoTarget, "Server requires a stronger form of authentication than we can provide! The error was: #{bind_result['errorMessage'].strip}")
|
|
when 14
|
|
fail_with(Msf::Exploit::Remote::Failure::NoTarget, "Server requires additional information to complete the bind. Error was: #{bind_result['errorMessage'].strip}")
|
|
when 48
|
|
fail_with(Msf::Exploit::Remote::Failure::NoAccess, "Target doesn't support the requested authentication type we sent. Try binding to the same user without a password, or providing credentials if you were doing anonymous authentication.")
|
|
when 49
|
|
fail_with(Msf::Exploit::Remote::Failure::NoAccess, 'Invalid credentials provided!')
|
|
else
|
|
fail_with(Msf::Exploit::Remote::Failure::Unknown, "Unknown error occurred whilst binding: #{bind_result['errorMessage'].strip}")
|
|
end
|
|
end
|
|
|
|
def validate_query_result!(query_result, filter)
|
|
if query_result.class != Hash
|
|
raise ArgumentError.new('Parameter to "validate_query_result!" function was not a Hash!')
|
|
end
|
|
|
|
# Codes taken from https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes
|
|
case query_result['resultCode']
|
|
when 0
|
|
vprint_status("Successfully queried #{filter}.")
|
|
when 1
|
|
# This is unknown as whilst we could fail on lack of authorization, this is not guaranteed with this error code.
|
|
# The user will need to inspect the error message to determine the root cause of the issue.
|
|
fail_with(Failure::Unknown, "An LDAP operational error occurred on #{filter}. It is likely the client requires authorization! The error was: #{query_result['errorMessage'].strip}")
|
|
when 2
|
|
fail_with(Failure::BadConfig, "The LDAP protocol being used by Metasploit isn't supported. The error was #{query_result['errorMessage'].strip}")
|
|
when 3
|
|
fail_with(Failure::TimeoutExpired, "The LDAP server returned a timeout response to the query #{filter}.")
|
|
when 4
|
|
fail_with(Failure::UnexpectedReply, "The LDAP query #{filter} was determined to result in too many entries for the LDAP server to return.")
|
|
when 11
|
|
fail_with(Failure::UnexpectedReply, "The LDAP server indicated that #{filter} hit some administrative limit within the server whilst the request was being processed.")
|
|
when 16
|
|
fail_with(Failure::NotFound, "The LDAP operation failed for #{filter} because the referenced attribute does not exist.")
|
|
when 18
|
|
fail_with(Failure::BadConfig, "The LDAP search with #{filter} failed because some matching is not supported for the target attribute type!")
|
|
when 32
|
|
fail_with(Failure::UnexpectedReply, "The LDAP search with #{filter} failed cause the operation targeted an entity within the base DN that does not exist.")
|
|
when 33
|
|
fail_with(Failure::BadConfig, "An attempt was made to deference an alias that didn't resolve properly with #{filter}.")
|
|
when 34
|
|
fail_with(Failure::BadConfig, 'The request included an invalid base DN entry.')
|
|
when 50
|
|
fail_with(Failure::NoAccess, 'The LDAP operation failed due to insufficient access rights.')
|
|
when 51
|
|
fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is too busy to perform the request.')
|
|
when 52
|
|
fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is not currently available to process the request.')
|
|
when 53
|
|
fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is unwilling to perform the request.')
|
|
when 64
|
|
fail_with(Failure::Unknown, "The LDAP operation failed due to a naming violation when using #{filter}.")
|
|
when 65
|
|
fail_with(Failure::Unknown, "The LDAP operation failed due to an object class violation when using #{filter}.")
|
|
else
|
|
if query_result['errorMessage'].blank?
|
|
fail_with(Failure::Unknown, "Query #{filter} failed but no error message was returned!")
|
|
else
|
|
fail_with(Failure::Unknown, "Query #{filter} failed with error: #{query_result['errorMessage'].strip}")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|