pyload-ng: getUserData/get_userdata exposed at Perms.ANY allow any authenticated account to brute-force the administrator password
What fixes it
No source in this snapshot names a fixed version. The vendor advisories under References are the place to check.
Description
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
#: 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
class Perms(IntFlag):
ANY = 0 #: requires no permission, but loginsrc/pyload/core/api/__init__.py:108
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:
@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):
client_ip = flask.request.headers.get("X-Forwarded-For", "").split(",")[0].strip() \
or flask.request.remote_addrRotating 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)fromgetUserDataandget_userdata, or drop them entirely — they are legacy compatibility shims, and modern callers already use the admin-onlycheck_auth. - Structurally:
Perms.ANY = 0makeshas_permission()vacuous, so any method decorated@permission(Perms.ANY)silently becomes public to every logged-in user. GiveANYa real bit value, or handle it explicitly inhas_permission()as "requires an authenticated session". - Do not trust
X-Forwarded-Forunless a trusted-proxy deployment is explicitly configured. Otherwise bucketrate_limit()onrequest.remote_addr, or add the same guardis_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:61uses 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.
Text from GitHub Advisory Database
Affected versions 52
- pyload-ng, pip>= 0.5.0b3.dev1, <= 0.5.0b3.dev101GitHub
- pyload-ng, PyPIfrom 0.5.0b3.dev1, through 0.5.0b3.dev101OSV
- pyload-ng, PyPI0.5.0b3.dev100OSV
- pyload-ng, PyPI0.5.0b3.dev101OSV
- pyload-ng, PyPI0.5.0b3.dev13OSV
- pyload-ng, PyPI0.5.0b3.dev14OSV
- pyload-ng, PyPI0.5.0b3.dev17OSV
- pyload-ng, PyPI0.5.0b3.dev18OSV
- pyload-ng, PyPI0.5.0b3.dev19OSV
- pyload-ng, PyPI0.5.0b3.dev20OSV
- pyload-ng, PyPI0.5.0b3.dev21OSV
- pyload-ng, PyPI0.5.0b3.dev22OSV
- pyload-ng, PyPI0.5.0b3.dev24OSV
- pyload-ng, PyPI0.5.0b3.dev26OSV
- pyload-ng, PyPI0.5.0b3.dev27OSV
- pyload-ng, PyPI0.5.0b3.dev28OSV
- pyload-ng, PyPI0.5.0b3.dev29OSV
- pyload-ng, PyPI0.5.0b3.dev30OSV
- pyload-ng, PyPI0.5.0b3.dev31OSV
- pyload-ng, PyPI0.5.0b3.dev32OSV
- pyload-ng, PyPI0.5.0b3.dev33OSV
- pyload-ng, PyPI0.5.0b3.dev34OSV
- pyload-ng, PyPI0.5.0b3.dev35OSV
- pyload-ng, PyPI0.5.0b3.dev38OSV
- pyload-ng, PyPI0.5.0b3.dev39OSV
- pyload-ng, PyPI0.5.0b3.dev40OSV
- pyload-ng, PyPI0.5.0b3.dev41OSV
- pyload-ng, PyPI0.5.0b3.dev42OSV
- pyload-ng, PyPI0.5.0b3.dev43OSV
- pyload-ng, PyPI0.5.0b3.dev44OSV
- pyload-ng, PyPI0.5.0b3.dev45OSV
- pyload-ng, PyPI0.5.0b3.dev46OSV
- pyload-ng, PyPI0.5.0b3.dev47OSV
- pyload-ng, PyPI0.5.0b3.dev48OSV
- pyload-ng, PyPI0.5.0b3.dev49OSV
- pyload-ng, PyPI0.5.0b3.dev50OSV
- pyload-ng, PyPI0.5.0b3.dev51OSV
- pyload-ng, PyPI0.5.0b3.dev52OSV
- pyload-ng, PyPI0.5.0b3.dev53OSV
- pyload-ng, PyPI0.5.0b3.dev54OSV
- pyload-ng, PyPI0.5.0b3.dev57OSV
- pyload-ng, PyPI0.5.0b3.dev60OSV
- pyload-ng, PyPI0.5.0b3.dev62OSV
- pyload-ng, PyPI0.5.0b3.dev64OSV
- pyload-ng, PyPI0.5.0b3.dev65OSV
- pyload-ng, PyPI0.5.0b3.dev66OSV
- pyload-ng, PyPI0.5.0b3.dev67OSV
- pyload-ng, PyPI0.5.0b3.dev68OSV
- pyload-ng, PyPI0.5.0b3.dev69OSV
- pyload-ng, PyPI0.5.0b3.dev70OSV
- pyload-ng, PyPI0.5.0b3.dev71OSV
- pyload-ng, PyPI0.5.0b3.dev72OSV