# changedetection.io has an Arbitrary Local File Read via a crafted backup restore

changedetection.io trusts attacker-controlled paths in restored history.txt, letting a crafted backup ZIP make Preview/API read arbitrary local files like /etc/passwd.

- Payout: Research
- Severity: high
- Program: changedetection.io
- Found by: minhlh56
- Disclosed: 2026-05-05
- CVE: CVE-2026-43891
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-8757-69j2-hx56
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-8757-69j2-hx56
- Tags: area:web domain:web impact:data-leak kind:research platform:ghsa sev:high tech:python technique:source-review vuln:info-disclosure vuln:path-traversal

**Target:** Backup restore / watch history snapshot reader (Python web app)
**Root cause:** Restored history.txt entries containing path separators are accepted as filesystem paths and preserved if the referenced file exists, instead of being restricted to basenames under watch.data_dir; get_history_snapshot then opens the resolved path directly.
**Steps:**
1. Create a watch (e.g. `https://example.com`) and trigger a successful check to generate a history entry.
2. Create a backup archive from the Backups section and extract it.
3. Locate the watch UUID directory containing watch.json and edit `history.txt`.
4. Replace the latest entry's filename with a local path, e.g. `1776969188,/etc/passwd` (keep original timestamp).
5. Repack the ZIP with UUID directories at the archive root (no extra parent layer).
6. Restore the modified backup in the UI, enabling replacement of existing watches with the same UUID.
7. Open Preview for the restored watch; the app reads the attacker path and displays the local file contents.
8. Optionally request the modified timestamp via the watch history API, which returns the same file content.
**How found:** Code review of the backup restore flow (shutil.copytree preserving history.txt) and the history path resolution logic in Watch.py, noticing that values with path separators are kept unchanged when the target file exists, then tracing get_history_snapshot to a direct open().
**Exploitation:** Attacker with access to backup restore uploads a crafted ZIP whose history.txt points at an existing readable local file; after restore, Preview UI or watch history API dereferences the stored path and returns file contents.
**Insight:** When auditing backup/import features, check whether restored metadata files (history/index/manifest) are re-parsed as trusted paths; a basename-only check that falls back to accepting existing external paths is a fail-open trust boundary.

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

**Crafted history.txt entry pointing to /etc/passwd**

```
1776969188,742215043ff9be7e635f05e680ff9b11.txt

becomes:

1776969188,/etc/passwd
```

**Vulnerable path resolution logic in Watch.py**

```
if os.sep not in v and '/' not in v and '\\' not in v:
    v = os.path.join(self.data_dir, v)
else:
    snapshot_fname = os.path.basename(v)
    proposed_new_path = os.path.join(self.data_dir, snapshot_fname)
    if not os.path.exists(v) and os.path.exists(proposed_new_path):
        v = proposed_new_path
```

**Direct file read in get_history_snapshot**

```
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
    return f.read()
```

