Partager :
Surface d'attaque · rsync
rsync : « refusing to follow a symlink owned by an untrusted user » (Debian 13, rsync 3.5.0)
Publié le · HardStacked · 10 min
Ton script de backup tournait depuis des mois. Puis une mise à jour de sécurité de Debian 13 a remplacé rsync 3.4.1 par 3.5.0, et maintenant il affiche refusing to follow a symlink owned by an untrusted user ou Too many levels of symbolic links (40). Ou il n'affiche rien, et le disque de backup se remplit. Sur serverfault, quelqu'un l'a dit en une phrase (question 1199956, créée le 2026-09-30, 177 vues quand on l'a lue le 2026-10-25, deux réponses, aucune acceptée) :
« Actually, I discovered this issue because my nightly offsite backups started to fail after upgrade. » En français : « En fait, j'ai découvert ce problème parce que mes backups hors site de la nuit ont commencé à échouer après la mise à jour. »
Une seule chose explique tout. Depuis rsync 3.5.0, rsync suit un lien symbolique dans les chemins que tu lui donnes (la destination, --link-dest, --files-from et une dizaine d'autres) seulement si le lien appartient à root ou à l'utilisateur qui lance rsync. Sous sudo, l'utilisateur qui lance rsync, c'est root : ton propre lien devient « untrusted ». Une destination échoue bruyamment. --link-dest n'échoue pas : code de sortie 0, et ton backup incrémental devient une copie complète, en silence. Tout ce qui suit a été mesuré sur un Raspberry Pi sous Debian 13.7, rsync 3.5.0, paquet 3.5.0+ds1-0+deb13u1.
01. Tu n'as pas demandé la 3.5.0
Le changelog du paquet est daté du 15 septembre 2026, dans trixie-security. La date de ta mise à jour se lit dans le journal dpkg. Sur notre poste :
le journal dpkg
$ 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
Le mainteneur explique le saut dans 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. » Le même fichier prévient que la mise à jour « contains behavior changes », dont celui qui casse les scripts : les chemins fournis par l'opérateur ne sont plus suivis à travers des liens non fiables. La destination et les arguments de --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 et des fichiers merge sont résolus un composant à la fois. Un lien est suivi « only when it is owned by root or by the user running rsync ».
La règle est bonne. Le NEWS amont de la 3.5.0 (13 août 2026) décrit la CVE-2026-53802 : rsync suivait des liens plantés par un attaquant dans des fichiers comme --files-from ou --password-file, et pouvait lire un fichier arbitraire. Si quelqu'un d'autre peut écrire dans un dossier que ton rsync privilégié traverse, un lien posé là peut le rediriger. Le correctif vérifie qui possède chaque lien, et il vérifie aussi les tiens.
02. sudo rend ton propre lien « untrusted »
Le labo vit dans /tmp/rsync-demo. Un lien backup, à toi, vers un dossier qui joue le disque de backup :
$ 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
Même commande, avec ton compte, puis sous sudo :
avec ton compte, puis sous 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
Le plus privilégié est celui qui se fait bloquer. rsync fait confiance à l'uid 0 et à son propre uid effectif. Avec ton compte, c'est toi, et ton lien passe. Sous sudo, c'est root seulement, et un lien à ton utilisateur est celui d'un inconnu. La règle est « the same for absolute and relative paths », dit le man : un lien relatif ne te sauve pas ici (dans le cas du démon, plus bas, oui, pour une autre raison). Pour un autre cas où root se voit refuser ce qu'un réglage durci ne lui permet plus, voir un root qui reçoit Permission denied dans une unité systemd.
03. « Too many levels of symbolic links (40) » n'est pas une boucle
Un seul lien, vers un dossier ordinaire, et rsync se plaint de 40 niveaux. Il n'y a aucune boucle. 40, c'est le numéro d'ELOOP, et c'est rsync lui-même qui le pose pour dire « j'ai refusé ce lien ». Dans le source (syscall.c, lignes 402 à 408), le test est lst.st_uid != 0 && lst.st_uid != trusted_uid, et quand il est vrai le code enregistre ELOOP. En traçant le cas du démon, plus bas, on a vu le noyau répondre ENOTDIR, pas ELOOP : le texte que tu lis vient de rsync, pas du système de fichiers.
La première ligne, refusing to follow a symlink owned by an untrusted user, vient d'un patch Debian (syscall_explain_untrusted_symlink_refusal.patch, repris de la pull request amont nº1074 du 2026-09-03). Sur une 3.5.0 compilée ailleurs, elle peut manquer, et il ne reste que le « (40) ». Même cause.
04. --link-dest : code 0, et une copie complète
C'est le cas qui coûte du disque. Un schéma incrémental typique garde un lien latest vers le dernier snapshot et le passe à --link-dest, pour que les fichiers inchangés deviennent des liens durs. Ici latest est un lien que tu as créé, donc il t'appartient :
--link-dest sous 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 avertit deux fois et continue, avec le code 0. Il a refusé d'ouvrir le dossier de référence, donc rien à lier. Le fichier le prouve :
compteur et inode
$ stat -c '%h %i %n' snap1/file.txt snap2/file.txt
1 191 snap1/file.txt
1 194 snap2/file.txt
Compteur à 1 des deux côtés, deux inodes différents : deux copies, là où un --link-dest qui marche donne un seul inode, compteur à 2. Avec un petit fichier, personne ne s'en soucie. Avec un vrai arbre, chaque nuit réécrit tout l'arbre.
L'avertissement sort sur stderr. Une tâche cron qui finit par >/dev/null 2>&1, ou un wrapper qui ne regarde que le code de sortie, ne voit rien :
stderr vers /dev/null
$ sudo rsync -a --link-dest=/tmp/rsync-demo/latest src/ snap3/ 2>/dev/null; echo "exit $?"
exit 0
Rien à l'écran, code 0, une troisième copie complète.
05. Trouve les liens, puis corrige
Pour vérifier ton installation, liste les liens des dossiers que ton script utilise et garde ceux que root ne possède pas :
lister les liens
$ 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
Les deux liens du labo sortent. Ensuite, lance stat -c %h sur un fichier inchangé de ton dernier snapshot : un compteur à 1 veut dire pas de lien dur, donc copie complète. Le zgrep de la première section te dit depuis quand chercher.
Les correctifs, du plus sûr au moins sûr.
D'abord, fais appartenir le lien à root. sudo chown -h root:root latest s'en charge (le -h vise le lien lui-même, pas sa cible) :
correctif 1 : le lien à 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
Compteur à 2, même inode 191 : le lien dur est revenu. Si ton script tourne sous ton compte, appelle sudo rsync --link-dest=.../latest, puis rafraîchit le lien avec un ln -sfn sans sudo, tu défais le correctif chaque nuit : chaque nouveau lien appartient à celui qui a lancé ln. Le ln doit tourner avec le même utilisateur que rsync, donc sudo ln -sfn.
Ensuite, donne à rsync le chemin réel et oublie le lien :
correctif 2 : le chemin réel
$ sudo rsync -a src/ "$(readlink -f backup)/"; echo "exit $?"
exit 0
Tu reprends à ton compte la vérification que rsync faisait : personne d'autre ne doit pouvoir écrire dans le dossier derrière ce chemin.
06. Le démon, --files-from, et pourquoi -L n'aide pas
L'auteur de la question serverfault fait tourner un démon, et sa liste --files-from contient un lien absolu qui appartient à root. La question le souligne : « all links and files are owned by root, the daemon is run as root, so these links are not "insecure" ». Ce n'est pas la propriété qui bloque ce transfert. On a reconstruit le cas avec un module et un lien absolu dedans :
le démon, un lien absolu
$ 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
Dans le source, la fonction qui parcourt les chemins d'un module (syscall.c, lignes 2950 à 2956) dit sans détour : absolute target: refuse, puis ELOOP. Dans un module, toute cible de lien absolue est refusée, peu importe le propriétaire. Ça rejoint la réponse 1 : « An absolute symlink is considered unsafe, even when it is root-owned ».
La réponse 2 dit « adding -L can get the job done ». Sur Debian 13 avec rsync 3.5.0, pas pour un lien absolu :
avec -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
Même échec, même code 23. Pour être juste, l'auteur de cette réponse était en 3.5.1 et nous en 3.5.0, et on n'a jamais vu son lien : il n'était peut-être pas absolu. Ce qui marche, c'est de rendre le lien relatif :
un lien relatif
$ 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
Ou de mettre les chemins réels dans la liste.
07. --insecure-links, en dernier recours
--insecure-links rétablit l'ancien comportement : suivre n'importe quel lien dans ces chemins. Pour un module de démon, l'équivalent est insecure links = yes. On les met en dernier à cause de ce que la page man de rsyncd.conf en dit :
WARNING: enabling "insecure links" re-opens the symlink-escape / TOCTOU vulnerabilities that the default closes (including CVE-2026-53797 and CVE-2026-53801)
Sur une machine à un seul utilisateur, que personne d'autre ne touche, ça peut être un compromis acceptable. Sur un hôte partagé, corrige les liens. Deux limites, d'après le man : l'option est locale seulement, « a daemon never honours it », et un client qui l'envoie à un démon voit sa demande refusée.
Ce qu'on n'a pas mesuré. Ubuntu : pas testé, ne suppose pas que l'article s'y applique. Rsync par SSH vers un serveur distant : le man dit que --insecure-links n'est pas transmis à l'autre côté (il faut le régler là-bas, par exemple avec --rsync-path), pas testé. Les outils qui enveloppent rsync, comme rsnapshot : pas testés. Ce qu'on a mesuré : Debian 13.7, rsync 3.5.0, un Raspberry Pi, le 2026-10-26 (UTC). Le labo se démonte en deux commandes (arrêter le démon, effacer le dossier) :
démontage
$ 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
Refais-le sur ta machine, dans /tmp, avant de toucher à un vrai backup.
Partager :