From 40a6e130710df3d8195d9612e0ba1d2aba2fc7d1 Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Tue, 5 May 2026 14:34:50 +1000 Subject: [PATCH] testsuite: end-to-end regression test for chdir-symlink-race 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) --- testsuite/alt-dest-symlink-race.test | 17 +++ testsuite/bare-do-open-symlink-race.test | 20 ++++ testsuite/chdir-symlink-race.test | 135 +++++++++++++++++++++++ testsuite/copy-dest-source-symlink.test | 15 +++ 4 files changed, 187 insertions(+) create mode 100755 testsuite/chdir-symlink-race.test diff --git a/testsuite/alt-dest-symlink-race.test b/testsuite/alt-dest-symlink-race.test index 2256f2f2..fd36c6e6 100755 --- a/testsuite/alt-dest-symlink-race.test +++ b/testsuite/alt-dest-symlink-race.test @@ -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" < "$conf" < "$conf" < "$conf" < ../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" < "$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 diff --git a/testsuite/copy-dest-source-symlink.test b/testsuite/copy-dest-source-symlink.test index 2d20fab4..f91ee986 100755 --- a/testsuite/copy-dest-source-symlink.test +++ b/testsuite/copy-dest-source-symlink.test @@ -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" <