From a5cb5aa669554d2f3cd28cb27fbbdd256746f32e Mon Sep 17 00:00:00 2001 From: Mohammad AbuNemeh Date: Tue, 21 Jul 2026 18:14:29 +0400 Subject: [PATCH 1/2] syscall: let secure_relative_open() fallback create a missing final component The per-component O_NOFOLLOW walk fallback in secure_relative_open() -- the tier used when no kernel RESOLVE_BENEATH is available (NetBSD, OpenBSD, Solaris, Cygwin, Linux < 5.6 where openat2 returns ENOSYS, and --disable-openat2 builds) -- probes each component with openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); and only falls back to opening the component as a file when the probe fails with ENOTDIR, i.e. only when the final component already exists as a non-directory. A final component that does not exist yet fails the probe with ENOENT, which is not special-cased, so the walk returns -1/ENOENT: on this tier secure_relative_open() can never create a new file, no matter what flags the caller passed. Impact: since the CVE-2026-29518 hardening the non-chroot daemon receiver routes its --inplace destination open through this helper with O_WRONLY|O_CREAT (receiver.c, secure_basis_open), so every --inplace transfer of a new file into a "use chroot = no" module fails with rsync: [receiver] open "..." failed: No such file or directory (2) and exit code 23 on the fallback tier. The common real-world casualty is MariaDB/Galera rsync SST on RHEL 8 (kernel 4.18, no openat2, distro backport of the same hardening): the joiner datadir is empty, every table file is a create, and the node can never join. Reproducible on any kernel with a --disable-openat2 build: rsync --daemon (use chroot = no) + rsync --inplace -r src/ dst -> fails for every file that does not already exist. Fix: when the O_DIRECTORY probe fails with ENOENT on the LAST component and the caller wants a file (not O_DIRECTORY), open it directly with the caller flags | O_NOFOLLOW, mirroring the existing ENOTDIR last-component fallback. O_CREAT now works; a symlink raced into the name is still refused with ELOOP (O_NOFOLLOW); a missing INTERMEDIATE component (more path follows) still fails with ENOENT; the all-components-were-directories and O_DIRECTORY handling is unchanged, as are the openat2/O_RESOLVE_BENEATH fast paths. --- syscall.c | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/syscall.c b/syscall.c index c7f12d2c2..a89849e33 100644 --- a/syscall.c +++ b/syscall.c @@ -1995,6 +1995,15 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo goto cleanup; } if (next_fd == -1) { + /* Final component that does not exist yet: if the caller + * wants a file (not O_DIRECTORY), open/create it here with + * O_NOFOLLOW so O_CREAT works and a pre-planted symlink at + * the name is still refused. A missing *intermediate* + * component (more path follows) stays a genuine ENOENT. */ + if (errno == ENOENT && !(flags & O_DIRECTORY) + && strtok(NULL, "/") == NULL) { + retfd = openat(dirfd, part, flags | O_NOFOLLOW, mode); + } goto cleanup; } if (dirfd != AT_FDCWD) close(dirfd); From 4ac3aea2ce076172568d7091f727967f3210c8f5 Mon Sep 17 00:00:00 2001 From: Mohammad AbuNemeh Date: Sun, 26 Jul 2026 10:14:33 +0400 Subject: [PATCH 2/2] t_secure_relpath: add secure_relative_open() resolution-semantics checks Add a "semantics" mode to the t_secure_relpath helper (and a testsuite/secure-open-semantics_test.py driver) covering the resolution behavior that both resolver tiers -- the kernel RESOLVE_BENEATH fast paths and the per-component O_NOFOLLOW walk fallback -- must agree on: - a missing FINAL component with O_CREAT is created (regression test for the fallback returning ENOENT instead of creating the file, which broke every new-file create through the non-chroot daemon receiver with --inplace); checked for single- and multi-component relpaths, and the created file must exist afterwards; - a missing INTERMEDIATE component still fails with ENOENT and creates nothing; - an existing regular file opens with O_RDONLY and reopens with O_WRONLY|O_CREAT (no O_EXCL), as the receiver does; - an out-of-tree ABSOLUTE symlink in the final component is refused and O_CREAT does not create the escape target. (The exact errno is tier-dependent -- ELOOP from the walk, EXDEV et al from RESOLVE_BENEATH -- so only the refusal and the non-creation are asserted. A within-tree symlink is deliberately not tested: the kernel tiers follow it, the fallback refuses it.); - O_DIRECTORY opens an existing directory (and it really is a directory) and stays ENOENT for a missing name -- creation must not kick in for directory requests. The helper only passes a nonzero mode alongside O_CREAT, as real callers do: openat2() rejects a nonzero mode without O_CREAT with EINVAL, so a sloppy constant mode would false-fail the fast path. With the fix reverted, the two create checks fail with ENOENT on a --disable-openat2 build and everything else still passes; with the fix in place the test passes on both an openat2 build and a --disable-openat2 build, and the fallback tier runs it natively on the NetBSD/OpenBSD/Solaris/Cygwin CI builds. Co-Authored-By: Claude Fable 5 --- t_secure_relpath.c | 164 +++++++++++++++++++++++- testsuite/secure-open-semantics_test.py | 36 ++++++ 2 files changed, 198 insertions(+), 2 deletions(-) create mode 100755 testsuite/secure-open-semantics_test.py diff --git a/t_secure_relpath.c b/t_secure_relpath.c index a0fdf0d25..16b87e3db 100644 --- a/t_secure_relpath.c +++ b/t_secure_relpath.c @@ -21,6 +21,18 @@ * post-fix, the front-door check catches every variant up front * with a consistent EINVAL across platforms. * + * Run with a second argument of "semantics" it instead checks the + * resolution semantics that both resolver tiers (the kernel + * RESOLVE_BENEATH fast paths and the per-component O_NOFOLLOW walk + * fallback) must agree on: creating a missing final component with + * O_CREAT, ENOENT for a missing intermediate component, opening an + * existing file, refusing an out-of-tree symlink in the final + * component, and O_DIRECTORY requests. The create case is a + * regression test for the fallback returning ENOENT instead of + * creating the file (which broke the non-chroot daemon receiver + * with --inplace); the fallback tier is exercised natively on + * NetBSD/OpenBSD/Solaris/Cygwin and by --disable-openat2 builds. + * * Not linked into rsync itself. */ @@ -99,10 +111,155 @@ static void check_basedir(const char *basedir) fprintf(stderr, "OK [basedir=%-12s]: rejected with EINVAL\n", basedir); } +static void check_open_ok(const char *relpath, int flags, mode_t mode, int want_dir, const char *what) +{ + struct stat sb; + int fd = secure_relative_open(NULL, relpath, flags, mode); + + if (fd < 0) { + fprintf(stderr, "FAIL [%-20s]: %s: expected success, got errno=%d (%s)\n", + relpath, what, errno, strerror(errno)); + errs++; + return; + } + if (fstat(fd, &sb) == 0 && want_dir != S_ISDIR(sb.st_mode)) { + fprintf(stderr, "FAIL [%-20s]: %s: opened a %s, expected a %s\n", + relpath, what, S_ISDIR(sb.st_mode) ? "directory" : "file", + want_dir ? "directory" : "file"); + close(fd); + errs++; + return; + } + close(fd); + fprintf(stderr, "OK [%-20s]: %s\n", relpath, what); +} + +static void check_open_fail(const char *relpath, int flags, int want_errno, const char *what) +{ + /* openat2() rejects a nonzero mode without O_CREAT (EINVAL), so + * only pass one when creating -- as real callers do. */ + mode_t mode = flags & O_CREAT ? 0600 : 0; + int fd = secure_relative_open(NULL, relpath, flags, mode); + int saved_errno = errno; + + if (fd >= 0) { + fprintf(stderr, "FAIL [%-20s]: %s: expected failure but got fd %d\n", + relpath, what, fd); + close(fd); + errs++; + return; + } + /* want_errno 0 = any errno is acceptable (the exact code differs + * between the resolver tiers); it just must fail. */ + if (want_errno && saved_errno != want_errno) { + fprintf(stderr, "FAIL [%-20s]: %s: errno=%d (%s), expected %d (%s)\n", + relpath, what, saved_errno, strerror(saved_errno), + want_errno, strerror(want_errno)); + errs++; + return; + } + fprintf(stderr, "OK [%-20s]: %s (errno=%s)\n", relpath, what, strerror(saved_errno)); +} + +static void check_presence(const char *path, int want_present, const char *what) +{ + struct stat sb; + int present = lstat(path, &sb) == 0; + + if (present != want_present) { + fprintf(stderr, "FAIL [%-20s]: %s: %s\n", + path, what, present ? "unexpectedly exists" : "does not exist"); + errs++; + return; + } + fprintf(stderr, "OK [%-20s]: %s\n", path, what); +} + +static int run_semantics_checks(void) +{ + char cwd[MAXPATHLEN], esc_target[MAXPATHLEN]; + int fd, have_symlink; + + if (mkdir("subdir", 0755) < 0 && errno != EEXIST) { + perror("mkdir subdir"); + return 2; + } + fd = open("subdir/existing.txt", O_WRONLY | O_CREAT, 0644); + if (fd < 0) { + perror("create subdir/existing.txt"); + return 2; + } + close(fd); + + /* An ABSOLUTE symlink target outside the anchor directory: refused + * by both tiers (the fast paths reject the escape in-kernel, the + * fallback refuses any symlink via O_NOFOLLOW). A within-tree + * symlink would be tier-dependent (the kernel paths follow it, the + * fallback does not), so it is deliberately not tested here. */ + if (!getcwd(cwd, sizeof cwd)) { + perror("getcwd"); + return 2; + } + if ((size_t)snprintf(esc_target, sizeof esc_target, + "%s/../t_secure_relpath_escape.tmp", cwd) >= sizeof esc_target) { + fprintf(stderr, "escape target path too long\n"); + return 2; + } + unlink(esc_target); + have_symlink = symlink(esc_target, "subdir/esclink") == 0; + + /* A missing final component with O_CREAT must be created (this is + * the non-chroot daemon receiver shape for --inplace: relpath with + * no basedir). Regression: the walk fallback returned ENOENT. */ + check_open_ok("newfile.txt", O_WRONLY | O_CREAT, 0600, 0, + "create missing final component"); + check_presence("newfile.txt", 1, "created file is present"); + check_open_ok("subdir/newfile2.txt", O_WRONLY | O_CREAT, 0600, 0, + "create missing final component (nested)"); + check_presence("subdir/newfile2.txt", 1, "created nested file is present"); + + /* A missing INTERMEDIATE component is a genuine ENOENT and must + * not create anything. */ + check_open_fail("missingdir/new.txt", O_WRONLY | O_CREAT, ENOENT, + "missing intermediate component"); + check_presence("missingdir", 0, "missing intermediate not created"); + + /* Existing regular file: readable, and O_CREAT without O_EXCL + * opens it (the receiver reopens existing files this way). */ + check_open_ok("subdir/existing.txt", O_RDONLY, 0, 0, + "open existing file O_RDONLY"); + check_open_ok("subdir/existing.txt", O_WRONLY | O_CREAT, 0600, 0, + "reopen existing file O_WRONLY|O_CREAT"); + + /* A symlink in the final component pointing outside the tree must + * be refused, and O_CREAT must not create the escape target. The + * errno differs by tier (ELOOP from the walk, EXDEV & co from + * RESOLVE_BENEATH), so only the refusal itself is checked. */ + if (have_symlink) { + check_open_fail("subdir/esclink", O_WRONLY | O_CREAT, 0, + "refuse out-of-tree symlink final component"); + check_presence(esc_target, 0, "escape target not created"); + } else + fprintf(stderr, "SKIP [subdir/esclink ]: symlink() unsupported here\n"); + + /* O_DIRECTORY: an existing directory opens (and is a directory); + * a missing name stays ENOENT -- O_CREAT-style creation must not + * kick in for directory requests. */ + check_open_ok("subdir", O_RDONLY | O_DIRECTORY, 0, 1, + "open existing dir O_DIRECTORY"); + check_open_fail("missingdir2", O_RDONLY | O_DIRECTORY, ENOENT, + "missing dir with O_DIRECTORY"); + check_presence("missingdir2", 0, "missing dir not created"); + + if (errs) + fprintf(stderr, "\n%d failure(s)\n", errs); + return errs ? 1 : 0; +} + int main(int argc, char **argv) { - if (argc != 2) { - fprintf(stderr, "usage: %s \n", argv[0]); + if (argc != 2 && !(argc == 3 && strcmp(argv[2], "semantics") == 0)) { + fprintf(stderr, "usage: %s [semantics]\n", argv[0]); return 2; } if (chdir(argv[1]) < 0) { @@ -118,6 +275,9 @@ int main(int argc, char **argv) am_daemon = 1; am_chrooted = 0; + if (argc == 3) + return run_semantics_checks(); + mkdir("subdir", 0755); /* Each of these relpaths must be rejected with EINVAL at the diff --git a/testsuite/secure-open-semantics_test.py b/testsuite/secure-open-semantics_test.py new file mode 100755 index 000000000..b712d6805 --- /dev/null +++ b/testsuite/secure-open-semantics_test.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +# Regression test for secure_relative_open()'s resolution semantics, +# which must be identical across the resolver tiers (the kernel +# RESOLVE_BENEATH fast paths and the per-component O_NOFOLLOW walk +# fallback used on NetBSD, OpenBSD, Solaris, Cygwin, pre-5.6 Linux +# and --disable-openat2 builds): +# +# - a missing FINAL component with O_CREAT is created (regression: +# the walk fallback returned ENOENT instead, which broke every +# new-file create through the non-chroot daemon receiver's +# --inplace path, e.g. MariaDB/Galera rsync SST); +# - a missing INTERMEDIATE component still fails with ENOENT; +# - an existing regular file opens (O_RDONLY, and O_WRONLY|O_CREAT +# without O_EXCL); +# - an out-of-tree symlink in the final component is refused and +# O_CREAT does not create the escape target; +# - O_DIRECTORY opens an existing directory and stays ENOENT for a +# missing name (no creation for directory requests). +# +# The checks live in the t_secure_relpath helper ('semantics' mode). + +import subprocess + +from rsyncfns import SCRATCHDIR, TOOLDIR, rmtree, test_fail + + +testdir = SCRATCHDIR / 'secure-open-test' +rmtree(testdir) +testdir.mkdir(parents=True) + +proc = subprocess.run([str(TOOLDIR / 't_secure_relpath'), str(testdir), 'semantics']) +if proc.returncode != 0: + test_fail( + 'secure_relative_open() resolution semantics check failed ' + '(see stderr above for the specific case)' + )