From 74ea276900779b95ddd1769d1d6ae78b2fd1a790 Mon Sep 17 00:00:00 2001
From: Andrew Tridgell <andrew@tridgell.net>
Date: Wed, 31 Dec 2025 13:50:35 +1100
Subject: [PATCH 47/60] 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 = <localhost-resolved-name>" 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) <noreply@anthropic.com>
---
 clientserver.c                   |  22 ++++++
 testsuite/daemon-chroot-acl.test | 111 +++++++++++++++++++++++++++++++
 2 files changed, 133 insertions(+)
 create mode 100644 testsuite/daemon-chroot-acl.test

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" <<EOF
+use chroot = no
+log file = $logfile
+daemon chroot = $chrootdir
+reverse lookup = $1
+hosts deny = $client_hostname
+max verbosity = 4
+
+[chrootmod]
+    path = /modroot
+    read only = yes
+    reverse lookup = $2
+EOF
+}
+
+# Run a transfer and return 0 if the daemon refused with @ERROR access
+# denied (the expected outcome when the deny rule matches).
+run_check() {
+    label="$1"
+
+    rm -f "$logfile"
+    rm -rf "$todir"
+    mkdir -p "$todir"
+
+    out="$scratchdir/run.out"
+
+    RSYNC_CONNECT_PROG="$RSYNC --config=$conf --daemon" \
+	$RSYNC -av localhost::chrootmod/ "$todir/" >"$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
-- 
2.51.0

