From eea59f9fbea5a4e0da95f4f24a9870d81548fa2c Mon Sep 17 00:00:00 2001 From: seks99x Date: Fri, 16 Oct 2026 20:39:20 +0300 Subject: [PATCH] Bound aligned_i by s->count in hash_search() (--inplace) When updating_basis_file is active (--inplace), hash_search() advances aligned_offset by s->blength and increments the 32-bit signed counter aligned_i until aligned_offset catches up to offset. If the source file is larger than 2 GiB and the peer supplies a small s->blength and s->count, continuing to increment aligned_i past s->count serves no purpose (as no basis blocks exist beyond s->count - 1) and can overflow INT32_MAX into a negative index that bypasses the one-sided aligned_i < s->count check. Stop the catch-up loop once aligned_i reaches s->count so aligned_i remains strictly within [0, s->count] and cannot overflow. Reported-by: Mominul Islam Hemal --- match.c | 2 +- testsuite/aligned_overflow_test.py | 190 +++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100755 testsuite/aligned_overflow_test.py diff --git a/match.c b/match.c index 4fa3b8599..62029208d 100644 --- a/match.c +++ b/match.c @@ -277,7 +277,7 @@ static void hash_search(int f,struct sum_struct *s, * the adjacent want_i optimization. */ if (updating_basis_file) { /* All the generator's chunks start at blength boundaries. */ - while (aligned_offset < offset) { + while (aligned_offset < offset && aligned_i < s->count) { aligned_offset += s->blength; aligned_i++; } diff --git a/testsuite/aligned_overflow_test.py b/testsuite/aligned_overflow_test.py new file mode 100755 index 000000000..a1c170478 --- /dev/null +++ b/testsuite/aligned_overflow_test.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""aligned_i overflow in hash_search() (--inplace > 2 GiB). Reported by Mominul Hemal + +Verifies that when `aligned_offset < offset` advances `aligned_i` in +hash_search(), `aligned_i` is bounded by `s->count` and cannot overflow a +32-bit signed integer on files > 2 GiB. +""" + +import socket +import struct +import subprocess +import threading + +from rsyncfns import FROMDIR, TODIR, TOOLDIR, rmtree, test_fail + +RSYNC_BIN = str(TOOLDIR / 'rsync') + +MPLEX_BASE = 7 +MSG_DATA = 0 +NDX_DONE = -1 +ITEM_TRANSFER = 1 << 15 + + +def w_shortint(x: int) -> bytes: + return struct.pack(' bytes: + return struct.pack('= 0 else x) + + +def w_varint(x: int) -> bytes: + if x < 0: + x = (1 << 32) + x + b = bytearray() + while True: + b.append(x & 0x7F) + x >>= 7 + if x == 0: + break + b[-1] |= 0x80 + return bytes(b) + + +def w_sum_head(count: int, blength: int, s2length: int, remainder: int) -> bytes: + return w_int(count) + w_int(blength) + w_int(s2length) + w_int(remainder) + + +def get_checksum1(buf: bytes) -> int: + sb = [c - 256 if c >= 128 else c for c in buf] + s1 = s2 = 0 + for val in sb: + s1 = (s1 + val) & 0xFFFFFFFF + s2 = (s2 + s1) & 0xFFFFFFFF + return ((s1 & 0xFFFF) + ((s2 & 0xFFFF) << 16)) & 0xFFFFFFFF + + +class MinimalDaemonReceiver: + def __init__(self, sock: socket.socket): + self.sock = sock + self._ndx_prev_positive = -1 + + def _readline(self) -> str: + line = bytearray() + while not line.endswith(b'\n'): + c = self.sock.recv(1) + if not c: + raise IOError("EOF reading line") + line += c + return line.decode('latin-1').rstrip('\n') + + def handshake(self): + self.sock.sendall(b"@RSYNCD: 30.0\n") + self._readline() # client greeting + self._readline() # module name + self.sock.sendall(b"@RSYNCD: OK\n") + self.sock.sendall(w_varint(0) + w_int(0)) + + def send_data(self, payload: bytes): + hdr = struct.pack(' bytes: + if ndx == NDX_DONE: + return b'\x00' + diff = ndx - self._ndx_prev_positive + self._ndx_prev_positive = ndx + return bytes([diff]) + + +BIG_SIZE = (1 << 31) + 64 # 2 GiB + 64 bytes +NEEDLE_OFFSET = (1 << 31) + 8 # Matching byte past 2^31 +NEEDLE_BYTE = b'\x01' + +rmtree(FROMDIR) +rmtree(TODIR) +FROMDIR.mkdir(parents=True, exist_ok=True) +TODIR.mkdir(parents=True, exist_ok=True) + +bigfile = FROMDIR / 'bigfile.bin' +out_log = TODIR / 'client.log' + +with open(bigfile, 'wb') as f: + f.truncate(BIG_SIZE) + f.seek(0) + f.write(NEEDLE_BYTE) + f.seek(NEEDLE_OFFSET) + f.write(NEEDLE_BYTE) + +lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +lsock.bind(('127.0.0.1', 0)) +port = lsock.getsockname()[1] +lsock.listen(1) +lsock.settimeout(30) + +csock = None +t = None +rc = None +logf = open(out_log, 'wb') +proc = subprocess.Popen( + [ + RSYNC_BIN, '-a', '--inplace', '--debug=deltasum1', + str(bigfile), f'rsync://127.0.0.1:{port}/mod/', + ], + stdout=logf, + stderr=logf, +) + +try: + csock, _ = lsock.accept() + receiver = MinimalDaemonReceiver(csock) + receiver.handshake() + + def drain_stream(): + try: + csock.settimeout(90) + while csock.recv(1 << 20): + pass + except OSError: + pass + + t = threading.Thread(target=drain_stream, daemon=True) + t.start() + + sum1 = get_checksum1(NEEDLE_BYTE) + # Send transfer request followed by 4 NDX_DONEs (phase 1, phase 2, + # end of send_files, and final goodbye) so the sender exits cleanly + # as soon as hash_search() completes. + buf = ( + receiver.w_ndx(0) + + w_shortint(ITEM_TRANSFER) + + w_sum_head(1, 1, 0, 0) + + w_int(sum1) + + (receiver.w_ndx(NDX_DONE) * 4) + ) + receiver.send_data(buf) + + rc = proc.wait(timeout=120) + t.join(timeout=5) +finally: + if csock is not None: + try: + csock.close() + except OSError: + pass + lsock.close() + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + if t is not None: + t.join(timeout=5) + logf.close() + bigfile.unlink(missing_ok=True) + +out_text = out_log.read_text(encoding='utf-8', errors='replace') +print(f"sender exited with rc={rc}", flush=True) +print(out_text[-1000:], flush=True) + +if rc != 0 or 'total: matches=' not in out_text: + test_fail( + f"sender crashed or did not complete hash_search() past 2 GiB " + f"(rc={rc}):\n{out_text[-1000:]}" + ) + +print("aligned_overflow test passed: completed >2 GiB hash_search() cleanly", flush=True)