The vulnerability is caused by trusting attacker-controlled snapshot paths restored from backup files.
The vulnerable flow starts in the backup restore logic. When a backup ZIP is restored, the application extracts the archive and copies each restored watch UUID directory directly into the live datastore using shutil.copytree(entry.path, dst_dir). This preserves attacker-controlled files inside the restored watch directory, including history.txt.
Relevant code:
After restore, the application parses history.txt in the watch history property. This is the core trust-boundary issue.
Relevant code:
The relevant logic is effectively:
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
This has the following security consequence:
history.txt value is only a filename, it is resolved safely under self.data_dir.As a result, a malicious restored history.txt entry such as:
1776969105,/etc/passwd
will be accepted if the referenced file exists and is readable by the application process.
The second vulnerable step is in get_history_snapshot(). Once the untrusted path has been accepted into the watch history, the application reads the resolved path directly without enforcing that it remains inside the watch directory.
Relevant code:
That function eventually performs direct file reads such as:
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
return f.read()
The third step is reachability. The trusted history entry is consumed by both the Preview UI and the watch history API.
Relevant code:
In the Preview flow, the application selects the latest history timestamp and calls:
content = watch.get_history_snapshot(timestamp=timestamp)
In the API flow, the application also calls:
content = watch.get_history_snapshot(timestamp=timestamp)
This creates the following end-to-end exploit chain:
history.txt.history.txt parser accepts an absolute or out-of-directory path if that path exists.The root cause is that imported history entries are treated as trusted filesystem paths instead of being restricted to safe basenames under watch.data_dir.
The following proof of concept demonstrates the end-to-end exploit chain. It assumes the attacker has gained access to the backup restore functionality to upload the crafted archive.
https://example.com
Backups section and create a backup archive.watch.json. For example:5db3d3d8-71e6-4db2-a81e-e1f0445c3e47
Open that watch directory and edit history.txt.
Replace the latest history entry with a path to an existing local file that is readable by the application process. For example:
1776969188,/etc/passwd
If the timestamp differs in the extracted backup, keep the original latest timestamp and only replace the filename/path portion.
Example:
1776969188,742215043ff9be7e635f05e680ff9b11.txt
becomes:
1776969188,/etc/passwd
Important:
<watch-uuid>/
<group-uuid>/
changedetection.json
url-list.txt
After restore completes, open Preview for the restored watch.
The application will read the attacker-controlled path from history.txt and display the contents of the referenced local file instead of the original watch snapshot.
Observed result:
Expected result:
history.txt.Optional API verification:
watch.get_history_snapshot(timestamp=timestamp) on the trusted history entry.This is an arbitrary local file disclosure vulnerability reachable through malicious backup restore content.
Who is impacted:
What can be exposed:
/etc/passwd, /proc/self/environ), system-level configurations, and host metrics.By accessing the backup restore functionality and importing a crafted archive, an attacker can exploit the application's fail-open path validation. The confidentiality impact is exceptionally high because, once the payload is ingested, the application can be manipulated to disclose arbitrary local system files and highly sensitive environment variables directly through standard UI or API responses.
The application should treat all paths restored from history.txt as untrusted input.
The root cause is in changedetectionio/model/Watch.py, where values containing path separators are currently accepted as filesystem paths and preserved if the referenced file already exists.
The fix should be:
history.txt.os.path.basename(v).self.data_dir.Suggested code change:
snapshot_fname = os.path.basename(v.strip())
resolved_path = os.path.join(self.data_dir, snapshot_fname)
if not os.path.exists(resolved_path):
logger.warning(
f"Skipping unsafe or missing history entry for {self.get('uuid')}: {v!r}"
)
continue
tmp_history[k] = resolved_path
This ensures restored history entries can only reference files inside the watch's own data directory and prevents arbitrary local file reads through Preview or the history API.
{
"cwe_ids": [
"CWE-73"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-05T21:16:21Z",
"nvd_published_at": "2026-05-12T18:17:28Z",
"severity": "HIGH"
}