]> git.ipfire.org Git - thirdparty/rsync.git/commitdiff
clientserver: fix hostname ACL bypass when using daemon chroot
authorAndrew Tridgell <andrew@tridgell.net>
Wed, 31 Dec 2025 02:50:35 +0000 (13:50 +1100)
committerAndrew Tridgell <andrew@tridgell.net>
Thu, 7 May 2026 21:49:13 +0000 (07:49 +1000)
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>
.github/workflows/macos-build.yml
clientserver.c
testsuite/daemon-chroot-acl.test [new file with mode: 0644]

index 729798f3834d9392fb31b5914bdf8bc75bbd226a..a127526e6b10c807ec2322872f897d82255a61fa 100644 (file)
@@ -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
index e8dfddb1bf77cdf1cfc2d227a5d74ede0ffd79d5..14daba3c0f9bba21d0d3e37adda3b39f23117be9 100644 (file)
@@ -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 (file)
index 0000000..9d1c1b6
--- /dev/null
@@ -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