Skip to content

secure_relative_open() portable fallback cannot create a new final component (ENOENT vs ENOTDIR). Daemon --inplace fails to create files on kernels without openat2. #1033

Description

@mabunemeh

Affected code

syscall.c, secure_relative_open() — the per-component O_NOFOLLOW walk fallback (the tier used when there is no kernel RESOLVE_BENEATH: NetBSD/OpenBSD/Solaris/Cygwin, Linux < 5.6 where openat2 returns ENOSYS at runtime, and --disable-openat2 builds).

Affected versions: current master (reproduced on 3.4.3-155-g3b84610c), v3.4.4, v3.4.3, v3.4.1-sec-patches1. (3.4.0–3.4.2 carry the same walker but no O_CREAT caller, so they don't hit it in practice.) Distro backports of the CVE-2026-29518 hardening onto older rsync are also affected (e.g. Red Hat rsync-3.1.3-26.el8_10/-27.el8_10).

What happens

The walk 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 (final component already exists as a non-directory):

for (const char *part = strtok(path_copy, "/"); part != NULL; part = strtok(NULL, "/")) {
	int next_fd = openat(dirfd, part, O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
	if (next_fd == -1 && errno == ENOTDIR) {
		if (strtok(NULL, "/") != NULL) { errno = ELOOP; goto cleanup; }
		retfd = openat(dirfd, part, flags | O_NOFOLLOW, mode);   /* file fallback */
		goto cleanup;
	}
	if (next_fd == -1) {
		goto cleanup;          /* <-- ENOENT (a to-be-created final file) lands HERE */
	}
	...
}

When the final component does not exist yet and the caller passed O_CREAT, the O_DIRECTORY probe fails with ENOENT (not ENOTDIR), so the file fallback is never reached and the function returns -1/ENOENT. On the portable fallback, secure_relative_open() can never create a new file — while the openat2(RESOLVE_BENEATH) / O_RESOLVE_BENEATH fast paths pass O_CREAT straight to the kernel and create it fine, so the tiers disagree.

Real world impact

Since the CVE-2026-29518 hardening (f1c24ab), the non-chroot daemon receiver routes its --inplace destination open through this helper (receiver.c):

if (use_secure_symlinks)        /* am_daemon && !am_chrooted */
	fd2 = secure_basis_open(NULL, fnametmp, O_WRONLY|O_CREAT, 0600);

So on any portable-fallback platform, a daemon module with use chroot = no receiving --inplace fails for every file that does not already exist.

Real-world casualty: MariaDB/Galera rsync SST on RHEL 8 (kernel 4.18, no openat2, distro backport of the same hardening). The joiner's datadir is empty, so every table file is a create; rsync exits 23, the SST script exits 255, Pacemaker leaves the galera resource FAILED, and the node can never join. Confirmed by strace on the affected host:

openat2(AT_FDCWD, "<table>.frm", {O_WRONLY|O_CREAT, RESOLVE_BENEATH}) = -1 ENOSYS
openat (AT_FDCWD, "<table>.frm", O_RDONLY|O_NOFOLLOW|O_DIRECTORY)      = -1 ENOENT

Reproduction (any kernel, current master)

./configure --disable-openat2     # forces the portable resolver tier;
make                              # Linux <5.6 takes the same path via runtime ENOSYS

cat > /tmp/r.conf <<EOF
port = 8730
address = 127.0.0.1
[sst]
  path = /tmp/dst/
  use chroot = no
  read only = no
EOF
mkdir -p /tmp/dst/db /tmp/src/db
echo hi > /tmp/src/db/new.frm            # a file that does NOT exist on dst yet
./rsync --daemon --no-detach --config /tmp/r.conf &

./rsync --inplace --recursive /tmp/src/db/ rsync://127.0.0.1:8730/sst/db

Result:

rsync: [receiver] open "/db/new.frm" (in sst) failed: No such file or directory (2)
rsync error: some files/attrs were not transferred (see previous errors) (code 23)

Dropping --inplace succeeds (temp-file + rename path); re-running --inplace once the file exists succeeds — the failure is specifically create through the walk.

Proposed 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 flags | O_NOFOLLOW — mirroring the existing ENOTDIR last-component fallback, and making the portable tier consistent with the kernel fast paths (which already honor O_CREAT). A missing intermediate component (more path follows) stays a genuine ENOENT.

 		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;
 		}

Security properties are unchanged: intermediate components are still opened O_DIRECTORY|O_NOFOLLOW (no symlink traversal, stays beneath basedir); the front-door ../absolute-path rejection is untouched; the created final file uses O_NOFOLLOW, so a symlink raced into that name yields ELOOP rather than an escape (open(O_CREAT|O_NOFOLLOW) on an existing symlink does not follow it). Callers without O_CREAT opening a missing file still get ENOENT.

Validation performed:

  • With the patch, the reproduction above succeeds (create + in-place update), and a pre-planted symlink at the destination name is still refused — the symlink target is untouched.
  • Full make check on the patched --disable-openat2 build: overall result 0.
  • Standalone reproducers (shipped-vs-fixed walker in C, and a syscall-faithful Python model) confirm: buggy walker → ENOENT on create; fixed → create OK, existing-file open OK, symlink final → ELOOP, ../ escape → EINVAL.

Reproducers and the patch are attached (zip).
repro_and_patch.zip

Disclaimer

AI was used in the creation and verification of this bug report

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions