changedetection.io CVE-2026-43891
HIGHSeverity by source
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Primary rating from GitHub Advisory · only source for this CVE.
CVSS VectorGitHub Advisory
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Lifecycle Timeline
2DescriptionGitHub Advisory
Details
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_pathThis has the following security consequence:
- If the
history.txtvalue is only a filename, it is resolved safely underself.data_dir. - If the value contains path separators, it is treated as a path reference rather than a watch-local snapshot name.
- If that external path already exists, it is preserved unchanged.
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:
- An attacker supplies a crafted backup ZIP.
- The restore process preserves attacker-controlled
history.txt. - The
history.txtparser accepts an absolute or out-of-directory path if that path exists. - Preview or the history API dereferences the stored path directly.
- The application returns the contents of the targeted local file.
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.
PoC
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.
- Create a normal watch in the UI, for example:
https://example.com- Trigger at least one successful check so the watch generates a valid history entry and can later be included in a backup.
<img width="1230" height="587" alt="image" src="https://github.com/user-attachments/assets/a76302c9-48d6-4aa3-9bfc-0ba2fdb31156" />
- Go to the
Backupssection and create a backup archive.
<img width="1232" height="390" alt="image" src="https://github.com/user-attachments/assets/8fef0444-d8f0-4db6-be75-04fa7db2fec8" />
- Extract the backup archive and identify the watch UUID directory that contains the target watch's
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/passwdIf the timestamp differs in the extracted backup, keep the original latest timestamp and only replace the filename/path portion.
Example:
1776969188,742215043ff9be7e635f05e680ff9b11.txtbecomes:
1776969188,/etc/passwd<img width="1139" height="366" alt="image" src="https://github.com/user-attachments/assets/07277b36-4747-4181-82d1-523385b40de3" />
- Repack the backup so that the UUID directories are located at the root of the ZIP archive.
Important:
- Do not add an extra parent directory layer when repacking.
- The archive root should contain directories such as:
<watch-uuid>/
<group-uuid>/
changedetection.json
url-list.txt<img width="986" height="353" alt="image" src="https://github.com/user-attachments/assets/21b30368-2cb5-4b88-9055-79d5bf06ad48" />
- In the UI, restore the modified backup and enable replacement of existing watches with the same UUID.
<img width="1229" height="700" alt="image" src="https://github.com/user-attachments/assets/79abd9d2-8b2d-4e08-abf3-3b63238a8055" />
- After restore completes, open
Previewfor the restored watch. - The application will read the attacker-controlled path from
history.txtand display the contents of the referenced local file instead of the original watch snapshot.
Observed result:
- The Preview page returns the content of the attacker-selected local file.
Expected result:
- The application should reject absolute paths or out-of-directory paths restored from
history.txt. - Snapshot history should be restricted to files within the watch's own data directory.
Optional API verification:
- The same issue can also be confirmed through the watch history API by requesting the modified timestamp after restore.
- The API returns the same file content because it also calls
watch.get_history_snapshot(timestamp=timestamp)on the trusted history entry.
<img width="1233" height="545" alt="image" src="https://github.com/user-attachments/assets/a2814a5a-2cdd-49a1-916b-c956dd5fda1f" />
Impact
This is an arbitrary local file disclosure vulnerability reachable through malicious backup restore content.
Who is impacted:
- Deployments where the application process has read access to sensitive local system files.
- Docker or host-mounted environments where secrets, config files, or operational artifacts are explicitly readable by the service.
What can be exposed:
- Arbitrary System Files: Core operating system files (e.g.,
/etc/passwd,/proc/self/environ), system-level configurations, and host metrics. - Application Data: Internal records and files residing under the /datastore directory.
- Secrets & Artifacts: Application-local configuration files, API tokens, database credentials, and other sensitive artifacts accessible to the application process.
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.
Recommendation
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:
- Never trust absolute or external paths from
history.txt. - Normalize every history entry to
os.path.basename(v). - Join the normalized filename to
self.data_dir. - Skip the entry if the resolved file does not exist inside the watch directory.
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_pathThis 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.
AnalysisAI
Arbitrary local file disclosure in changedetection.io allows remote unauthenticated attackers to read sensitive system files via crafted backup archives. When a malicious backup ZIP is uploaded and restored, the application trusts attacker-controlled paths in the history.txt file, enabling reads of files like /etc/passwd, environment variables, application secrets, and mounted Docker volumes through the Preview UI or history API. This vulnerability (CVSS 7.5) affects all versions through 0.54.10, with fix available in 0.55.1. No active exploitation (KEV) confirmed, but a detailed proof-of-concept exists demonstrating the complete attack chain from backup modification to file exfiltration. EPSS data not available, but the combination of network attack vector, no authentication requirement, and public exploit code makes this a priority for immediate patching.
Technical ContextAI
The vulnerability stems from CWE-73 (External Control of File Name or Path) in the Python-based changedetection.io application's backup restore mechanism. The affected code in changedetectionio/model/Watch.py fails to sanitize file paths restored from backup archives' history.txt files. When processing watch history entries, the application checks if a value contains path separators (os.sep, '/', '\') and if the referenced external path exists, it preserves the attacker-controlled absolute or relative path unchanged instead of normalizing to a safe basename. This path is then passed directly to Python's open() function in get_history_snapshot() without directory traversal validation. The backup restore flow uses shutil.copytree() to transplant entire watch UUID directories from untrusted ZIP archives into the live datastore, preserving malicious history.txt payloads. The vulnerable code path is reachable through both the web UI Preview feature (changedetectionio/blueprint/ui/preview.py) and the REST API watch history endpoint (changedetectionio/api/Watch.py), both of which call get_history_snapshot() on trusted history entries. The CPE identifier pkg:pip/changedetection.io indicates this is a Python package distributed via PyPI, commonly deployed in Docker containers with host filesystem mounts.
RemediationAI
Upgrade to changedetection.io version 0.55.1 or later immediately. For PyPI installations, run 'pip install --upgrade changedetection.io>=0.55.1'. For Docker deployments, update container images to 'dgtlmoon/changedetection.io:0.55.1' or later and redeploy. The fix normalizes all history.txt paths to safe basenames using os.path.basename() and restricts resolution to the watch's data directory, preventing directory traversal. Until patching is complete, implement these compensating controls: (1) Restrict access to the /backups/restore endpoint to only trusted administrators via reverse proxy ACLs or application authentication (verify current auth status first-if already authenticated-only, this reduces risk). Trade-off: may complicate legitimate backup restore workflows. (2) Run the application process with minimal filesystem permissions using Docker user remapping (--userns-remap) or Kubernetes securityContext, limiting readable files to application data only. Trade-off: requires container reconfiguration and testing. (3) Enable read-only root filesystem in Docker (--read-only flag with tmpfs mounts for /tmp) to prevent write access to restored malicious history.txt, though this does not prevent reads of existing files. Trade-off: requires application testing for write path compatibility. (4) Monitor file access logs for unexpected reads outside /datastore directory, especially /etc/, /proc/, and mounted secret paths. These mitigations reduce but do not eliminate risk-patching to 0.55.1 is the only complete remediation. See vendor advisory at https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-8757-69j2-hx56.
Wazuh SIEM platform versions 4.4.0 through 4.9.0 contain an unsafe deserialization vulnerability in the DistributedAPI t
BentoML version 1.4.2 and earlier contains an unauthenticated remote code execution vulnerability through insecure deser
pgAdmin 4 contains critical remote code execution vulnerabilities in the Query Tool download and Cloud Deployment endpoi
The renderLocalView function in render/views.py in graphite-web in Graphite 0.9.5 through 0.9.10 uses the pickle Python
BentoML is a Python library for building online serving systems optimized for AI apps and model inference. Rated critica
OpenSSL before 0.9.8za, 1.0.0 before 1.0.0m, and 1.0.1 before 1.0.1h does not properly restrict processing of ChangeCiph
pyLoad download manager version prior to 0.5.0b3.dev77 exposes the Flask SECRET_KEY through an unauthenticated endpoint.
In Mercurial before 4.1.3, "hg serve --stdio" allows remote authenticated users to launch the Python debugger, and conse
Unauthenticated remote code execution in Marimo ≤0.20.4 allows attackers to execute arbitrary system commands via the `/
pyLoad is the free and open-source Download Manager written in pure Python. Rated medium severity (CVSS 5.3), this vulne
Langflow (a visual LLM pipeline builder) contains a critical unauthenticated code execution vulnerability (CVE-2026-3301
Cross-user flow execution in Langflow (< 1.9.1) lets any authenticated API-key holder run another user's flow by passing
Same weakness CWE-73 – External Control of File Name or Path
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-8757-69j2-hx56