Skip to content

syscall: let secure_relative_open() fallback create a missing final component#1034

Open
mabunemeh wants to merge 2 commits into
RsyncProject:masterfrom
mabunemeh:fix-secure-open-enoent-create
Open

syscall: let secure_relative_open() fallback create a missing final component#1034
mabunemeh wants to merge 2 commits into
RsyncProject:masterfrom
mabunemeh:fix-secure-open-enoent-create

Conversation

@mabunemeh

Copy link
Copy Markdown

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

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.

…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 steadytao left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@mabunemeh
mabunemeh force-pushed the fix-secure-open-enoent-create branch from 73e8e6a to 4ac3aea Compare July 26, 2026 06:21

@mabunemeh mabunemeh left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants