syscall: let secure_relative_open() fallback create a missing final component#1034
syscall: let secure_relative_open() fallback create a missing final component#1034mabunemeh wants to merge 2 commits into
Conversation
…omponent 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.
steadytao
left a comment
There was a problem hiding this comment.
The source change is narrowly plausible but this resolver is security-sensitive so it needs an automated regression test before merge.
Please cover a missing final component with O_CREAT, a missing intermediate component, an existing regular file, a final symlink and an O_DIRECTORY request.
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 <noreply@anthropic.com>
73e8e6a to
4ac3aea
Compare
mabunemeh
left a comment
There was a problem hiding this comment.
Added in 41c3aea: a semantics mode for the existing t_secure_relpath helper plus a testsuite/secure-open-semantics_test.py driver covering the five requested cases — missing final component with O_CREAT (single- and multi-component, created file asserted present), missing intermediate component (ENOENT, nothing created), existing regular file (O_RDONLY and O_WRONLY|O_CREAT reopen), a final-component symlink, and O_DIRECTORY requests (existing dir opens and is a dir; missing name stays ENOENT).
Validation: the test passes on both an openat2 build and a --disable-openat2 build, and make check on the --disable-openat2 build is fully green. With the fix reverted, exactly the two create checks fail with ENOENT and the rest still pass, so the test isolates this regression. The fallback tier also runs natively on the NetBSD/OpenBSD/Solaris/Cygwin CI jobs.
Fixes #1033
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
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
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:
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.