CVE-2026-48797
Backpropagate's –auth flag is not enforced, exposing the UI control plane.
- CVSS 9.3
- CWE-358
- Authentication, Authorization, and Session Management
- Remote
Backpropagate is a Python library for fine-tuning large language models on a single GPU. In versions 1.1.0 and 1.1.1, the optional Reflex web UI exposes a training control plane without authentication: dataset upload, model load, training start/stop, multi-run orchestration, GGUF export, and HuggingFace Hub push. The CLI accepts two operator-facing flags intended as security controls: –auth user:pass — documented as "require HTTP Basic authentication on every request to the UI." and–share — documented as "expose the UI on a public address; requires –auth." When –auth user:pass is passed, the CLI prints Auth: enabled (user: <username>) to confirm to the operator that authentication is active, then exports BACKPROPAGATE_UI_AUTH=user:pass to the subprocess that launches the Reflex backend. The Reflex backend (backpropagate/ui_app/**) never reads BACKPROPAGATE_UI_AUTH. No authentication middleware is registered. No request-level guard runs. No WebSocket upgrade guard runs. Any client that reaches the bound port — local or remote, depending on whether –share is used — has full UI access. An inline comment at backpropagate/cli.py:1217-1218 in the v1.1.0 source documents the gap: "For Phase 1 the variable is exported but Reflex doesn't read it yet." This comment was internal-facing; the user-facing documentation (README, CHANGELOG, SHIP_GATE) advertised the contract as enforced. An attacker who reaches the bound port can read uploaded datasets, trigger arbitrary training runs against any local base models as well as read their paths, trigger HuggingFace Hub pushes and cause disk-fill DoS. This issue has been fixed in version 1.2.0. If developers cannot immediately upgrade to 1.2.0 run backprop ui with no flags so it binds to localhost, use SSH port-forwarding (ssh -L 7860:localhost:7860 <training-host>) instead of –share for remote access, and audit any host previously launched with –share, re-issuing any HF tokens used during those sessions.
- CWE
- CWE-358
- CVSS base score
- 9.3
- Published
- 2026-06-17
- OWASP
- A07 Identification and Authentication Failures
- Orthogonal defect classification
- Interface
- Code defect classification
- Missing Check
- Category
- Authentication, Authorization, and Session Management
- Subcategory
- Insecure Authentication Mechanisms
- Accessibility scope
- Remote
- Impact
- Unauthorized Access
- Affected component
- Backpropagat
- Fixed by upgrading
- Yes
Solution
Upgrade Backpropagate to version 1.2.0 or later.
Vulnerable code sample
import os
import argparse
import subprocess
import sys
# This is a simplified, representative model of the vulnerable code structure
# described in the CVE. It is not the actual source code of the 'backpropagate' library.
# This would conceptually be in a file like `backpropagate/cli.py`
def main():
"""The CLI entrypoint that incorrectly handles authentication."""
parser = argparse.ArgumentParser(
description="Backpropagate UI Launcher (Vulnerable Example)"
)
parser.add_argument(
"--auth",
type=str,
default=None,
help="Require HTTP Basic authentication on every request to the UI."
)
parser.add_argument(
"--share",
action="store_true",
help="Expose the UI on a public address; requires --auth."
)
args = parser.parse_args()
# Create an environment for the subprocess
env = os.environ.copy()
if args.auth:
try:
username = args.auth.split(':')[0]
print(f"Auth: enabled (user: {username})")
except IndexError:
print("Error: Invalid auth format. Expected user:pass", file=sys.stderr)
sys.exit(1)
# The CLI sets the environment variable as documented.
env['BACKPROPAGATE_UI_AUTH'] = args.auth
# For Phase 1 the variable is exported but Reflex doesn't read it yet.
# In the actual library, this launches the Reflex UI app.
# We simulate this by calling a separate Python script as a subprocess.
# The vulnerability lies in the fact that `ui_app.py` never uses the env var.
print("Starting Web UI...")
# We pass the --share flag to the UI app to control the listening address.
cmd = [sys.executable, "-c", "from ui_app import run; run()", f"--share={args.share}"]
subprocess.run(cmd, env=env)
# This would conceptually be in a file like `backpropagate/ui_app/main.py`
# We'll embed it here as a string executed by the subprocess for a single-file example.
UI_APP_CODE = """
import argparse
from flask import Flask, jsonify, request
# This is a stand-in for the actual Reflex web application.
# Flask is used here for simplicity to demonstrate the core issue.
app = Flask(__name__)
# This environment variable is set by the launcher but is NEVER read by the UI app.
# AUTH_CREDS = os.environ.get('BACKPROPAGATE_UI_AUTH') # This line is missing.
@app.route("/")
def index():
return "Backpropagate UI is running.", 200
@app.route("/api/upload-dataset", methods=["POST"])
def upload_dataset():
# VULNERABILITY: No authentication check is performed.
# Any client can access this sensitive endpoint.
print("Received request to /api/upload-dataset")
return jsonify({"status": "success", "message": "Dataset uploaded."}), 200
@app.route("/api/start-training", methods=["POST"])
def start_training():
# VULNERABILITY: No authentication check is performed.
# Any client can trigger a training run.
print("Received request to /api/start-training")
return jsonify({"status": "success", "message": "Training started."}), 200
def run():
parser = argparse.ArgumentParser()
parser.add_argument("--share", type=lambda x: (str(x).lower() == 'true'), default=False)
args = parser.parse_args()
host = "0.0.0.0" if args.share else "127.0.0.1"
print(f"UI backend listening on http://{host}:8000")
app.run(host=host, port=8000)
# We need a way to pass this code to the subprocess.
# A simple way is to create a temporary file.
with open("ui_app.py", "w") as f:
f.write(UI_APP_CODE)
"""
if __name__ == "__main__":
# Create the fake UI app file
exec(UI_APP_CODE)
# Run the main CLI function
main()Patched code sample
import os
import base64
import secrets
import reflex as rx
from starlette.datastructures import Headers
from starlette.responses import Response
from starlette.types import ASGIApp, Receive, Scope, Send
# --- Start of the fix ---
# The vulnerability existed because the backend application never read the
# BACKPROPAGATE_UI_AUTH environment variable or registered any auth middleware.
# The fix involves both reading the variable and adding middleware to enforce authentication.
# 1. Read credentials from the environment variable.
auth_creds_str = os.environ.get("BACKPROPAGATE_UI_AUTH")
auth_enabled = bool(auth_creds_str)
USERNAME, PASSWORD = (None, None)
if auth_enabled:
try:
USERNAME, PASSWORD = auth_creds_str.split(":", 1)
except ValueError:
# If the env var is malformed, disable auth and warn the user.
print("Warning: BACKPROPAGATE_UI_AUTH is malformed. Auth is disabled.")
auth_enabled = False
class HttpBasicAuthMiddleware:
"""
Middleware that enforces HTTP Basic authentication if credentials are set.
"""
def __init__(self, app: ASGIApp):
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send):
# Only apply authentication if it's enabled and the scope is HTTP.
if not auth_enabled or scope["type"] != "http":
await self.app(scope, receive, send)
return
headers = Headers(scope=scope)
auth_header = headers.get("Authorization")
if auth_header:
try:
auth_type, encoded_creds = auth_header.split(" ", 1)
if auth_type.lower() == "basic":
decoded_creds = base64.b64decode(encoded_creds).decode("utf-8")
username, password = decoded_creds.split(":", 1)
# Use secrets.compare_digest to prevent timing attacks.
is_user_ok = secrets.compare_digest(username, USERNAME)
is_pass_ok = secrets.compare_digest(password, PASSWORD)
if is_user_ok and is_pass_ok:
# If credentials are valid, proceed to the application.
await self.app(scope, receive, send)
return
except (ValueError, TypeError, base64.binascii.Error):
# If header is malformed, fall through to send 401.
pass
# If authentication fails or is not provided, send a 401 response.
response = Response(
content="Unauthorized",
status_code=401,
headers={"WWW-Authenticate": "Basic"},
)
await response(scope, receive, send)
# --- End of the fix ---
# A minimal Reflex app to demonstrate the middleware in action.
def protected_page() -> rx.Component:
return rx.text("Authentication successful. Welcome to the control panel.")
app = rx.App()
app.add_page(protected_page)
# 2. Register the authentication middleware with the application.
# This is the crucial step that was missing in the vulnerable versions.
app.add_middleware(HttpBasicAuthMiddleware)Cite this entry
@misc{vaitp:cve202648797,
title = {{Backpropagate's --auth flag is not enforced, exposing the UI control plane.}},
author = {Bogaerts, Fr\'ed\'eric and Ivaki, Naghmeh and Fonseca, Jos\'e},
year = {2026},
note = {VAITP Python Vulnerability Dataset, entry CVE-2026-48797},
howpublished = {\url{https://netpack.pt/vaitp/vulnerability/CVE-2026-48797/}}
}
Introducing the "VAITP dataset": a specialized repository of Python vulnerabilities and patches, meticulously compiled for the use of the security research community. As Python's prominence grows, understanding and addressing potential security vulnerabilities become crucial. Crafted by and for the cybersecurity community, this dataset offers a valuable resource for researchers, analysts, and developers to analyze and mitigate the security risks associated with Python. Through the comprehensive exploration of vulnerabilities and corresponding patches, the VAITP dataset fosters a safer and more resilient Python ecosystem, encouraging collaborative advancements in programming security.
The supreme art of war is to subdue the enemy without fighting.
Sun Tzu – “The Art of War”
:: Shaping the future through research and ingenuity ::
