Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions syscall.c
Original file line number Diff line number Diff line change
Expand Up @@ -3246,6 +3246,31 @@ int secure_relative_open(const char *basedir, const char *relpath, int flags, mo
flags |= O_NOATIME;
#endif

#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY
if (!am_daemon && am_sender && basedir && strcmp(basedir, "/") == 0 && *relpath) {
/* Absolute sender names retain their ancestors with --relative. Follow
* trusted-owned ancestor symlinks, not a RESOLVE_BENEATH walk that rejects
* /mnt/home -> /initrd/mnt/dev_save. Daemon and cwd anchors stay confined. */
char fullpath[MAXPATHLEN];
const char *bname;
int dfd, fd, saved_errno;
if (snprintf(fullpath, sizeof fullpath, "/%s", relpath) >= (int)sizeof fullpath) {
errno = ENAMETOOLONG;
return -1;
}
if (flags & O_DIRECTORY)
return open_no_attacker_symlinks(fullpath, flags, mode);
dfd = owner_walk_parent(fullpath, &bname);
Comment thread
steadytao marked this conversation as resolved.
Outdated
if (dfd < 0)
return -1;
fd = openat(dfd, bname, flags | O_NOFOLLOW, mode);
saved_errno = errno;
close(dfd);
errno = saved_errno;
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) {
Expand Down
58 changes: 58 additions & 0 deletions t_secure_relpath.c
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,63 @@ static void check_beneath_dotdot(void)
close(anchor);
}

static void check_sender_absolute_ancestor(void)
{
#if defined AT_FDCWD && defined O_NOFOLLOW && defined O_DIRECTORY
char cwd[MAXPATHLEN], target[MAXPATHLEN], path[MAXPATHLEN];
int fd;
if (!getcwd(cwd, sizeof cwd)
|| snprintf(target, sizeof target, "%s/subdir", cwd) >= (int)sizeof target
|| snprintf(path, sizeof path, "%s/absolute-alias", cwd) >= (int)sizeof path
|| symlink(target, "absolute-alias") < 0) {
perror("absolute ancestor fixture");
errs++;
return;
}
am_daemon = 0;
am_sender = 1;
fd = secure_relative_open("/", path + 1, O_RDONLY | O_DIRECTORY, 0);
if (fd < 0) {
perror("trusted absolute sender ancestor");
errs++;
} else
close(fd);
fd = secure_relative_open(NULL, "absolute-alias", O_RDONLY | O_DIRECTORY, 0);
if (fd >= 0 || errno != ELOOP) {
fprintf(stderr, "FAIL [cwd sender ancestor]: rc=%d errno=%d\n", fd, errno);
if (fd >= 0)
close(fd);
errs++;
}
if (symlink("missing", "subdir/leaf-link") < 0
|| snprintf(path, sizeof path, "%s/absolute-alias/leaf-link", cwd) >= (int)sizeof path) {
perror("sender leaf fixture");
errs++;
} else {
fd = secure_relative_open("/", path + 1, O_RDONLY, 0);
if (fd >= 0 || errno != ELOOP) {
fprintf(stderr, "FAIL [absolute sender leaf]: rc=%d errno=%d\n", fd, errno);
if (fd >= 0)
close(fd);
errs++;
}
}
am_daemon = 1;
am_sender = 0;
if (snprintf(path, sizeof path, "%s/absolute-alias", cwd) >= (int)sizeof path) {
errs++;
return;
}
fd = secure_relative_open("/", path + 1, O_RDONLY | O_DIRECTORY, 0);
if (fd >= 0 || errno != ELOOP) {
fprintf(stderr, "FAIL [daemon absolute ancestor]: rc=%d errno=%d\n", fd, errno);
if (fd >= 0)
close(fd);
errs++;
}
#endif
}

int main(int argc, char **argv)
{
if (argc != 2) {
Expand Down Expand Up @@ -224,6 +281,7 @@ int main(int argc, char **argv)
* literal '..'. Its dedicated fd-anchored entry point must preserve an
* in-tree climb while refusing to pop above the anchor. */
check_beneath_dotdot();
check_sender_absolute_ancestor();

if (errs)
fprintf(stderr, "\n%d failure(s)\n", errs);
Expand Down
56 changes: 56 additions & 0 deletions testsuite/relative-source-ancestor_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Absolute --relative sources with a trusted-owned ancestor symlink."""

import os
import pwd
import subprocess

from rsyncfns import SCRATCHDIR, rsync_argv, test_fail

real = SCRATCHDIR / 'real'
source = real / 'My_Documents'
source.mkdir(parents=True)
(source / 'marker').write_text('source contents\n')
link = SCRATCHDIR / 'home'
os.symlink(str(real), link)

for index, options in enumerate((('-a',), ('-aR',), ('-aR', '--no-inc-recursive'))):
for trailing in ('', '/'):
dest = SCRATCHDIR / f'dest-{index}-{bool(trailing)}'
dest.mkdir()
proc = subprocess.run(
rsync_argv(*options, str(link / 'My_Documents') + trailing, str(dest) + '/'),
capture_output=True, text=True,
)
if proc.returncode:
test_fail(f'trusted ancestor transfer failed: {proc.stdout}{proc.stderr}')
if '-aR' in options:
expected = dest / str(link / 'My_Documents').lstrip('/') / 'marker'
else:
expected = dest / ('' if trailing else 'My_Documents') / 'marker'
if not expected.is_file() or expected.read_text() != 'source contents\n':
test_fail('relative source layout or contents changed')

dest = SCRATCHDIR / 'remove-dest'
dest.mkdir()
proc = subprocess.run(
rsync_argv('-aR', '--remove-source-files', str(link / 'My_Documents') + '/', str(dest) + '/'),
capture_output=True, text=True,
)
expected = dest / str(link / 'My_Documents').lstrip('/') / 'marker'
if proc.returncode or (source / 'marker').exists() or not expected.is_file():
test_fail(f'remove-source-files failed: {proc.stdout}{proc.stderr}')
(source / 'marker').write_text('source contents\n')

if os.geteuid() == 0:
attacker = next((entry.pw_uid for entry in pwd.getpwall() if entry.pw_uid != 0), None)
if attacker is not None:
os.lchown(link, attacker, -1)
dest = SCRATCHDIR / 'untrusted-dest'
dest.mkdir()
proc = subprocess.run(
rsync_argv('-aR', str(link / 'My_Documents') + '/', str(dest) + '/'),
capture_output=True, text=True,
)
if proc.returncode == 0 or any(dest.rglob('marker')):
test_fail('an untrusted ancestor symlink was followed')
Loading