Files
rsync/t_symlink_secure.c
Andrew Tridgell 56f6b67453 generator: decide the unconfined mknod fallback at compile time
The ENOSYS test was the wrong discriminator.  It was meant to detect "this
build compiled no fd-relative create at all", but a live mknodat() or
mkfifoat() returns ENOSYS too -- an unimplemented FUSE mknod does, and
seccomp can synthesise it -- so a runtime failure could route a create
through the unconfined path on a platform that has the secure primitive.

On Linux the fall-through lands in do_mknod_at(), which re-confines with
secure_relative_open(), so no escape was reachable there.  The real gap is
a mixed-capability build (mkfifoat() but no mknodat()): a runtime ENOSYS
from a real mkfifoat() reached the unconfined fallback even though an
fd-relative FIFO primitive existed.

Whether a primitive exists is a property of the build, so decide it there:
no_atfd_mknod_primitive() is false wherever mknodat() covers the node type,
where mkfifoat() covers a FIFO, or where --fake-super creates through
openat() -- which is always present and was previously able to fall back on
its own unrelated failures.

Also correct the SECURITY.md residual, which overstated the loss.  Plain
mknod()/mkfifo() do not follow a planted leaf symlink; they fail EEXIST,
verified directly against a symlink to a victim file.  What a no-mknodat
platform actually loses is the pinned parent, so the residual is a
parent-component race rather than a followed basename, and ordinary
fake-super placeholder creation stays confined via openat(O_NOFOLLOW).

And narrow t_symlink_secure's skip: it skipped the whole helper without
mknodat(), including do_symlink_at() assertions that do not depend on it.
Only the do_mknod_at() checks are now skipped, and the helper still skips
outright when neither applies rather than passing vacuously.
2026-08-01 19:19:42 +10:00

161 lines
4.8 KiB
C

/*
* Test harness for the fake-super branches of do_symlink_at()/do_mknod_at().
* Fake-super stores a symlink/device as a placeholder file, so the create
* resolves the final component; the no-slash branch used to fall back to
* do_symlink()/do_mknod(), whose plain open() followed a planted basename
* symlink and escaped the module. Checks the fixed wrappers refuse it;
* --poc shows the old fallback escaping. Not linked into rsync. GPL version 2.
*/
#include "rsync.h"
#include <sys/stat.h>
/* The symlink placeholder (and thus this escape) exists only where symlink
* xattrs are unavailable -- the same guard do_symlink() uses. Elsewhere
* symlink() fails EEXIST on a planted link, so only the device path applies. */
#if defined SUPPORT_LINKS && (defined NO_SYMLINK_XATTRS || defined NO_SYMLINK_USER_XATTRS)
#define TEST_SYMLINK_PLACEHOLDER 1
#endif
int dry_run = 0;
int am_root = -1; /* --fake-super */
int am_sender = 0;
int read_only = 0;
int list_only = 0;
int copy_links = 0;
int copy_unsafe_links = 0;
extern int am_daemon, am_chrooted;
short info_levels[COUNT_INFO], debug_levels[COUNT_DEBUG];
static int errs = 0;
static void check_preserved(const char *label, const char *victim, const char *want)
{
char buf[256];
int fd = open(victim, O_RDONLY);
ssize_t n = fd >= 0 ? read(fd, buf, sizeof buf - 1) : -1;
if (fd >= 0)
close(fd);
if (n < 0)
n = 0;
buf[n] = '\0';
if (n > 0 && buf[n-1] == '\n')
buf[n-1] = '\0';
if (strcmp(buf, want) != 0) {
fprintf(stderr, "FAIL [%s]: victim %s = \"%s\", expected \"%s\" "
"(basename symlink was followed -> module escape)\n",
label, victim, buf, want);
errs++;
return;
}
fprintf(stderr, "OK [%s]: victim %s preserved\n", label, victim);
}
static void check_clobbered(const char *label, const char *victim, const char *unwanted)
{
char buf[256];
int fd = open(victim, O_RDONLY);
ssize_t n = fd >= 0 ? read(fd, buf, sizeof buf - 1) : -1;
if (fd >= 0)
close(fd);
if (n < 0)
n = 0;
buf[n] = '\0';
if (n > 0 && buf[n-1] == '\n')
buf[n-1] = '\0';
if (strcmp(buf, unwanted) != 0) {
fprintf(stderr, "FAIL [%s]: victim %s = \"%s\", expected the escape to write \"%s\"\n",
label, victim, buf, unwanted);
errs++;
return;
}
fprintf(stderr, "OK [%s]: victim %s clobbered as expected (escape demonstrated)\n",
label, victim);
}
int main(int argc, char **argv)
{
#ifndef AT_FDCWD
fprintf(stderr, "SKIP: AT_FDCWD not available\n");
return 77;
#else
int poc = 0;
const char *moddir;
# if !defined(HAVE_MKNODAT) && !defined(TEST_SYMLINK_PLACEHOLDER)
/* Nothing left to assert: the do_mknod_at() checks need mknodat(), and
* the do_symlink_at() ones are not compiled here. Skip rather than
* pass vacuously. */
(void)argc; (void)argv;
fprintf(stderr, "SKIP: no mknodat() and no symlink placeholders -- "
"nothing this helper asserts applies to this build\n");
return 77;
# endif
if (argc == 3 && strcmp(argv[1], "--poc") == 0) {
poc = 1;
moddir = argv[2];
} else if (argc == 2) {
moddir = argv[1];
} else {
fprintf(stderr, "usage: %s [--poc] <module-dir>\n", argv[0]);
return 2;
}
if (chdir(moddir) < 0) {
perror("chdir");
return 2;
}
am_daemon = 1;
am_chrooted = 0;
am_root = -1; /* fake-super: symlinks/devices stored as files */
if (poc) {
/* Pre-fix fallback: a no-slash path went to do_symlink()/do_mknod(),
* which open() the basename without O_NOFOLLOW. */
#ifdef TEST_SYMLINK_PLACEHOLDER
do_symlink("VULN_SYM_PAYLOAD", "sympath");
check_clobbered("poc do_symlink bare", "../outside/secret_sym",
"VULN_SYM_PAYLOAD");
#endif
do_mknod("nodpath", S_IFCHR | 0600, 0);
check_clobbered("poc do_mknod bare", "../outside/secret_nod", "");
return errs ? 1 : 0;
}
/* Fixed wrappers: a bare-path basename symlink must not be followed;
* the victim outside the module stays untouched. */
#ifdef TEST_SYMLINK_PLACEHOLDER
do_symlink_at("FIXED_SYM_PAYLOAD", "sympath");
check_preserved("do_symlink_at bare", "../outside/secret_sym", "VICTIM_SYM");
/* Slashed path for parity (already protected before the fix). */
do_symlink_at("FIXED_SYM_PAYLOAD", "sub/sympath2");
check_preserved("do_symlink_at slashed", "../outside/secret_sym2", "VICTIM_SYM2");
#endif
# ifdef HAVE_MKNODAT
/* Without mknodat() do_mknod_at() IS do_mknod(): the confinement is
* compiled out by design (SECURITY.md), so these would assert a
* property the build deliberately does not have. The do_symlink_at()
* checks above do not depend on it and still run. */
do_mknod_at("nodpath", S_IFCHR | 0600, 0);
check_preserved("do_mknod_at bare", "../outside/secret_nod", "VICTIM_NOD");
do_mknod_at("sub/nodpath2", S_IFCHR | 0600, 0);
check_preserved("do_mknod_at slashed", "../outside/secret_nod2", "VICTIM_NOD2");
# endif
if (errs)
fprintf(stderr, "%d failure(s)\n", errs);
return errs ? 1 : 0;
#endif
}