Attack surface · rsync
rsync: refusing to follow a symlink owned by an untrusted user (Debian 13, rsync 3.5.0)
Published · HardStacked · 10 min
Your backup script ran for months. Then a Debian 13 security update swapped rsync 3.4.1 for 3.5.0, and now it prints refusing to follow a symlink owned by an untrusted user or Too many levels of symbolic links (40). Or it prints nothing, and the backup disk fills up. Someone put the situation in one sentence on serverfault (question 1199956, created 2026-09-30, 177 views when we read it on 2026-10-25, two answers, none accepted):
"Actually, I discovered this issue because my nightly offsite backups started to fail after upgrade."
One thing explains all of it. Since rsync 3.5.0, rsync follows a symlink in the paths you give it (the destination, --link-dest, --files-from and a dozen others) only if the link belongs to root or to the user running rsync. Under sudo, the user running rsync is root, so your own link becomes "untrusted". A destination fails loudly. --link-dest does not fail: exit code 0, and your incremental backup turns into a full copy, silently. Everything below was measured on a Raspberry Pi under Debian 13.7, rsync 3.5.0, package 3.5.0+ds1-0+deb13u1.
01. You did not ask for 3.5.0
The package changelog is signed 15 Sep 2026 by the Debian maintainer, in trixie-security. The date you got it on your machine is in the dpkg log. On our workstation:
the dpkg log
$ zgrep -h " upgrade rsync:.* 3\.5\." /var/log/dpkg.log*
2026-09-29 06:08:32 upgrade rsync:arm64 3.4.1+ds1-5+deb13u4 3.5.0+ds1-0+deb13u1
The maintainer explains the jump in NEWS.Debian: "In order to fix 33 CVEs, I have decided to bump the package to 3.5.0 rather than backporting all patches individually." The same file warns that the update "contains behavior changes" and lists the one that breaks scripts: operator-supplied paths are no longer followed through untrusted symlinks. The destination directory and the arguments to --backup-dir, --temp-dir, --partial-dir, --link-dest, --compare-dest, --copy-dest, --log-file, --password-file, --files-from, --include-from, --exclude-from, --write-batch, --read-batch and filter merge files are resolved one component at a time. A symlink is followed "only when it is owned by root or by the user running rsync".
The rule is a good one. Upstream's NEWS for 3.5.0 (13 Aug 2026) describes CVE-2026-53802: rsync followed attacker-planted symlinks in files like --files-from or --password-file, and could read an arbitrary file. If someone else can write in a directory your privileged rsync walks through, a symlink there can redirect it. The fix is to check who owns each link. The side effect is that it also checks yours.
02. sudo makes your own symlink "untrusted"
The lab is a few lines in /tmp/rsync-demo. A backup symlink, owned by you, pointing to a folder that plays the backup disk:
$ mkdir -p /tmp/rsync-demo/{src,disk,snap1} && cd /tmp/rsync-demo && echo hello > src/file.txt
$ ln -s /tmp/rsync-demo/disk backup
$ ls -l backup
lrwxrwxrwx 1 user user 20 Oct 26 02:29 backup -> /tmp/rsync-demo/disk
Same command, as yourself, then under sudo:
as you, then under sudo
$ rsync -a src/ backup/; echo "exit $?"
exit 0
$ sudo rsync -a src/ backup/; echo "exit $?"
refusing to follow a symlink owned by an untrusted user; use --insecure-links locally or "insecure links = yes" in a daemon module ONLY if every path component is trusted
rsync: [Receiver] change_dir#1 "/tmp/rsync-demo/backup/" failed: Too many levels of symbolic links (40)
rsync error: errors selecting input/output files, dirs (code 3) at main.c(768) [Receiver=3.5.0]
exit 3
The more privileged run is the one that gets blocked. rsync trusts uid 0 and its own effective uid. As yourself, that is you, and your link passes. Under sudo, that is root only, and a link owned by your user is a stranger's link. The man page says the rule is "the same for absolute and relative paths", so making the link relative will not save you here (in the daemon case further down it does, for another reason). For another case where root is refused something a hardened setup no longer lets it do, see a root that gets Permission denied inside a systemd unit.
03. "Too many levels of symbolic links (40)" is not a loop
Look at the last error again. One link, pointing to a plain folder, and rsync complains about 40 levels. There is no loop. 40 is the number of ELOOP, and rsync sets it itself to mean "I refused this link". In the source (syscall.c, lines 402 to 408), the test reads lst.st_uid != 0 && lst.st_uid != trusted_uid, and when it is true the code stores ELOOP. When we traced the daemon case further down, the kernel had answered ENOTDIR, not ELOOP: the string you read comes from rsync, not from the filesystem.
The first line, refusing to follow a symlink owned by an untrusted user, comes from a Debian patch (syscall_explain_untrusted_symlink_refusal.patch, taken from upstream pull request #1074 dated 2026-09-03). On a 3.5.0 built somewhere else, that line may be missing, and all you get is the "(40)". Same cause.
04. --link-dest: exit 0, and a full copy
This is the case that costs disk. A typical incremental scheme keeps a latest link pointing to the previous snapshot, and passes it to --link-dest so unchanged files become hard links instead of copies. Here latest is a link you created, so it belongs to you:
--link-dest under sudo
$ rsync -a src/ snap1/ && ln -s snap1 latest
$ sudo rsync -a --link-dest=/tmp/rsync-demo/latest src/ snap2/; echo "exit $?"
refusing to follow a symlink owned by an untrusted user; use --insecure-links locally or "insecure links = yes" in a daemon module ONLY if every path component is trusted
refusing to follow a symlink owned by an untrusted user; use --insecure-links locally or "insecure links = yes" in a daemon module ONLY if every path component is trusted
exit 0
rsync warns twice and carries on, with exit 0. It refused to open the reference directory, so it had nothing to link to. The file proves it:
link count and inode
$ stat -c '%h %i %n' snap1/file.txt snap2/file.txt
1 191 snap1/file.txt
1 194 snap2/file.txt
Link count 1 on both, two different inodes. That is two copies, where a working --link-dest gives one inode with a count of 2. With one small file nobody cares. With a real tree and a nightly run, every night writes the whole tree again.
The warning goes to stderr. A cron job that ends in >/dev/null 2>&1, or a wrapper that only looks at the exit code, sees nothing:
stderr to /dev/null
$ sudo rsync -a --link-dest=/tmp/rsync-demo/latest src/ snap3/ 2>/dev/null; echo "exit $?"
exit 0
Nothing on screen, exit 0, and a third full copy.
05. Find the links, then fix them
To check your own setup, list the symlinks in the folders your script uses and keep the ones root does not own:
list the links
$ find /tmp/rsync-demo -maxdepth 1 -type l ! -user root -printf '%u %p -> %l\n'
user /tmp/rsync-demo/latest -> snap1
user /tmp/rsync-demo/backup -> /tmp/rsync-demo/disk
Both lab links show up. Then check your last snapshots: run stat -c %h on a file you know did not change. A count of 1 means no hard link, so that snapshot is a full copy. The zgrep from the first section tells you since when to look.
Fixes, safest first.
First, make the link belong to root. sudo chown -h root:root latest does it (the -h is for the link itself, not its target):
fix 1: the link to root
$ sudo chown -h root:root latest
$ sudo rsync -a --link-dest=/tmp/rsync-demo/latest src/ snap4/; echo "exit $?"
exit 0
$ stat -c '%h %i %n' snap1/file.txt snap4/file.txt
2 191 snap1/file.txt
2 191 snap4/file.txt
Count 2, same inode 191: the hard link is back. If your script runs as you and calls sudo rsync --link-dest=.../latest, then refreshes the link with a plain ln -sfn, you will undo this every night, because each new link belongs to whoever ran ln. The ln has to run as the same user as rsync, so sudo ln -sfn.
Second, give rsync the real path and skip the link:
fix 2: the real path
$ sudo rsync -a src/ "$(readlink -f backup)/"; echo "exit $?"
exit 0
You take over the check rsync did for you: nobody else may write in the folder behind that path.
06. The daemon, --files-from, and why -L does not help
The serverfault author runs a daemon, and their --files-from list contains an absolute link owned by root. They point out that "all links and files are owned by root, the daemon is run as root, so these links are not 'insecure'". The ownership rule is not what stops them. We rebuilt the case with a module and one absolute link inside it:
the daemon, an absolute link
$ mkdir -p mod/dir2 && echo two > mod/dir2/file2 && ln -s /tmp/rsync-demo/mod/dir2 mod/link2
$ printf 'pid file = /tmp/rsync-demo/rsyncd.pid\nuse chroot = no\naddress = localhost\n[m]\n path = /tmp/rsync-demo/mod\n read only = yes\n' > rsyncd.conf
$ cat rsyncd.conf
pid file = /tmp/rsync-demo/rsyncd.pid
use chroot = no
address = localhost
[m]
path = /tmp/rsync-demo/mod
read only = yes
$ echo link2/ > list
$ rsync --daemon --port=8873 --config=rsyncd.conf
$ rsync -4 -a --files-from=list rsync://localhost:8873/m/ dest-a/; echo "exit $?"
rsync: [sender] opendir "link2" (in m) failed: Too many levels of symbolic links (40)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1956) [generator=3.5.0]
exit 23
In the source, the function that walks paths inside a module (syscall.c, lines 2950 to 2956) has a comment that says it plainly: absolute target: refuse, and then ELOOP. Inside a module, any absolute symlink target is refused, whoever owns the link. That matches answer 1 on the question: "An absolute symlink is considered unsafe, even when it is root-owned".
Answer 2 says "adding -L can get the job done". On Debian 13 with rsync 3.5.0, not for an absolute link:
with -L
$ rsync -4 -aL --files-from=list rsync://localhost:8873/m/ dest-L/; echo "exit $?"
rsync: [sender] opendir "link2" (in m) failed: Too many levels of symbolic links (40)
rsync error: some files/attrs were not transferred (see previous errors) (code 23) at main.c(1956) [generator=3.5.0]
exit 23
Same failure, same exit 23. To be fair, that author ran 3.5.1 and we ran 3.5.0, and we never saw their link, so it may not have been absolute. What works is making the link relative:
a relative link
$ ln -sfnr mod/dir2 mod/link2 && ls -l mod/link2
lrwxrwxrwx 1 user user 4 Oct 26 02:30 mod/link2 -> dir2
$ rsync -4 -a --files-from=list rsync://localhost:8873/m/ dest-b/; echo "exit $?"
exit 0
$ find dest-b -type f
dest-b/link2/file2
Or put the real paths in the list.
07. --insecure-links is the last resort
--insecure-links brings back the old behavior: follow any symlink in those paths. For a daemon module, the equivalent is insecure links = yes. We put them last because of what the rsyncd.conf man page says about it:
WARNING: enabling "insecure links" re-opens the symlink-escape / TOCTOU vulnerabilities that the default closes (including CVE-2026-53797 and CVE-2026-53801)
On a single-user box nobody else touches, that may be an acceptable trade. On a shared host, fix the links instead. Two limits from the man page: the option is local only, "a daemon never honours it", and a client that sends it to a daemon has the request refused.
What we did not measure. Ubuntu: we have not run it, so do not assume this article applies. Rsync over SSH to a remote server: the man page says --insecure-links is not sent to the remote side, so a remote peer that needs it must get it another way, for example through --rsync-path. We did not test that. Tools that wrap rsync, like rsnapshot: not tested either. What we did measure is Debian 13.7, rsync 3.5.0, a Raspberry Pi, 2026-10-26 (UTC), and the lab comes down in two commands (stop the daemon, delete the folder):
teardown
$ kill "$(cat rsyncd.pid)"; sleep 1; cd / && sudo rm -rf /tmp/rsync-demo
$ pgrep -ax rsync || echo 'no rsync process'; ls /tmp/rsync-demo
no rsync process
ls: cannot access '/tmp/rsync-demo': No such file or directory
Run it on your own machine, in /tmp, before touching a real backup.