From: Andrew Tridgell Date: Wed, 31 Dec 2025 02:50:35 +0000 (+1100) Subject: clientserver: fix hostname ACL bypass when using daemon chroot X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=6b6d875a2e3f30a4309a4e09bdeeb774b1155915;p=thirdparty%2Frsync.git clientserver: fix hostname ACL bypass when using daemon chroot On an rsync daemon configured with "daemon chroot", the reverse-DNS lookup of the connecting client was performed *after* the chroot had been entered. If the chroot did not contain the files glibc needs for resolution (/etc/resolv.conf, /etc/nsswitch.conf, /etc/hosts, NSS service modules), the lookup failed and client_name() returned "UNKNOWN". Hostname-based deny rules ("hosts deny = *.evil.example") therefore could not match, and an attacker controlling their PTR record could connect from a hostname the administrator had intended to deny. IP-based ACLs were unaffected. Do the reverse DNS lookup before chroot/setuid; client_name() caches its result, so the post-chroot call uses the cached value and hostname-based ACLs work even when DNS is unavailable post-chroot. Adds testsuite/daemon-chroot-acl.test as end-to-end regression coverage. The test sets up an empty chroot directory, configures "hosts deny = " with daemon chroot, and asserts the connection is refused with @ERROR access denied. Uses unshare --user --map-root-user for non-root CAP_SYS_CHROOT; skips cleanly on non-Linux or when user namespaces aren't available. Reporter: Joshua Rogers (MegaManSec). Co-Authored-By: Claude Opus 4.7 (1M context) --- diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml index 729798f3..a127526e 100644 --- a/.github/workflows/macos-build.yml +++ b/.github/workflows/macos-build.yml @@ -41,7 +41,7 @@ jobs: - name: info run: rsync --version - name: check - run: sudo RSYNC_EXPECT_SKIPPED=acls-default,chmod-temp-dir,chown-fake,devices-fake,dir-sgid,open-noatime,protected-regular,simd-checksum,xattrs-hlink,xattrs make check + run: sudo RSYNC_EXPECT_SKIPPED=acls-default,chmod-temp-dir,chown-fake,daemon-chroot-acl,devices-fake,dir-sgid,open-noatime,protected-regular,simd-checksum,xattrs-hlink,xattrs make check - name: ssl file list run: rsync-ssl --no-motd download.samba.org::rsyncftp/ || true - name: save artifact diff --git a/clientserver.c b/clientserver.c index e8dfddb1..14daba3c 100644 --- a/clientserver.c +++ b/clientserver.c @@ -1312,6 +1312,28 @@ int start_daemon(int f_in, int f_out) if (lp_proxy_protocol() && !read_proxy_protocol_header(f_in)) return -1; + /* Do reverse DNS lookup before chroot/setuid. The result is cached, + * so the later client_name() call will use this cached value. This + * ensures hostname-based ACLs work even when DNS is unavailable + * after chroot. + * + * "reverse lookup" can be set globally OR per-module, so we also + * scan each module: a deployment with "reverse lookup = no" in the + * global section but "reverse lookup = yes" in a specific module + * still triggers a post-chroot lookup at access-check time + * (rsync_module() in this file), which would also fail in the + * chroot and turn hostname-based deny rules into silent bypasses. */ + { + int need_reverse = lp_reverse_lookup(-1); + int j, num_modules = lp_num_modules(); + for (j = 0; !need_reverse && j < num_modules; j++) { + if (lp_reverse_lookup(j)) + need_reverse = 1; + } + if (need_reverse) + (void)client_name(client_addr(f_in)); + } + p = lp_daemon_chroot(); if (*p) { log_init(0); /* Make use we've initialized syslog before chrooting. */ diff --git a/testsuite/daemon-chroot-acl.test b/testsuite/daemon-chroot-acl.test new file mode 100644 index 00000000..9d1c1b63 --- /dev/null +++ b/testsuite/daemon-chroot-acl.test @@ -0,0 +1,111 @@ +#!/bin/sh + +# Copyright (C) 2026 by Andrew Tridgell + +# This program is distributable under the terms of the GNU GPL (see +# COPYING). + +# Regression test for GHSA-rjfm-3w2m-jf4f: a hostname-based "hosts deny" +# rule must still match when the daemon performs a 'daemon chroot' and +# the chroot does not contain the NSS files glibc needs for reverse DNS. +# +# Pre-fix, reverse DNS happened *after* the daemon chroot. With an empty +# chroot the NSS lookup failed, client_name() returned "UNKNOWN", and a +# deny rule referring to the connecting hostname silently failed to +# match. +# +# Two scenarios are exercised so we can distinguish the case the fix +# definitely covers from the per-module path that may still be +# vulnerable: +# A. global "reverse lookup = yes" (covered by b6abdb4c) +# B. only module "reverse lookup = yes" (gap to verify) + +. "$suitedir/rsync.fns" + +case `uname -s` in +Linux*) ;; +*) test_skipped "test is Linux-specific (uses chroot+unshare)" ;; +esac + +# We need CAP_SYS_CHROOT. Re-exec under a user namespace if not root. +if ! chroot / /bin/true 2>/dev/null; then + if [ -z "$RSYNC_UNSHARED" ] && unshare --user --map-root-user true 2>/dev/null; then + echo "Re-running under unshare --user --map-root-user..." + RSYNC_UNSHARED=1 exec unshare --user --map-root-user "$SHELL_PATH" $RUNSHFLAGS "$0" + fi + test_skipped "need CAP_SYS_CHROOT (root or unshare --user --map-root-user)" +fi + +# We need 127.0.0.1 to reverse-resolve to a real hostname while NSS is +# still working (i.e. before the daemon's chroot). The daemon will +# look that name up itself as part of its hostname-based ACL check; +# we then deny that name and assert the connection is rejected. +client_hostname=`getent hosts 127.0.0.1 2>/dev/null | awk 'NR==1 {print $2}'` +if [ -z "$client_hostname" ] || [ "$client_hostname" = "127.0.0.1" ]; then + test_skipped "no reverse DNS for 127.0.0.1" +fi + +chrootdir="$scratchdir/chroot" +rm -rf "$chrootdir" +mkdir -p "$chrootdir/modroot" +echo "from chroot" > "$chrootdir/modroot/file1" + +conf="$scratchdir/test-rsyncd.conf" +logfile="$scratchdir/rsyncd.log" + +write_conf() { + cat >"$conf" <"$out" 2>&1 + rc=$? + + echo "----- $label (rsync exit $rc):" + cat "$out" + echo "----- daemon log:" + [ -f "$logfile" ] && cat "$logfile" + echo "-----" + + grep -q '@ERROR.*access denied' "$out" +} + +# Scenario A: global reverse lookup. Covered by b6abdb4c. +write_conf yes yes +if ! run_check "Scenario A (global reverse lookup = yes)"; then + test_fail "Scenario A: hostname deny rule was bypassed" +fi + +# Scenario B: only the per-module reverse-lookup setting is enabled. +# The b6abdb4c fix only pre-warms client_name()'s cache when the +# global setting is on, so the post-chroot lookup in this path may +# still produce "UNKNOWN" and bypass the deny rule. +write_conf no yes +if ! run_check "Scenario B (per-module reverse lookup only)"; then + test_fail "Scenario B: hostname deny rule was bypassed (per-module reverse lookup with daemon chroot still has the bypass)" +fi + +exit 0