From ce83ccd2ac1906c6782222240e6bc041984782c3 Mon Sep 17 00:00:00 2001 From: Omar Elsayed Date: Thu, 20 Aug 2026 18:41:22 +0300 Subject: [PATCH 1/2] Fix ona_open to safely resolve bash process substitution pseudo-paths Bash process substitution (e.g., `<(...)` or `>(...)`) exposes file descriptors as symlinks under `/proc/self/fd/X` pointing to kernel pseudo-paths such as `pipe:[12345]`. Previously, `ona_open()` would read this target and attempt to resolve it as a literal file path on disk, causing the operation to fail with `ENOENT` and breaking legitimate local process substitution. This patch safely intercepts and resolves these pseudo-paths while maintaining strict confinement boundaries and averting TOCTOU risks: - Detects kernel pseudo-paths (`pipe:[`, `socket:[`, `anon_inode:`) only when `fd_pin_tail` confirms the path resolves precisely to a direct child of a valid FD directory. - Categorically rejects pseudo-path resolution if `confine_root` is active (yielding `ENOENT`). - Strips `O_NOFOLLOW` for legitimate leaf pseudo-paths, allowing `openat()` to correctly delegate resolution. - Reverts `fd_pin_tail` to its upstream signature, as manual PID validation is no longer required due to the secure `openat()` design. --- syscall.c | 44 ++++++++++++- testsuite/pseudo-paths_test.py | 113 +++++++++++++++++++++++++++++++++ testsuite/skiplist/cygwin.txt | 1 + testsuite/skiplist/macos.txt | 1 + 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 testsuite/pseudo-paths_test.py diff --git a/syscall.c b/syscall.c index a98a99fac..77c36ff7f 100644 --- a/syscall.c +++ b/syscall.c @@ -319,8 +319,7 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz * (abspath_outside_confinement). A relative operator path starts at the * daemon's cwd == the module root; an absolute one (or a followed absolute * symlink target) restarts at "/". */ - char abspath[MAXPATHLEN]; - abspath[0] = '\0'; + char abspath[MAXPATHLEN] = {0}; if (am_daemon && module_dir && module_dir[0] == '/') strlcpy(abspath, module_dir, sizeof abspath); /* "/" for a path=/ module */ else if (confine_root) { @@ -344,7 +343,8 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz * reach the magic link. This only suspends the check for that prefix: * following the link restarts the walk at its absolute target, and every * component of THAT is checked, so a pin aimed outside is still refused. */ - int pin_transit = !am_daemon && confine_root && fd_pin_tail(path) != NULL; + const char *ptail = fd_pin_tail(path); + int pin_transit = !am_daemon && confine_root && ptail != NULL; /* Path-walk state. `remaining` is the unconsumed tail; we splice * symlink targets back into it as we go. Sized 2x MAXPATHLEN so a @@ -434,6 +434,44 @@ static int ona_open(const char *path, int flags, mode_t mode, char *out_abs, siz } target[n] = '\0'; + /* Detect Linux kernel pseudo-paths (pipes, sockets, anon_inodes). + * These are not real paths on disk and never contain slashes. */ + const char *abstail = fd_pin_tail(abspath); + int is_fd_dir = (abstail != NULL && *abstail == '\0' && ptail != NULL); + if (is_fd_dir && (strncmp(target, "pipe:[", 6) == 0 || + strncmp(target, "socket:[", 8) == 0 || + strncmp(target, "anon_inode:", 11) == 0)) { + if (confine_root) { + /* If confine-root is active, we categorically + * refuse to resolve kernel pseudo-paths. */ + saved_errno = ENOENT; + goto out; + } + /* Safely reopen the descriptor (Symlink traversal). + * Bash process substitution >(...) exposes /dev/fd/X as a symlink + * to a pipe (e.g., pipe:[12345]). If rsync attempts to open this + * with O_NOFOLLOW, the kernel will reject it with ELOOP. + * We strip O_NOFOLLOW (using & ~O_NOFOLLOW) to allow proper + * kernel symlink resolution of the pseudo-path. */ + retfd = openat(dfd, comp, (flags & ~O_NOFOLLOW) | O_CLOEXEC, mode); + /* POST-OPEN VERIFICATION: + * As an additional hardening measure against TOCTOU race conditions, + * we explicitly ensure we do not open a regular file or a directory. + * This prevents an attacker from swapping the FD to a sensitive + * file just before openat(), while still + * preserving legitimate support for pipes, sockets, and anon_inodes. */ + if (retfd >= 0) { + STRUCT_STAT pst; + if (fstat(retfd, &pst) < 0 || S_ISREG(pst.st_mode) || S_ISDIR(pst.st_mode)) { + close(retfd); + retfd = -1; + errno = ELOOP; + } + } + saved_errno = retfd < 0 ? errno : 0; + goto out; + } + /* Splice: new `remaining` = + . * Absolute target restarts the walk from "/". */ char tail[MAXPATHLEN]; diff --git a/testsuite/pseudo-paths_test.py b/testsuite/pseudo-paths_test.py new file mode 100644 index 000000000..6044ad548 --- /dev/null +++ b/testsuite/pseudo-paths_test.py @@ -0,0 +1,113 @@ +"""Process substitution /dev/fd/ write pipe pseudo-paths for --log-file must not crash and must successfully write logs, but must be rejected if confined root.""" + +import shlex +import shutil +import subprocess +import sys +from pathlib import Path + +from rsyncfns import ( + SCRATCHDIR, makepath, rmtree, rsync_argv, test_fail, test_skipped, +) +if not sys.platform.startswith('linux'): + test_skipped('Kernel pseudo-path string is a Linux-specific procfs feature') + raise SystemExit(0) + +# We require bash specifically because standard POSIX /bin/sh does not +# guarantee support for >(...) process substitution syntax. +bash = shutil.which('bash') +if bash is None: + test_skipped('bash is unavailable, cannot test process substitution') + +# Verify the host bash actually supports process substitution +probe = subprocess.run( + [bash, '-c', 'echo "probe" > >(cat > /dev/null)'], + capture_output=True +) +if probe.returncode != 0: + test_skipped('bash process substitution is not supported on this system') + +base = Path(SCRATCHDIR / 'rsync-pseudo-path').resolve() +src = base / 'src' +dest = base / 'dest' +log_out = base / 'test_log.txt' +log_out_confined = base / 'test_log_confined.txt' +makepath(src, dest) + +(src / 'transfer_me.txt').write_text('sync this\n') + +rsync_base_cmd = shlex.join(rsync_argv('-a')) +src_path = shlex.quote(str(src) + '/') +dest_path = shlex.quote(str(dest) + '/') + +log_path = shlex.quote(str(log_out)) +log_path_confined = shlex.quote(str(log_out_confined)) + +# ------------------------------------------------------------------------- +# TEST 1: Unconfined process substitution (Should Succeed) +# ------------------------------------------------------------------------- +bash_script = f"{rsync_base_cmd} -v --log-file=>(cat > {log_path}) {src_path} {dest_path}" + +try: + proc = subprocess.run( + [bash, '-c', bash_script], + capture_output=True, + text=True, + timeout=10, + ) +except subprocess.TimeoutExpired: + rmtree(base) + test_fail('process substitution test timed out') + +ctx = f'rc={proc.returncode}, stderr={proc.stderr.strip()!r}' + +if proc.returncode != 0: + rmtree(base) + test_fail(f'rsync crashed writing to a pseudo-path log pipe ({ctx})') + +if not (dest / 'transfer_me.txt').is_file(): + rmtree(base) + test_fail(f'rsync failed to transfer the allowed file ({ctx})') + +if not log_out.exists() or log_out.stat().st_size == 0: + rmtree(base) + test_fail(f'rsync survived, but failed to write data to the log pipe ({ctx})') + +log_data = log_out.read_text() +if "transfer_me.txt" not in log_data: + rmtree(base) + test_fail(f'Log pipe received data, but is missing expected output: {log_data[:100]}') + +print('Test 1 Passed: rsync successfully wrote logs to a process substitution pseudo-path') + +# ------------------------------------------------------------------------- +# TEST 2: Confined Root (Should Reject Pseudo-path) +# ------------------------------------------------------------------------- +bash_script_confined = f"{rsync_base_cmd} --confine-root={dest_path} -v --log-file=>(cat > {log_path_confined}) {src_path} {dest_path}" + +try: + proc_confined = subprocess.run( + [bash, '-c', bash_script_confined], + capture_output=True, + text=True, + timeout=10, + ) +except subprocess.TimeoutExpired: + rmtree(base) + test_fail('confined process substitution test timed out') + +ctx_confined = f'rc={proc_confined.returncode}, stderr={proc_confined.stderr.strip()!r}' + +# Rsync considers log-file failure a warning, so it still exits 0. +stderr_lower = proc_confined.stderr.lower() +if "no such file or directory" in stderr_lower and "failed to open" in stderr_lower: + if log_out_confined.exists() and log_out_confined.stat().st_size > 0: + rmtree(base) + test_fail(f'rsync printed an error but still wrote the confined log! ({ctx_confined})') + print('Test 2 Passed: rsync correctly rejected the pseudo-path when confine_root was active') +else: + rmtree(base) + test_fail(f'rsync failed to reject the pseudo-path or had an unexpected error ({ctx_confined})') + +rmtree(base) +raise SystemExit(0) diff --git a/testsuite/skiplist/cygwin.txt b/testsuite/skiplist/cygwin.txt index 7cdbac732..cf9e4ac82 100644 --- a/testsuite/skiplist/cygwin.txt +++ b/testsuite/skiplist/cygwin.txt @@ -52,6 +52,7 @@ partial-protected-regular-retry-linux partial-protected-regular-retry-policy # deterministic partial EACCES recovery uses dyld interposing password-file-symlink protected-regular +pseudo-paths rename-mixed-parent-transfer rrsync-sender-leaf-flip rrsync-sender-parent-pin diff --git a/testsuite/skiplist/macos.txt b/testsuite/skiplist/macos.txt index 3ec3c6b04..b64e92b4c 100644 --- a/testsuite/skiplist/macos.txt +++ b/testsuite/skiplist/macos.txt @@ -22,6 +22,7 @@ open-noatime partial-protected-regular-retry-linux preallocate protected-regular +pseudo-paths # dynamically skips on runners lacking bash process substitution readonly-partial-abort-mode-regression # rrsync-sender-leaf-flip rrsync-sender-parent-pin From f7bfc3c990a0d0f93c48426f12845c32ca3495fa Mon Sep 17 00:00:00 2001 From: Zen Dodd Date: Tue, 25 Aug 2026 13:38:56 +1000 Subject: [PATCH 2/2] testsuite: expect pseudo-path skip on Alma --- .github/workflows/almalinux-8-build.yml | 2 +- testsuite/skiplist/README.md | 1 + testsuite/skiplist/almalinux-8.txt | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 testsuite/skiplist/almalinux-8.txt diff --git a/.github/workflows/almalinux-8-build.yml b/.github/workflows/almalinux-8-build.yml index 8eb4598c3..a0c474e0b 100644 --- a/.github/workflows/almalinux-8-build.yml +++ b/.github/workflows/almalinux-8-build.yml @@ -67,7 +67,7 @@ jobs: # crtimes-not-supported skip matches the other Linux jobs; # daemon-chroot-acl and proxy-response-line-too-long skip because # the default (secure) transport opens no listening socket. - run: RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt make check + run: RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/common.txt,@testsuite/skiplist/linux.txt,@testsuite/skiplist/almalinux-8.txt make check - name: check (TCP daemon transport) # Second run exercising the real loopback-TCP daemon path. run: ./runtests.py --rsync-bin="$PWD/rsync" --use-tcp -j 8 diff --git a/testsuite/skiplist/README.md b/testsuite/skiplist/README.md index 31dd457ed..2e822eff8 100644 --- a/testsuite/skiplist/README.md +++ b/testsuite/skiplist/README.md @@ -23,6 +23,7 @@ different tests merge cleanly. | file | contents | | --- | --- | | `common.txt` | skipped on every platform that runs the oracle — mostly `require_tcp` / `require_asan` tests, which the default stdio-pipe `make check` cannot satisfy | +| `almalinux-8.txt` | AlmaLinux 8 container additions | | `linux.txt` | Linux-only additions | | `macos.txt` | macOS-only additions | | `cygwin.txt` | Cygwin-only additions | diff --git a/testsuite/skiplist/almalinux-8.txt b/testsuite/skiplist/almalinux-8.txt new file mode 100644 index 000000000..483ac8cef --- /dev/null +++ b/testsuite/skiplist/almalinux-8.txt @@ -0,0 +1,8 @@ +# Tests expected to SKIP. One name per line, '#' starts a comment; the file +# must stay sorted and duplicate-free (runtests.py enforces both). Referenced +# from a workflow as RSYNC_EXPECT_SKIPPED=@testsuite/skiplist/[,@...]. +# See testsuite/skiplist/README.md. +# +# AlmaLinux 8 container additions to common.txt and linux.txt. + +pseudo-paths # Bash process substitution is unavailable in the AlmaLinux 8 container