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
Affected code
syscall.c,secure_relative_open()— the per-componentO_NOFOLLOWwalk fallback (the tier used when there is no kernelRESOLVE_BENEATH: NetBSD/OpenBSD/Solaris/Cygwin, Linux < 5.6 whereopenat2returnsENOSYSat runtime, and--disable-openat2builds).Affected versions: current
master(reproduced on3.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 noO_CREATcaller, 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 Hatrsync-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 withENOTDIR(final component already exists as a non-directory):When the final component does not exist yet and the caller passed
O_CREAT, theO_DIRECTORYprobe fails withENOENT(notENOTDIR), 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 theopenat2(RESOLVE_BENEATH)/O_RESOLVE_BENEATHfast paths passO_CREATstraight 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
--inplacedestination open through this helper (receiver.c):So on any portable-fallback platform, a daemon module with
use chroot = noreceiving--inplacefails 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 resourceFAILED, and the node can never join. Confirmed bystraceon the affected host:Reproduction (any kernel, current master)
Result:
Dropping
--inplacesucceeds (temp-file + rename path); re-running--inplaceonce the file exists succeeds — the failure is specifically create through the walk.Proposed fix
When the
O_DIRECTORYprobe fails withENOENTon the last component and the caller wants a file (notO_DIRECTORY), open it directly withflags | O_NOFOLLOW— mirroring the existingENOTDIRlast-component fallback, and making the portable tier consistent with the kernel fast paths (which already honorO_CREAT). A missing intermediate component (more path follows) stays a genuineENOENT.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 usesO_NOFOLLOW, so a symlink raced into that name yieldsELOOPrather than an escape (open(O_CREAT|O_NOFOLLOW)on an existing symlink does not follow it). Callers withoutO_CREATopening a missing file still getENOENT.Validation performed:
make checkon the patched--disable-openat2build: overall result 0.ENOENTon 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