syscall: use openat2(RESOLVE_BENEATH) on Linux for secure_relative_open

The CVE fix in commit c35e283 made secure_relative_open() walk every
component of relpath with O_NOFOLLOW. That blocks every symlink in the
path, which is stricter than the threat model required: legitimate
directory symlinks within the destination tree (e.g. when using -K /
--copy-dirlinks) are also rejected, breaking delta transfers with
"failed verification -- update discarded".  See issue #715.

On Linux 5.6+, openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS) gives
us exactly what we want: the kernel rejects any resolution that would
escape the starting directory (via "..", absolute paths, or symlinks
pointing outside dirfd) while still following symlinks that resolve
within it. /proc magic-links are blocked too.

Use openat2 first; fall back to the existing per-component O_NOFOLLOW
walk on ENOSYS (kernel < 5.6). The lexical "../" checks at the head
of the function are kept as defense in depth. The Linux gate is
plain #ifdef __linux__: the runtime ENOSYS fallback covers the only
case that actually matters (header present + old kernel), and any
Linux build environment without linux/openat2.h will fail with a
clear "no such file" error rather than silently disabling the
protection.

Verified manually that openat2(RESOLVE_BENEATH) blocks all four
escape patterns (absolute symlink, ../ symlink, lexical .., absolute
path) while allowing direct and within-tree symlinks. The new
testsuite/symlink-dirlink-basis.test (taken from PR #864 by Samuel
Henrique) exercises the issue #715 regression and passes; full
make check passes 47/47.

Test: testsuite/symlink-dirlink-basis.test (8 scenarios)
Fixes: https://github.com/RsyncProject/rsync/issues/715

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrew Tridgell
2026-04-30 08:39:22 +10:00
parent dcf364dac5
commit 4fa7156ccd
2 changed files with 303 additions and 4 deletions

View File

@@ -33,6 +33,11 @@
#include <sys/syscall.h>
#endif
#ifdef __linux__
#include <sys/syscall.h>
#include <linux/openat2.h>
#endif
#include "ifuncs.h"
extern int dry_run;
@@ -720,12 +725,49 @@ int do_open_nofollow(const char *pathname, int flags)
/*
open a file relative to a base directory. The basedir can be NULL,
in which case the current working directory is used. The relpath
must be a relative path, and the relpath must not contain any
elements in the path which follow symlinks (ie. like O_NOFOLLOW, but
applies to all path components, not just the last component)
must be a relative path. The kernel must guarantee that resolution
cannot escape basedir (or the cwd, when basedir is NULL): no ".."
jumps above the start, no symlinks pointing outside, no absolute
paths, no /proc magic-link tricks.
The relpath must also not contain any ../ elements in the path
Symlinks *within* basedir are followed normally — earlier rsync
versions rejected every symlink with O_NOFOLLOW on each component,
which broke legitimate directory symlinks on the receiver side
(https://github.com/RsyncProject/rsync/issues/715). The escape
prevention is handled by the kernel via openat2(RESOLVE_BENEATH)
on Linux 5.6+; older systems fall back to the per-component
O_NOFOLLOW walk below.
The relpath must also not contain any ../ elements in the path.
*/
#ifdef __linux__
static int secure_relative_open_linux(const char *basedir, const char *relpath, int flags, mode_t mode)
{
struct open_how how;
int dirfd, retfd;
memset(&how, 0, sizeof how);
how.flags = flags;
how.mode = mode;
how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS;
if (basedir == NULL) {
dirfd = AT_FDCWD;
} else {
dirfd = openat(AT_FDCWD, basedir, O_RDONLY | O_DIRECTORY);
if (dirfd == -1)
return -1;
}
retfd = syscall(SYS_openat2, dirfd, relpath, &how, sizeof how);
if (dirfd != AT_FDCWD)
close(dirfd);
return retfd;
}
#endif
int secure_relative_open(const char *basedir, const char *relpath, int flags, mode_t mode)
{
if (!relpath || relpath[0] == '/') {
@@ -739,6 +781,16 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo
return -1;
}
#ifdef __linux__
{
int fd = secure_relative_open_linux(basedir, relpath, flags, mode);
/* ENOSYS = kernel < 5.6 doesn't have the syscall even though
* glibc/kernel-headers do; fall through to the portable path. */
if (fd != -1 || errno != ENOSYS)
return fd;
}
#endif
#if !defined(O_NOFOLLOW) || !defined(O_DIRECTORY) || !defined(AT_FDCWD)
// really old system, all we can do is live with the risks
if (!basedir) {