mirror of
https://github.com/aphrody-labs/rsync.git
synced 2026-10-17 12:37:10 +00:00
flist: reject non-directory transfer-root entries
This commit is contained in:
1 parent
ab4e81d749
commit
21fade1bbb
3 files changed
+163
No files matched your search
@@ -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')
|
||||
Reference in new issue
Block a user