]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
testsuite: end-to-end regression test for chdir-symlink-race
authorAndrew Tridgell <andrew@tridgell.net>
Tue, 5 May 2026 04:34:50 +0000 (14:34 +1000)
committerAndrew Tridgell <andrew@tridgell.net>
Wed, 20 May 2026 00:01:22 +0000 (10:01 +1000)
testsuite/chdir-symlink-race.test runs an actual rsync daemon
(via RSYNC_CONNECT_PROG to avoid the network) configured with
"use chroot = no", plants a symlink at module/subdir -> ../outside,
and runs four flavours of attacker-shaped transfer (single-file
poc_chmod, -r push into the symlinked subdir with --size-only and
without, -r push into the module root). All four must leave the
outside-the-module sentinel file's mode AND content unchanged.

Portability:
  - file_mode() helper falls back to BSD stat -f %Lp when GNU
    stat -c %a is unavailable (macOS, FreeBSD).
  - Pre-saved pristine copy + cmp(1) replaces sha1sum, which
    differs across platforms (sha1sum / shasum / sha1).

Tests are kept running as root in the user-namespace re-exec
wrapper used by symlink-race tests so the daemon's setuid path
doesn't drop into the test user's identity (which on Linux
would mean the chmod-escape code path can't trigger because
the test user doesn't have CAP_FOWNER over the outside file).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
testsuite/alt-dest-symlink-race.test
testsuite/bare-do-open-symlink-race.test
testsuite/chdir-symlink-race.test [new file with mode: 0755]
testsuite/copy-dest-source-symlink.test

index 2256f2f22727bd4936b813aca06a7b6e083239a1..fd36c6e6c23ebe73930afef37fb1a4c983169acc 100755 (executable)
@@ -62,8 +62,25 @@ echo "OUTSIDE_SECRET_DATA" > "$src/target.txt"
 touch -r "$outside/target.txt" "$src/target.txt"
 chmod 0644 "$src/target.txt"
 
+# When running as root the daemon would drop to "nobody" by
+# default, which can't write into the test scratch dir. Force the
+# daemon to keep our uid/gid in that case so the basis-link
+# transfer can actually create the destination file. (Non-root
+# can't specify uid/gid in rsyncd.conf -- comment them out then.)
+my_uid=`get_testuid`
+root_uid=`get_rootuid`
+root_gid=`get_rootgid`
+uid_setting="uid = $root_uid"
+gid_setting="gid = $root_gid"
+if test x"$my_uid" != x"$root_uid"; then
+    uid_setting="#$uid_setting"
+    gid_setting="#$gid_setting"
+fi
+
 cat > "$conf" <<EOF
 use chroot = no
+$uid_setting
+$gid_setting
 log file = $scratchdir/rsyncd.log
 [upload]
     path = $mod
index b8c51bbe7918f7ed1891f93af5a9de157607809b..e295223964f036ad5a41d3714c231734cf210d27 100755 (executable)
@@ -82,6 +82,20 @@ verify_outside_unchanged_or_absent() {
     fi
 }
 
+# When running as root the daemon would drop to "nobody" by default
+# and fail to write into the test scratch dir. Force it to keep our
+# uid/gid in that case so the receiver actually runs the code paths
+# we want to test.
+my_uid=`get_testuid`
+root_uid=`get_rootuid`
+root_gid=`get_rootgid`
+uid_setting="uid = $root_uid"
+gid_setting="gid = $root_gid"
+if test x"$my_uid" != x"$root_uid"; then
+    uid_setting="#$uid_setting"
+    gid_setting="#$gid_setting"
+fi
+
 
 ############################################################
 # Scenario 3b: --inplace --backup --backup-dir=cd
@@ -103,6 +117,8 @@ chmod 0644 "$src/target.txt"
 
 cat > "$conf" <<EOF
 use chroot = no
+$uid_setting
+$gid_setting
 log file = $scratchdir/rsyncd.log
 [upload]
     path = $mod
@@ -137,6 +153,8 @@ ln -s /etc/passwd "$src/cd/sym"
 
 cat > "$conf" <<EOF
 use chroot = no
+$uid_setting
+$gid_setting
 log file = $scratchdir/rsyncd.log
 [upload_fake]
     path = $mod
@@ -170,6 +188,8 @@ fi
 
 cat > "$conf" <<EOF
 use chroot = no
+$uid_setting
+$gid_setting
 log file = $scratchdir/rsyncd.log
 [upload_fake]
     path = $mod
diff --git a/testsuite/chdir-symlink-race.test b/testsuite/chdir-symlink-race.test
new file mode 100755 (executable)
index 0000000..f5d4cb3
--- /dev/null
@@ -0,0 +1,135 @@
+#!/bin/sh
+
+# Copyright (C) 2026 by Andrew Tridgell
+
+# This program is distributable under the terms of the GNU GPL (see
+# COPYING).
+
+# Regression test for the symlink-TOCTOU class of bug at the receiver's
+# chdir(). After the CVE-2026-29518 fix to secure_relative_open(), an
+# attack remained where the receiver's chdir() into a destination
+# subdirectory followed an attacker-planted symlink, escaping the
+# module. Every subsequent path-relative syscall (open, chmod, lchown,
+# utimes, etc.) inherited the escape -- secure_relative_open's
+# RESOLVE_BENEATH anchor itself was outside the module by then, so it
+# stopped protecting against anything.
+#
+# This test runs an actual rsync daemon (via RSYNC_CONNECT_PROG to
+# avoid the network) configured with "use chroot = no", plants a
+# symlink at module/subdir -> ../outside, and runs four flavours of
+# rsync transfer that previously all reached files in ../outside:
+#
+#   1. single-file dest = subdir/target.txt    (the original poc_chmod)
+#   2. -r src/subdir/ to upload/subdir/        (the chdir-escape case)
+#   3. -r src/subdir/ to upload/subdir/        (no --size-only: forces basis read+write)
+#   4. -r src/ to upload/                      (was already protected by the
+#                                               original CVE-2026-29518 fix;
+#                                               regression-checked here)
+#
+# All four must leave the outside-the-module sentinel file's mode AND
+# content unchanged.
+
+. "$suitedir/rsync.fns"
+
+case "$(uname -s)" in
+    SunOS|OpenBSD|NetBSD|CYGWIN*)
+       test_skipped "secure chdir relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
+       ;;
+esac
+
+mod="$scratchdir/module"
+outside="$scratchdir/outside"
+src="$scratchdir/src"
+conf="$scratchdir/test-rsyncd.conf"
+
+rm -rf "$mod" "$outside" "$src"
+mkdir -p "$mod" "$outside" "$src" "$src/subdir"
+
+# Portable octal-mode helper -- macOS and FreeBSD's stat use -f, GNU
+# coreutils stat uses -c.
+file_mode() {
+    stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1"
+}
+
+# The "secret" file outside the module the attacker is trying to alter.
+# Save a pristine copy alongside it so we can compare with cmp(1) rather
+# than depending on sha1sum/shasum/sha1, which differ across platforms.
+echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt"
+chmod 0600 "$outside/target.txt"
+outside_pristine="$scratchdir/outside-pristine.txt"
+cp -p "$outside/target.txt" "$outside_pristine"
+
+# Symlink trap planted in the module by the local attacker.
+ln -s "$outside" "$mod/subdir"
+
+# Source files the sender will push: same size as the outside target,
+# different content, mode 0666 (the perms the attacker tries to push).
+SIZE=$(stat -c %s "$outside/target.txt" 2>/dev/null \
+       || stat -f %z "$outside/target.txt")
+head -c "$SIZE" /dev/urandom > "$src/target.txt"
+head -c "$SIZE" /dev/urandom > "$src/subdir/target.txt"
+chmod 0666 "$src/target.txt" "$src/subdir/target.txt"
+
+cat > "$conf" <<EOF
+use chroot = no
+log file = $scratchdir/rsyncd.log
+[upload]
+    path = $mod
+    use chroot = no
+    read only = no
+EOF
+
+reset_outside() {
+    chmod 0600 "$outside/target.txt"
+    echo "OUTSIDE_SECRET_DATA" > "$outside/target.txt"
+}
+
+verify_unchanged() {
+    label="$1"
+    mode=$(file_mode "$outside/target.txt")
+    case "$mode" in
+       600|0600) ;;
+       *) test_fail "$label: outside file mode changed from 600 to $mode (chmod escape)" ;;
+    esac
+    if ! cmp -s "$outside/target.txt" "$outside_pristine"; then
+       test_fail "$label: outside file content changed (write escape)"
+    fi
+}
+
+run_attack() {
+    label="$1"; shift
+    reset_outside
+    RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
+       $RSYNC "$@" >/dev/null 2>&1 || true
+    verify_unchanged "$label"
+}
+
+# 1. The original poc_chmod scenario: single file, dest path with
+#    the symlinked subdir as a path component. With --size-only the
+#    receiver normally skips the basis open and goes straight to chmod
+#    -- only the chdir-escape blocks the chmod from reaching outside.
+run_attack "single-file --size-only" \
+    -tp --size-only \
+    "$src/target.txt" rsync://localhost/upload/subdir/target.txt
+
+# 2. -r push into the symlinked subdir: receiver chdir's into "subdir",
+#    follows the symlink, ends up in outside.
+run_attack "-r --size-only into subdir/" \
+    -rtp --size-only \
+    "$src/subdir/" rsync://localhost/upload/subdir/
+
+# 3. Same but no --size-only -- forces the basis-file open and a real
+#    rename, so this exercises the read-disclosure and write-escape
+#    paths together.
+run_attack "-r without --size-only into subdir/" \
+    -rtp \
+    "$src/subdir/" rsync://localhost/upload/subdir/
+
+# 4. -r src/ to upload/ -- this case was already covered by the
+#    original CVE-2026-29518 fix because the receiver stays at module
+#    root and operates on slashed paths. Regression check.
+run_attack "-r --size-only into upload/ root" \
+    -rtp --size-only \
+    "$src/" rsync://localhost/upload/
+
+exit 0
index 2d20fab46256fdb6b76be06432aa8bb31afed37e..f91ee986f0bbd60f132bb450c982093489f868ff 100755 (executable)
@@ -54,8 +54,23 @@ echo "ATTACKER_KNOWN_DATA!" > "$src/target.txt"
 touch -r "$outside/target.txt" "$src/target.txt"
 chmod 0644 "$src/target.txt"
 
+# When running as root the daemon would drop to "nobody" by
+# default and fail to mkstemp in the scratch dir; force it to
+# keep our uid/gid in that case.
+my_uid=`get_testuid`
+root_uid=`get_rootuid`
+root_gid=`get_rootgid`
+uid_setting="uid = $root_uid"
+gid_setting="gid = $root_gid"
+if test x"$my_uid" != x"$root_uid"; then
+    uid_setting="#$uid_setting"
+    gid_setting="#$gid_setting"
+fi
+
 cat > "$conf" <<EOF
 use chroot = no
+$uid_setting
+$gid_setting
 log file = $scratchdir/rsyncd.log
 [upload]
     path = $mod