PT-2026-107508 · Pypi+1 · Wger
CVE-2026-43976
·
Publicado
2026-10-07
·
Atualizado
2026-10-07
CVSS v3.1
7.1
Alta
| Vetor | AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N |
Summary
Five gym management views in wger apply a flawed gym-scope guard (
gym a != gym b) that silently passes when both operands are None. A trainer with gym.gym trainer and gym.add adminusernote permissions and no gym assignment (gym=None) can read private admin notes, uploaded documents, gym contracts, user configuration, and user permission data for any other unaffiliated user on the instance. The subsequent querysets filter only on the attacker-supplied member id with no secondary gym-scoped validation, so all records are disclosed.Details
Files:
wger/gym/views/user.py, wger/gym/views/admin notes.py, wger/gym/views/document.py, wger/gym/views/contract.py, wger/gym/views/user config.pyThe same flawed comparison pattern appears across at least five views:
python
# VULNERABLE - applied in admin notes list, documents list, contracts list,
# user config, and gym permissions user edit
if request.user.userprofile.gym != user.userprofile.gym:
return HttpResponseForbidden()
# After the guard (admin notes example):
notes = AdminUserNote.objects.filter(member=member) # only filtered by member idWhen both
request.user.userprofile.gym and user.userprofile.gym are None, Python evaluates None != None as False, and HttpResponseForbidden is never reached. The subsequent queryset applies only the attacker-supplied member (user ID) as a filter — there is no secondary check tying the queryset to the requesting trainer's gym. All private admin notes, documents, and contracts for the target user are returned in the response body.Affected endpoints:
GET /en/gym/notes/list/user/<member pk>-> admin notes list viewGET /en/gym/documents/list/user/<member pk>-> documents list viewGET /en/gym/contract/list/<member pk>-> contracts list viewGET /en/gym/user/<member pk>/config-> user config viewGET /en/gym/user/<member pk>/permissions-> permission edit view
Suggested patch:
diff
--- a/wger/gym/views/user.py
+++ b/wger/gym/views/user.py
- if request.user.userprofile.gym != user.userprofile.gym:
- return HttpResponseForbidden()
+ trainer gym id = request.user.userprofile.gym id
+ member gym id = user.userprofile.gym id
+
+ if trainer gym id is None or trainer gym id != member gym id:
+ return HttpResponseForbidden()
# Also tighten the queryset with a gym-scoped secondary filter:
- notes = AdminUserNote.objects.filter(member=member)
+ notes = AdminUserNote.objects.filter(
+ member=member,
+ member userprofile gym id=request.user.userprofile.gym id,
+ )Extract a shared helper
assert same gym(trainer, member) and call it consistently from all five affected views to eliminate future drift.PoC
Tested on
wger/server:latest Docker image. Test users: trainer1 (gym.gym trainer + gym.add adminusernote permissions, userprofile.gym=None) and alice (regular user, userprofile.gym=None, has a private admin note pre-seeded).Step 1 - Authenticate as trainer with required perms and gym=None:
POST /en/user/login HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
username=trainer1&password=[REDACTED]&csrfmiddlewaretoken=[REDACTED]
-> 302 Found; Set-Cookie: sessionid=[trainer1 session]Step 2 - Read victim's private admin notes:
GET /en/gym/notes/list/user/2 HTTP/1.1
Host: target
Cookie: sessionid=[trainer1 session]
-> 200 OK
body contains all private admin notes for user 2:
"PRIVATE NOTE ABOUT ALICE SALARY 50K"
"PHASE4 SECRET SALARY 100K"Step 3 - Read victim's documents and contracts (same pattern):
GET /en/gym/documents/list/user/2
GET /en/gym/contract/list/2
-> 200 OK for each; all records disclosedStep 4 (optional) - Mass enumeration across all gym=None users:
Iterate user PKs 1..N:
GET /en/gym/notes/list/user/{uid}
-> 200 = gym=None victim (notes leaked)
-> 403 = gym-assigned user (check works correctly when gym values differ)RBAC Disproof Protocol:
- Scenario A (admin, gym=1 -> member gym=1) -> HTTP 200 (expected - same-gym read is a documented feature)
- Scenario B (trainer1, gym=None -> alice gym=None) -> HTTP 200 with PII in body (expected HTTP 403)
- Scenario C (admin, gym=1 -> alice gym=None) -> HTTP 403 (expected - guard works when gyms differ; confirms bypass is
None-specific)
Reproducibility: 2/2 runs after clean-baseline database reset.
Impact
An authenticated trainer with
gym.gym trainer + gym.add adminusernote permissions and userprofile.gym=None can:- Enumerate and read all private admin notes for every other
gym=Noneuser (notes may contain salary data, medical notes, disciplinary records). - Download all uploaded documents attached to those users (contracts, ID scans, medical forms).
- Read all gym contracts (financial terms, subscription details).
- Read user configuration details.
- Via the permissions endpoint, potentially modify victim permissions (creates a privilege escalation path - not fully explored in this submission but the endpoint is reachable).
Affected deployments: every wger instance where
gym.gym trainer + gym.add adminusernote are delegated to non-admin users AND any other users exist with gym=None. The gym=None state is the default for newly registered users before manual gym assignment, so every public-registration wger instance is affected.Severity: High (CVSS 7.1). Network-reachable, low complexity, requires only low privilege (delegated trainer), scope unchanged (same wger authority), high confidentiality loss across all unaffiliated accounts, low integrity impact (permission-edit view reachable).
This is structurally the same bug class as the sibling findings affecting
trainer login and reset user password (submitted separately). The root cause - Django ORM object-!= returning False when both sides are None - warrants a shared same gym() helper applied across all five views.Exploit
Correção
Incorrect Authorization
Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾
Enumeração de Fraquezas
Identificadores relacionados
Produtos afetados
Wger