flist: reject non-directory transfer-root entries

This commit is contained in:
Codex authored and Andrew Tridgell committed 2026-07-23 15:39:14 +10:00
1 parent ab4e81d749
commit 21fade1bbb
3 files changed
+163

No files matched your search

+9
View File
@@ -1124,6 +1124,15 @@ static struct file_struct *recv_file_entry(int f, struct file_list *flist, int x
exit_cleanup(RERR_UNSUPPORTED);
}
/* "." is the synthetic transfer root. Reinterpreting it as a file lets
* --force recursively remove the real destination directory before the
* receiver creates that file. */
if ((!strcmp(thisname, ".") || !strcmp(thisname, "/.")) && !S_ISDIR(mode)) {
rprintf(FERROR, "ERROR: rejecting non-directory transfer-root entry: %s\n",
thisname);
exit_cleanup(RERR_PROTOCOL);
}
if (*thisname == '/' ? thisname[1] != '.' || thisname[2] != '\0' : *thisname != '.' || thisname[1] != '\0') {
int filt_flags = S_ISDIR(mode) ? NAME_IS_DIR : NAME_IS_FILE;
if (!trust_sender_filter /* a per-dir filter rule means we must trust the sender's filtering */
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""A write client must not use a forged dot entry to wipe a daemon module."""
import time
from rsyncfns import (
SCRATCHDIR, claim_ports, makepath, require_tcp, rmtree, start_rsyncd,
test_fail,
)
import rsync_proto as rp
PORT = 13013
require_tcp("the malicious daemon client needs a real TCP socket; run with --use-tcp")
claim_ports(PORT)
base = SCRATCHDIR / 'daemon-dot-file-force-wipe'
rmtree(base)
mod = base / 'mod'
makepath(mod / 'private')
sentinel = mod / 'private' / 'must-survive'
sentinel.write_text('pre-existing module data\n')
conf = base / 'rsyncd.conf'
conf.write_text(f"""\
pid file = {base}/rsyncd.pid
use chroot = no
[mod]
path = {mod}
read only = no
write only = yes
refuse options = delete
""")
start_rsyncd(conf, PORT)
s = rp.DaemonClient('127.0.0.1', PORT)
s.handshake(
'mod',
['--server', '-re.LsfxCIu', '--force', '--no-inc-recursive', '.', 'mod/'],
greeting_version=30,
)
s.send_flat_flist([
rp.FileEntry('.', mode=rp.S_IFREG | 0o644, length=0),
])
s.drain(timeout=5)
s.close()
for _ in range(50):
if not sentinel.exists():
break
time.sleep(0.02)
if not sentinel.exists():
test_fail(
'write-only client bypassed refuse-options=delete by sending a regular '
'dot entry with --force and recursively wiped the daemon module root')
print('daemon-dot-file-force-wipe: daemon rejected a non-directory dot root '
'from a write-only client')
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""A malicious sender must not reinterpret the destination root as a file.
The receiver permits the synthetic "." entry as a top-level directory even
when an implied-file filter restricts the requested source. This server sends
that special name with regular-file mode. Under --force, replacing it must not
recursively empty the receiver's destination root. No --delete is requested.
"""
import socket
import subprocess
import threading
from rsyncfns import (
SCRATCHDIR, claim_ports, makepath, require_tcp, rmtree, rsync_argv,
test_fail,
)
import rsync_proto as rp
PORT = 13012
require_tcp("the malicious sender needs a real TCP socket; run with --use-tcp")
claim_ports(PORT)
base = SCRATCHDIR / 'malicious-dot-file-delete-scope'
rmtree(base)
dest = base / 'dest'
makepath(dest / 'private')
sentinel = dest / 'private' / 'must-survive'
sentinel.write_text('receiver-owned file outside the requested leaf\n')
# Native control: the same scoped single-file transfer with --force does not
# touch receiver-owned siblings when the sender encodes the requested leaf
# honestly. This distinguishes the attack from documented --force semantics.
honest_src = base / 'honest-src'
makepath(honest_src)
(honest_src / 'requested').write_text('honest source\n')
control = subprocess.run(
rsync_argv('-r', '--force', '--no-inc-recursive',
str(honest_src / 'requested'), str(dest) + '/'),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30)
if control.returncode or not sentinel.exists():
test_fail('native --force single-file control unexpectedly touched the '
f'destination scope:\n{control.stdout}{control.stderr}')
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
lsock.bind(('127.0.0.1', PORT))
lsock.listen(1)
lsock.settimeout(30)
state = {}
def serve():
try:
csock, _ = lsock.accept()
s = rp.DaemonReceiver(csock)
s.handshake()
# "." is the receiver's synthetic transfer-root entry and is exempted
# from its requested-name filter. Forge it as a regular file instead
# of the only legitimate representation: a directory.
s.send_data(
rp.FileEntry('.', mode=rp.S_IFREG | 0o644, length=0).encode()
+ rp.end_of_flist(0, s.protocol)
+ s.w_ndx(rp.NDX_DONE) * 3
)
s.drain(timeout=5)
s.close()
except Exception as exc: # noqa: BLE001 - reported below
state['err'] = repr(exc)
t = threading.Thread(target=serve, daemon=True)
t.start()
try:
proc = subprocess.run(
rsync_argv('-r', '--force', '--no-inc-recursive',
f'rsync://127.0.0.1:{PORT}/mod/requested', str(dest) + '/'),
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30)
finally:
t.join(timeout=10)
lsock.close()
if not sentinel.exists():
test_fail(
'malicious sender encoded the special "." root as a regular file and '
'made --force recursively erase the destination root')
if state.get('err'):
test_fail(f"Python sender failed before exercising receiver: {state['err']}\n"
f"receiver output:\n{proc.stdout}{proc.stderr}")
print('malicious-dot-file-delete-scope: receiver rejected a non-directory '
'encoding of its synthetic transfer-root entry')