GHSA-68W4-83FH-F2W8
Vulnerability from github – Published: 2026-10-09 17:09 – Updated: 2026-10-09 17:09Summary
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 login
src/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_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)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.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "pyload-ng"
},
"ranges": [
{
"events": [
{
"introduced": "0.5.0b3.dev1"
},
{
"last_affected": "0.5.0b3.dev101"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-287"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-09T17:09:15Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "## Summary\n\n`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\u0027s permission check is a bitmask AND, that gate is a no-op: **every authenticated account passes, including one holding zero permission bits.**\n\nBoth 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.\n\nAn 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`.\n\n## Affected code\n\n`src/pyload/core/api/__init__.py:1446` and `:1464`\n\n```python\n #: Old API\n @permission(Perms.ANY)\n @get\n def getUserData(self, username: str, password: str) -\u003e OldUserData:\n \"\"\"\n similar to `check_auth` but returns UserData type.\n \"\"\"\n user = self.check_auth(username, password)\n ...\n\n @permission(Perms.ANY)\n @get\n def get_userdata(self, username: str, password: str) -\u003e UserData:\n user = self.check_auth(username, password)\n ...\n```\n\n## Root cause\n\n`src/pyload/core/api/__init__.py:57`\n\n```python\nclass Perms(IntFlag):\n ANY = 0 #: requires no permission, but login\n```\n\n`src/pyload/core/api/__init__.py:108`\n\n```python\ndef has_permission(user_perms: Perms, required_perms: Perms):\n return required_perms == (user_perms \u0026 required_perms)\n```\n\nFor `required_perms == 0` this evaluates to `0 == (user_perms \u0026 0)` \u2192 `0 == 0` \u2192 **always `True`**. The `@permission(Perms.ANY)` gate therefore admits every authenticated principal regardless of which permission bits they hold.\n\nContrast with the intended admin-only primitive, `src/pyload/core/api/__init__.py:1396`:\n\n```python\n @legacy(\"checkAuth\")\n @get\n def check_auth(self, username: str, password: str) -\u003e dict[str, Any]:\n```\n\n`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.\n\nBecause both wrappers also carry `@get`, they are placed in `method_map` and are directly routable via `/api/\u003cfunc\u003e`.\n\n## Amplifiers\n\n**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.\n\n**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`):\n\n```python\nclient_ip = flask.request.headers.get(\"X-Forwarded-For\", \"\").split(\",\")[0].strip() \\\n or flask.request.remote_addr\n```\n\nRotating `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 \u2014 the same guard was evidently not applied inside `rate_limit()`.\n\n## Impact\n\nOnline brute force of the administrator account leading to full administrative takeover. A successful response additionally discloses the target account\u0027s `id`, `name`, `email`, `role`, and `permission` bits.\n\n## Proof of concept\n\nVerified against 0.5.0b3, commit `a5b008958`.\n\nSetup: stock instance with admin `pyload`, plus a non-admin user `bob` created with `role=USER` and `permission=0`.\n\n**Step 1 \u2014 establish that `bob` is genuinely unprivileged:**\n\n```\nGET /api/checkAuth?username=pyload\u0026password=pyload -\u003e 401 {\"error\": \"Access denied\"}\nGET /api/getAllUserData -\u003e 401 {\"error\": \"Access denied\"}\n```\n\n**Step 2 \u2014 the flaw. Same user, same session, `Perms.ANY` gate:**\n\n```\nGET /api/getUserData?username=pyload\u0026password=WRONG\n -\u003e 200 {\"name\": null, \"email\": null, \"role\": null, \"permission\": null, \"template_name\": null}\n\nGET /api/getUserData?username=pyload\u0026password=pyload\n -\u003e 200 {\"name\": \"pyload\", \"email\": \"\", \"role\": 0, \"permission\": 0, \"template_name\": \"default\"}\n```\n\n`role: 0` is `Role.ADMIN`. `get_userdata` behaves identically. This is a perfect yes/no oracle.\n\n**Step 3 \u2014 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:\n\n```\nattempts : 667\nelapsed : 39.7s (16.8 guesses/sec)\n429 rate-limits : 0\naccount lockout : NONE - same session authenticated throughout\nRECOVERED SECRET : \u0027zq\u0027 (true value \u0027zq\u0027) match=True\n```\n\n**Step 4 \u2014 full takeover with the recovered secret:**\n\n```\nPOST /login as pyload/\u003crecovered\u003e -\u003e HTTP 302 (authenticated as admin)\nGET /api/getAllUserData (admin-only) -\u003e HTTP 200 (full user dump)\n```\n\nMeasured 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.\n\n## Suggested remediation\n\n- Remove `@permission(Perms.ANY)` from `getUserData` and `get_userdata`, or drop them entirely \u2014 they are legacy compatibility shims, and modern callers already use the admin-only `check_auth`.\n- 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\".\n- 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.\n- Add per-account failed-authentication throttling or lockout.\n- 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).\n\n## Notes for triage\n\nThere is no `0.5.0b3` release on PyPI. The `develop` branch auto-publishes dev builds (`setup.py:86` appends `.dev\u003cbuild\u003e` 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 \u2014 please confirm whether `0.5.0b2.*` and `0.4.x` are affected before finalizing the version range.",
"id": "GHSA-68w4-83fh-f2w8",
"modified": "2026-10-09T17:09:15Z",
"published": "2026-10-09T17:09:15Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/pyload/pyload/security/advisories/GHSA-68w4-83fh-f2w8"
},
{
"type": "WEB",
"url": "https://github.com/pyload/pyload/commit/b99d2a2f06135363ceb0aab16aac5025f138e658"
},
{
"type": "PACKAGE",
"url": "https://github.com/pyload/pyload"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "pyload-ng: getUserData/get_userdata exposed at Perms.ANY allow any authenticated account to brute-force the administrator password"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.