Skip to content

Fix ENOENT when resolving kernel pseudo-paths in ona_open - #1054

Open
seks99x wants to merge 5 commits into
RsyncProject:masterfrom
seks99x:seks99x-fd-link-fix
Open

Fix ENOENT when resolving kernel pseudo-paths in ona_open#1054
seks99x wants to merge 5 commits into
RsyncProject:masterfrom
seks99x:seks99x-fd-link-fix

Conversation

@seks99x

@seks99x seks99x commented Aug 14, 2026

Copy link
Copy Markdown
Member

Fixes #1053

What was added:

This PR patches ona_open() to properly handle Linux kernel pseudo-paths (pipes, sockets, etc.) generated by Bash process substitution without triggering an ENOENT error.
String-based detection: After readlinkat() successfully resolves a trusted symlink (like /dev/fd/63), the code now checks if the target is a kernel pseudo-path (pipe:[, socket:[, or anon_inode:[).
Enforcing Leaf Nodes: Added an if (!is_last) check to immediately return ENOTDIR if an operator attempts to traverse a pipe or socket like a directory.
Dropping O_NOFOLLOW: If the pseudo-path passes the above security checks, the function calls openat(dfd, comp, flags & ~O_NOFOLLOW, mode). By dropping the O_NOFOLLOW flag on the final component, we allow the kernel to hook the process into the memory object without treating it as a physical file on disk.

@seks99x seks99x added the run-ci label Aug 14, 2026
Comment thread syscall.c Outdated
@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch 2 times, most recently from 12f8181 to f220a9e Compare August 14, 2026 09:10
@seks99x

seks99x commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Regarding the CI: I added pseudo-paths to macos.txt and cygwin.txt since they legitimately lack Linux's native /dev/fd/ process substitution. The Ubuntu runners run and pass the test perfectly.

However, the AlmaLinux runner is still failing the skip oracle (likely because the minimal almalinux:8 container is missing the bash package). I think it should be added through .github/workflows/almalinux-8-build.yml but I can't push any changes to it.

@steadytao steadytao removed the run-ci label Aug 15, 2026
@samueloph

Copy link
Copy Markdown
Member

@seks99x I've analyzed this through and agent and it's flagging a security regression, I don't want to post all the AI blob because I'm not familiar enough with the rsync inner-workings (and not enough time), but these POCs look sound.

I wanted to post this before I get the time to investigate it deeper because I want to avoid a possible security regression.

1. The fd number comes from the symlink's filename, and the only validation is st_ino equality against rsync's own fd table - nothing confirms the path is actually /proc/self/fd. The claim is that a symlink named 3 whose target text is pipe:[N], anywhere on the filesystem, resolves to rsync's own fd 3:

#!/bin/bash
# An ordinary symlink - nothing to do with /proc or /dev/fd - resolving to a
# descriptor rsync already holds open.   Usage: bash repro-a.sh /path/to/rsync
set -u
RSYNC="${1:?usage: repro-a.sh /path/to/rsync}"

lab=$(mktemp -d); trap 'rm -rf "$lab"' EXIT
mkdir -p "$lab/src" "$lab/plant" "$lab/dest"
echo hello > "$lab/src/keep.txt"
echo hello > "$lab/src/drop.txt"

# Give rsync an inherited fd 3 holding the text "drop.txt".  This stands in for
# any descriptor the rsync process happens to have open.
exec 3< <(printf 'drop.txt\n')

# The inode the kernel reports for that pipe - the number that appears inside
# the "pipe:[...]" string in /proc/self/fd, and the only thing compared.
ino=$(stat -L -c %i /proc/self/fd/3)

# Plant an ordinary symlink in an ordinary directory.  Two things matter:
#   * its NAME is "3"   -> atoi(comp), then fstat(3) on *rsync's own* fd table
#   * its TARGET is text, not a path -> matches the "pipe:[" prefix test
# The link is dangling.  It is owned by us, i.e. by the euid rsync runs as,
# which is precisely the case ona_open() follows by design.
ln -sfn "pipe:[$ino]" "$lab/plant/3"

echo "planted: $lab/plant/3 -> $(readlink "$lab/plant/3")"
"$RSYNC" -a --exclude-from="$lab/plant/3" "$lab/src/" "$lab/dest/"
echo "exit status: $?"
echo "transferred: $(ls "$lab/dest" | tr '\n' ' ')"
3.5.0:   failed to open exclude file .../plant/3: No such file or directory (2)
         exit status: 11, transferred: (nothing)        <-- dangling link, correct

patched: exit status: 0, transferred: keep.txt          <-- filter rules were read
                                                            from rsync's own fd 3

2. dup()/F_DUPFD_CLOEXEC returns the original description, so flags/mode are dropped - the caller gets the original access mode and a shared offset. Claude pointed at batch.c (O_WRONLY|O_CREAT|O_TRUNC), connection.c (O_RDWR|O_CREAT, daemon lock) and log.c (O_APPEND) as callers passing write flags. Its reproducer for the write side:

#!/bin/bash
# The same mechanism reaching a WRITE: --log-file lands in a file the supplied
# path never names.        Usage: bash repro-b.sh /path/to/rsync
set -u
RSYNC="${1:?usage: repro-b.sh /path/to/rsync}"

lab=$(mktemp -d); trap 'rm -rf "$lab"' EXIT
mkdir -p "$lab/src" "$lab/plant" "$lab/dest"
echo hello > "$lab/src/keep.txt"
printf 'ORIGINAL-LINE-1\nORIGINAL-LINE-2\n' > "$lab/victim"

# fd 3 = a writable descriptor on the victim.  Note this is a REGULAR FILE, not
# a pipe: S_ISFIFO/S_ISSOCK is never checked and st_dev is never compared, so
# only the inode number has to line up.
exec 3<> "$lab/victim"
ino=$(stat -L -c %i /proc/self/fd/3)
ln -sfn "pipe:[$ino]" "$lab/plant/3"

# --log-file asks open_no_attacker_symlinks() for O_WRONLY|O_APPEND|O_CREAT.
# dup() ignores flags and mode and returns the ORIGINAL description.
"$RSYNC" -a --log-file="$lab/plant/3" "$lab/src/" "$lab/dest/"
echo "--- $lab/victim after the run ---"; cat "$lab/victim"
3.5.0:   failed to open log-file ... No such file or directory
         victim: ORIGINAL-LINE-1 / ORIGINAL-LINE-2, untouched

patched: victim: ORIGINAL-LINE-1
                 ORIGINAL-LINE-2
                 2026/08/14 22:30:58 [425595] building file list
                 2026/08/14 22:30:58 [425595] >f+++++++++ keep.txt

It also thought the branch returns without the abspath_outside_confinement() / out_abs handling the rest of the walk does, and that the anon_inode:[ arm can't match (real targets are anon_inode:[eventpoll] - a name, not an inode number, so strtoull() yields 0).

@seks99x

seks99x commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

@samueloph thank you so much for the review.
Actually both has nothing to do with the symlink issue, rsync follow trusted UIDs symlink by design (symlinks owned by the same process UID) I tried to change the ownership of the symlink it failed with ELOOP. The only possible ( if im not missing something) issue could happen here is attacker would control what fd to return, which on my mind i don’t find something critical regarding that. attackers most of the time have only control over actual file paths rather than fd.
If you have other thoughts also i would appreciate it.

@seks99x

seks99x commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

The anon_inode issue is real yeah thank you for pointing this.
This would require different handling other than the rest, i assumed they all would produce a long number.
Will push fixes for this tomorrow.

@seks99x

seks99x commented Aug 15, 2026

Copy link
Copy Markdown
Member Author

Im thinking about handling the parent path that it is really coming from /dev/fd for example but im afraid to break a legit case which could come from another path that im not thinking of. From what im seeing UNIX like environments literally have many cases we can’t think of it easily.

@steadytao @samueloph do you think it would be better to handle parent paths to make sure its getting from /proc/self or /dev/fd?

From my own perspective for this to have a proper attack , the privileged process need to be opening a sensitive/privileged file and before calling close() the attacker can plant a symlink naming it as the fd number which would require also a guess. I feel its sophisticated/fanciful and wont work in real world but i want your opinions too.

@steadytao

Copy link
Copy Markdown
Member

I will come back to this one 🤔

@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch from d76c80d to 5d86391 Compare August 17, 2026 10:47
@seks99x

seks99x commented Aug 17, 2026

Copy link
Copy Markdown
Member Author

@steadytao I've rebased this PR on the latest master. I also updated the logic to use the new fd_pin_tail(), ensuring we only make exceptions for pseudo-paths that strictly fall under /proc/self/fd or /dev/fd like what we did with the symlinks exceptions. I think this keeps our exceptions handling clean and consistent.

@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch from 5d86391 to f036afc Compare August 17, 2026 18:07
When traversing /dev/fd/ or /proc/self/fd/, the Linux kernel returns pseudo-paths like 'pipe:[123]' or 'anon_inode:inotify'.

Since the parent directory is securely verified using fd_pin_tail(), this patch implicitly trusts the kernel's symlink target without requiring a strict inode match. The numeric FD is extracted from the path and safely duplicated using fcntl(F_DUPFD_CLOEXEC).

Additionally, the abspath array is zero-initialized to silence Clang static analyzer warnings.

This ensures bash process substitution <(cmd) and custom IPC sockets work correctly.
@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch from f036afc to 1bb3c5b Compare August 17, 2026 19:09
Comment thread syscall.c Outdated
Comment thread syscall.c Outdated
@steadytao

Copy link
Copy Markdown
Member

Looking pretty good thus far!

@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch 6 times, most recently from 4bb6dc4 to 237175c Compare August 18, 2026 19:01
@seks99x

seks99x commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

@steadytao Thanks for your review! My bad I didn't thought of the flags or noticed the /proc/pid case. I modified fd_pin_tail() to handle the strict process verification to avoid a lot of redundancy here. I also manually enforced the access modes and added two new python tests to make sure we are going right. Could you recheck please?

@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch from 237175c to 903f719 Compare August 18, 2026 21:18
…wn PID

This commit addresses two critical edge cases in pseudo-path handling
within ona_open() to ensure strict security boundaries:

1. Cross-PID Duplication Prevention:
   To prevent the unintended duplication of internal file descriptors
   via cross-PID paths (e.g., /proc/<victim_pid>/fd/<internal_fd>),
   fd_pin_tail() has been updated with a strict mode. This ensures that
   the dup() shortcut is exclusively limited to the current process via
   /proc/self/fd or /dev/fd.

2. API Contract Enforcement on dup():
   Because dup() inherits the file descriptor's original access mode and
   status flags, it can bypass caller requirements. ona_open()
   now manually queries the inherited FD state via fcntl(..., F_GETFL).
   It explicitly rejects incompatible requests by enforcing O_ACCMODE
   (returning EACCES), O_DIRECTORY (returning ENOTDIR), and O_APPEND
   (returning EINVAL).

Includes two new Python regression tests to explicitly verify cross-PID
rejection and open() flag API contracts on pseudo-paths.
@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch from 903f719 to 916db38 Compare August 18, 2026 21:23
Comment thread syscall.c Outdated
Comment on lines +498 to +501
if ((flags & O_APPEND) && !(real_flags & O_APPEND)) {
saved_errno = EINVAL;
goto out;
}

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.

This still does not preserve the callers open() contract. A duplicated descriptor retains unrequested flags such as O_APPEND or O_NONBLOCK cannot acquire requested flags absent from the original and shares the original open-file-description offset.

Comment thread syscall.c
Comment on lines +448 to +451
if (is_fd_dir && strchr(target, '/') == NULL &&
(strncmp(target, "pipe:[", 6) == 0 ||
strncmp(target, "socket:[", 8) == 0 ||
strncmp(target, "anon_inode:", 11) == 0)) {

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.

Why are sockets and anonymous inodes included?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Looks like that vacation actually affected my focus in a bad way, but I'm back now!

I took a step back and used strace to map out exactly how rsync is handling this flags.

I initially tried to strictly validate O_APPEND, but realized that doing so breaks legitimate use cases like --log-file=>(...). Bash process substitution creates pipes without O_APPEND (just O_WRONLY), while rsync explicitly requests it. If we enforce a strict match, it throws a silent EINVAL:

fcntl(63, F_GETFL)                      = 0x1 (flags O_WRONLY)
rsync: [client] failed to open log-file /dev/fd/63: Invalid argument (22)
Ignoring "log file" setting.

By safely ignoring O_APPEND during the inheritance check, it clones the descriptor and then automatically corrects the flags:

fcntl(63, F_DUPFD_CLOEXEC, 0)           = 3
fcntl(3, F_GETFL)                       = 0x1 (flags O_WRONLY)
fcntl(3, F_SETFL, O_WRONLY|O_APPEND)    = 0

To handle the actual dangerous flags you mentioned (like O_NONBLOCK ), I implemented a strict SHARED_STATUS_FLAGS macro. If the pipe possesses an unrequested dangerous state, it is safely rejected.

Regarding socket:[ and anon_inode:[ , they are whitelisted because they must be handled the exact same way via dup().

  • Bash network redirection (e.g., 3< /dev/tcp/...) resolves to socket:[...].
  • Diskless memory objects (like eventfd or epoll) resolve to anon_inode:[...]. (You can actually see this by running: python3 -c 'import select, os; ep = select.epoll(); print(os.readlink(f"/proc/self/fd/{ep.fileno()}"))').

I’ve updated the test suite to include a edge case ensuring that an O_RDWR socket correctly satisfies an O_RDONLY request without being falsely blocked by strict O_ACCMODE equality.

Let me know how this looks!

@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch 4 times, most recently from 590ac6f to 7222f2d Compare August 19, 2026 13:43
…reams

Validate `O_ACCMODE` and status flags before calling `dup()` on inherited file descriptors (such as `pipe:`, `socket:`, and `anon_inode:`). Inheriting unrequested kernel states can alter process execution, such as fatal `SIGIO` interrupts from `O_ASYNC` or unexpected `EAGAIN` failures from `O_NONBLOCK`.

This patch introduces the `SHARED_STATUS_FLAGS` macro to strictly validate inherited file descriptions and reject dangerous, unrequested states with `EINVAL`.

`O_APPEND` is explicitly excluded from this strict validation to preserve support for bash process substitution (e.g., `--log-file=>(...)`), which supplies `O_WRONLY` pipes. Excluding it allows internal subsystems to safely inherit the descriptor and apply `O_APPEND` dynamically via `fcntl()`.

Additionally, the `O_ACCMODE` validation ensures bidirectional network streams (`O_RDWR` sockets) successfully satisfy read-only or write-only requests without being falsely rejected by exact-match checks.
@seks99x
seks99x force-pushed the seks99x-fd-link-fix branch from 7222f2d to ce157e4 Compare August 19, 2026 13:46
Comment thread syscall.c
}
/* Safely duplicate the descriptor, immune to TOCTOU symlink races */
#ifdef F_DUPFD_CLOEXEC
retfd = fcntl(fd_num, F_DUPFD_CLOEXEC, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This avoids silently inheriting O_NONBLOCK, but returning EINVAL still doesn’t match open(path, flags, mode) here. On Linux, reopening /proc/self/fd/N with O_RDONLY creates a blocking file description. In a delayed-writer exclude-file test, current head exits 11, while a local openat(dfd, comp, flags, mode) version waits for and applies the rule.

The updated pseudo-flags test also leaves the write end open and expects rejection, so it would time out with blocking behavior. Could we reopen the already verified numeric entry instead of duplicating it?

@seks99x seks99x Aug 19, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

tracking down all these edge cases is getting a bit tiring.

When I originally opened this PR, I actually started by reopening the descriptor with ⁠openat()⁠ and O_NOFOLLOW However, because we weren't strictly validating the parent base path at the time, using a relative ⁠openat(dfd, comp, ...)⁠ introduced a severe TOCTOU directory traversal vulnerability.
Even since we now check that the path contains a digit in the last component and the absolute path starting with /proc/self/fd or /dev/fd , this still could pose a risk also. If an attacker passed a path like ⁠/proc/self/fd/3/test/4⁠ (where FD 3 points to an attacker-controlled directory like ⁠/tmp/⁠), they could race the ⁠openat⁠ call and swap the ⁠4⁠ symlink to an arbitrary file right after our ⁠readlink⁠ validation passed.

We could hardcode the path:

Current Prefix Validation: The code now explicitly enforces that the ⁠abspath⁠ genuinely started with ⁠/proc/self/fd/⁠ or ⁠/dev/fd/⁠.

Absolute Path hardcoding: Instead of using a relative ⁠openat()⁠ with the abspath or target value we extract the validated integer comp and dynamically construct a hardcoded absolute path (⁠/proc/self/fd/%d⁠) %d since we already validated it correctly ( last component, is digit and in our process ). Then calling open() on this hardcoded path should be safe i guess.

Hopefully, this finally puts these boundary issues to rest!

@steadytao what do you think? I feel handling this perfectly using dup() is getting complex/dangerous.

I’ll push updates tomorrow.

Comment thread syscall.c
retfd = dup(fd_num);
#endif
saved_errno = (retfd >= 0) ? 0 : errno;
goto out;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This return still happens before the --confine-root check below. I reproduced it with a per-directory merge that names an inherited /proc/self/fd/N pipe: current head reads the pipe and applies its rule even though the anonymous object has no target beneath the confined root.

Could we apply the confinement decision before returning and add an in-band merge regression? A command-line --exclude-from test is opened too early to exercise this boundary.

@seks99x seks99x closed this Aug 19, 2026
@seks99x seks99x reopened this Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[3.5.0 regression] Rsync fails to open filter list supplied from shell process substitution

4 participants