Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cygwin-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
- name: info
run: bash -c '/usr/local/bin/rsync --version'
- name: check
run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,bare-do-open-symlink-race,chdir-symlink-race,chmod-symlink-race,chown,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis make check'
run: bash -c 'RSYNC_EXPECT_SKIPPED=acls-default,acls,bare-do-open-symlink-race,chdir-symlink-race,chown,daemon-chroot-acl,devices,dir-sgid,open-noatime,protected-regular,sender-flist-symlink-leak,simd-checksum,symlink-dirlink-basis make check'
- name: ssl file list
run: bash -c 'PATH="/usr/local/bin:$PATH" rsync-ssl --no-motd download.samba.org::rsyncftp/ || true'
- name: save artifact
Expand Down
72 changes: 69 additions & 3 deletions t_chmod_secure.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@

#include <sys/stat.h>

#ifdef __linux__
#include <sys/syscall.h>
#include <linux/openat2.h>
#endif

int dry_run = 0;
int am_root = 0;
int am_sender = 0;
Expand All @@ -30,6 +35,42 @@ short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG];

static int errs = 0;

/* Probe the running kernel for the RESOLVE_BENEATH-equivalent confinement
* that secure_relative_open() prefers over the per-component O_NOFOLLOW
* walk. Returns 1 if either openat2(RESOLVE_BENEATH) on Linux 5.6+ or
* openat(O_RESOLVE_BENEATH) on FreeBSD 13+ / macOS 15+ is honoured by
* the running kernel, 0 otherwise. The probe opens "." (a directory
* the helper has just chdir'd into) so it can't fail for any reason
* other than the kernel rejecting the requested confinement flag. */
static int kernel_resolve_beneath_supported(void)
{
int fd;
#ifdef __linux__
{
struct open_how how;
memset(&how, 0, sizeof how);
how.flags = O_RDONLY | O_DIRECTORY;
how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS;
fd = syscall(SYS_openat2, AT_FDCWD, ".", &how, sizeof how);
if (fd >= 0) {
close(fd);
return 1;
}
/* ENOSYS = kernel < 5.6. Fall through to the O_RESOLVE_BENEATH
* probe in case we're a Linux build running on a kernel that
* gained O_RESOLVE_BENEATH via some out-of-tree backport. */
}
#endif
#ifdef O_RESOLVE_BENEATH
fd = openat(AT_FDCWD, ".", O_RDONLY | O_DIRECTORY | O_RESOLVE_BENEATH);
if (fd >= 0) {
close(fd);
return 1;
}
#endif
return 0;
}

static void check(const char *label, int actual_rc, int expect_ok,
const char *path, mode_t expected_mode)
{
Expand Down Expand Up @@ -87,10 +128,35 @@ int main(int argc, char **argv)
* files to mode 0600 so we have a clean baseline to compare.
*/

/* Scenario A: legitimate parent dir-symlink, chmod must succeed. */
/* Scenario A: legitimate parent dir-symlink.
*
* On platforms whose kernel offers RESOLVE_BENEATH-equivalent
* confinement (Linux 5.6+ openat2, FreeBSD 13+ / macOS 15+
* O_RESOLVE_BENEATH), the within-tree symlink is followed and
* the chmod must succeed.
*
* On platforms that fall back to the per-component O_NOFOLLOW
* walk (OpenBSD, NetBSD, Solaris, older Cygwin, HPE NonStop,
* and pre-5.6 Linux), every symlink is rejected -- including
* this legitimate one. That's a real platform limitation (the
* same one that causes the #715 regression there) and the
* expected outcome is rejection.
*
* Detect at runtime and expect accordingly. The other three
* scenarios behave identically on both code paths and need no
* adjustment. */
int kernel_has_rb = kernel_resolve_beneath_supported();
fprintf(stderr, "INFO: kernel RESOLVE_BENEATH-equivalent confinement: %s\n",
kernel_has_rb ? "available" : "not available (per-component fallback)");

int rc = do_chmod_at("inside_link/sentinel", 0640);
check("A: legit dir-symlink within tree",
rc, 1, "realdir/sentinel", 0640);
if (kernel_has_rb) {
check("A: legit dir-symlink within tree (kernel confined)",
rc, 1, "realdir/sentinel", 0640);
} else {
check("A: legit dir-symlink within tree (per-component fallback rejects)",
rc, 0, "realdir/sentinel", 0600);
}

/* Scenario B: parent symlink escapes the tree -- chmod must be
* rejected and the outside file's mode must be unchanged. */
Expand Down
7 changes: 6 additions & 1 deletion t_stub.c
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ int preserve_perms = 0;
int preserve_executability = 0;
int omit_link_times = 0;
int open_noatime = 0;
size_t max_alloc = 0; /* max_alloc is needed when combined with util2.o */
size_t max_alloc = (size_t)-1; /* test helpers are not memory-constrained;
* 0 here makes every my_alloc()/my_strdup() in
* util2.c trip the "exceeded --max-alloc=0"
* check, which any helper exercising the
* per-component fallback of secure_relative_open()
* hits at its first my_strdup() call. */
char *partial_dir;
char *module_dir;
filter_rule_list daemon_filter_list;
Expand Down
6 changes: 4 additions & 2 deletions testsuite/chdir-symlink-race.test
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ ln -s "$outside" "$mod/subdir"
# 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"
make_data_file "$src/target.txt" "$SIZE" \
|| test_fail "failed to create source file"
make_data_file "$src/subdir/target.txt" "$SIZE" \
|| test_fail "failed to create source file"
chmod 0666 "$src/target.txt" "$src/subdir/target.txt"

cat > "$conf" <<EOF
Expand Down
36 changes: 18 additions & 18 deletions testsuite/chmod-symlink-race.test
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,30 @@
# receiver's check and its act, and the syscall escapes the module.
#
# This test exercises the new do_chmod_at() wrapper via the
# t_chmod_secure helper. The helper sets up two scenarios:
# t_chmod_secure helper. The helper sets up four scenarios:
# - a parent dir-symlink that resolves WITHIN the module tree
# (legitimate -K-style use, must continue to work)
# (legitimate -K-style use)
# - a parent dir-symlink that escapes the module tree (the
# attack, must be rejected)
# plus two regression scenarios (plain relative path, top-level
# file) that just confirm the safe wrapper doesn't break the
# normal case.
# attack, must be rejected on every platform)
# - plain relative path (regression check)
# - top-level file with no parent component (regression check)
#
# The kernel-enforced "stay below dirfd" path resolution is
# only available on Linux 5.6+, FreeBSD 13+, and macOS 15+.
# Skip on platforms that fall back to per-component O_NOFOLLOW
# (Solaris, OpenBSD, NetBSD, Cygwin); the per-component fallback
# would also reject the attack but the legitimate dir-symlink
# scenario would fail there.
# Kernel-enforced "stay below dirfd" path resolution is available
# on Linux 5.6+, FreeBSD 13+, and macOS 15+. On those platforms
# the legitimate within-tree symlink must be followed and the
# chmod must succeed. On platforms that fall back to the
# per-component O_NOFOLLOW walk (Solaris, OpenBSD, NetBSD,
# older Cygwin, HPE NonStop, pre-5.6 Linux), every symlink --
# including the legitimate one -- is rejected; that's a real
# platform limitation, not a security regression. The helper
# probes the running kernel at startup and adjusts the expected
# outcome for the within-tree-symlink scenario accordingly, so
# this test runs everywhere and gives the per-component fallback
# real CI coverage (the attack-rejection, plain-path, and
# top-level scenarios all behave identically on both code paths).

. "$suitedir/rsync.fns"

case "$(uname -s)" in
SunOS|OpenBSD|NetBSD|CYGWIN*)
test_skipped "do_chmod_at relies on RESOLVE_BENEATH-equivalent kernel support not available on $(uname -s)"
;;
esac

mod="$scratchdir/module"
trap_outside="$scratchdir/trap"
rm -rf "$mod" "$trap_outside"
Expand Down
44 changes: 44 additions & 0 deletions testsuite/rsync.fns
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,50 @@ makepath() {
}


###########################
# Create a file at $1 of $2 bytes containing non-trivial content
# suitable for rsync's delta algorithm to chew on. Prefers
# /dev/urandom for speed and entropy, falling back to a
# deterministic awk pseudo-random generator on platforms that
# lack /dev/urandom (e.g. HPE NonStop). The tests using this
# helper don't need cryptographic randomness -- they only need
# bytes that compress and delta-match like normal file content.

make_data_file() {
if [ $# -ne 2 ]; then
echo "usage: make_data_file PATH SIZE" >&2
return 2
fi
if [ -r /dev/urandom ] && \
dd if=/dev/urandom of="$1" bs="$2" count=1 2>/dev/null && \
[ -s "$1" ]; then
return 0
fi
# Fallback: a 32-bit linear congruential generator with BSD/glibc
# parameters. Seeded from PID and a POSIX cksum of the destination
# path so successive calls with different paths produce distinct
# content. Output is constrained to the printable-ASCII range
# (33..126, i.e. '!' through '~') for two portability reasons:
# - awk implementations vary on whether printf "%c", 0 emits a
# NUL byte or terminates the string;
# - gawk in UTF-8 locales encodes printf "%c", N for N > 127
# as a 2-byte UTF-8 sequence, which would make the output
# larger than the requested sz.
# The tests using this helper don't need 8-bit binary data, only
# non-trivial content for the rsync delta algorithm.
_path_seed=$(printf '%s' "$1" | cksum 2>/dev/null | awk '{print $1}')
awk -v sz="$2" -v seed_a="$$" -v seed_b="${_path_seed:-0}" 'BEGIN {
s = (seed_a + seed_b) % 2147483648
if (s < 0) s = -s
for (i = 0; i < sz; i++) {
s = (s * 1103515245 + 12345) % 2147483648
b = (int(s / 65536) % 94) + 33 # 33..126
printf "%c", b
}
}' > "$1"
}


###########################
# Run a test (in '$1') then compare directories $2 and $3 to see if
# there are any difference. If there are, explain them.
Expand Down
4 changes: 3 additions & 1 deletion testsuite/symlink-dirlink-basis.test
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ export RSYNC_RSH

# Helper: create a large file suitable for delta transfers.
# ~32KB is large enough for rsync's block matching to find matches.
# make_data_file lives in rsync.fns and falls back to an awk PRNG
# on platforms without /dev/urandom (e.g. HPE NonStop).
make_testfile() {
dd if=/dev/urandom of="$1" bs=1024 count=32 2>/dev/null \
make_data_file "$1" 32768 \
|| test_fail "failed to create test file $1"
}

Expand Down
Loading