From e1c5f0e93a75dd45f32f3b92ba221ef158ac2e5f Mon Sep 17 00:00:00 2001 From: Andrew Tridgell Date: Thu, 21 May 2026 07:13:36 +1000 Subject: [PATCH] t_chmod_secure: probe kernel RESOLVE_BENEATH at runtime; drop test skip The chmod-symlink-race test was previously a no-op on Solaris, OpenBSD, NetBSD, and Cygwin via a case 'uname -s' skip. The skip was too broad: of the four scenarios the helper exercises, only the 'legitimate within-tree dir-symlink' one actually needs RESOLVE_BENEATH-equivalent kernel support. The other three (attack rejection, plain relative path, top-level file) behave identically on the per-component O_NOFOLLOW fallback and would have caught the t_stub.c max_alloc=0 bug fixed in the previous commit if the test had been allowed to run. Make the helper probe the running kernel for either openat2(RESOLVE_BENEATH) on Linux 5.6+ or openat(O_RESOLVE_BENEATH) on FreeBSD 13+ / macOS 15+ by opening '.' under the requested confinement. Honour the result: - If RESOLVE_BENEATH-equivalent confinement is available, the within-tree symlink scenario must succeed (status quo). - If not, the per-component O_NOFOLLOW fallback rejects every symlink including legitimate ones; expect the within-tree symlink scenario to be rejected (rc != 0) and the file mode to remain unchanged. The attack-rejection, plain-path and top-level scenarios are unchanged: they expect the same outcome on both code paths. Drop the case-based skip from chmod-symlink-race.test so the test runs everywhere and the per-component fallback gets the CI coverage that the SunOS/OpenBSD/NetBSD/Cygwin runners can provide. HPE NonStop -- which lacks RESOLVE_BENEATH but isn't in the existing skip list -- is also covered by this change. --- .github/workflows/cygwin-build.yml | 2 +- t_chmod_secure.c | 72 ++++++++++++++++++++++++++++-- testsuite/chmod-symlink-race.test | 36 +++++++-------- 3 files changed, 88 insertions(+), 22 deletions(-) diff --git a/.github/workflows/cygwin-build.yml b/.github/workflows/cygwin-build.yml index 781e4695..fe5a5c42 100644 --- a/.github/workflows/cygwin-build.yml +++ b/.github/workflows/cygwin-build.yml @@ -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 diff --git a/t_chmod_secure.c b/t_chmod_secure.c index 114dfb2d..7c57dbbc 100644 --- a/t_chmod_secure.c +++ b/t_chmod_secure.c @@ -17,6 +17,11 @@ #include +#ifdef __linux__ +#include +#include +#endif + int dry_run = 0; int am_root = 0; int am_sender = 0; @@ -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) { @@ -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. */ diff --git a/testsuite/chmod-symlink-race.test b/testsuite/chmod-symlink-race.test index 48bbfbb4..6453af92 100755 --- a/testsuite/chmod-symlink-race.test +++ b/testsuite/chmod-symlink-race.test @@ -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"