【解説】rsync の CVE-2024-12084 について

CVSS3スコア9.8、Critical な脆弱性で話題になった rsync の CVE-2024-12084 について、ソースコードレベルで何が起こっているか解説します。
https://access.redhat.com/security/cve/cve-2024-12084

この記事は会社の業務でまとめた情報から、社外秘部分を抜いて記載しています。
そのため一部で話の整合性が取れない場所があるかもしれません。ご了承ください。

結論

  • ファイル転送時の検証にMD5ハッシュ値を使っていた
  • v3.2.7で利用できるハッシュ値にSHA1/256/512が追加された
    • これに伴いハッシュ長が16→64まで拡張された
  • 一方でハッシュ値を格納する配列はMD5前提で16バイトのままだった
  • 利用するハッシュ長を16以上に指定するとメモリ破壊が発生した

この脆弱性は rsync v3.2.7 で追加され、v3.4.0 で修正されました。
ただしビルドオプションやバックポートの有無によって異なるため、バージョンのみで判断するのは危険です。

より正確に確認するには rsync –version の出力で Daemon auth list に SHA1/256/512 が入っているどうかで判断可能です。

影響なし(Rocky Linux9)

$ rsync --version
rsync  version 3.2.3  protocol version 31
Copyright (C) 1996-2020 by Andrew Tridgell, Wayne Davison, and others.
Web site: https://rsync.samba.org/
Capabilities:
    64-bit files, 64-bit inums, 64-bit timestamps, 64-bit long ints,
    socketpairs, hardlinks, hardlink-specials, symlinks, IPv6, atimes,
    batchfiles, inplace, append, ACLs, xattrs, optional protect-args, iconv,
    symtimes, prealloc, stop-at, no crtimes
Optimizations:
    SIMD, asm, openssl-crypto
Checksum list:
    md5 md4 none
Compress list:
    zstd lz4 zlibx zlib none


影響あり(CentOS Stream 10)

$ rsync --version
rsync  version 3.3.0  protocol version 31
Copyright (C) 1996-2024 by Andrew Tridgell, Wayne Davison, and others.
Web site: https://rsync.samba.org/
Capabilities:
    64-bit files, 64-bit inums, 64-bit timestamps, 64-bit long ints,
    socketpairs, symlinks, symtimes, hardlinks, hardlink-specials,
    hardlink-symlinks, IPv6, atimes, batchfiles, inplace, append, ACLs,
    xattrs, optional secluded-args, iconv, prealloc, stop-at, no crtimes
Optimizations:
    SIMD-roll, no asm-roll, openssl-crypto, no asm-MD5
Checksum list:
    md5 md4 sha1 none
Compress list:
    zstd lz4 zlibx zlib none
Daemon auth list: 
    sha512 sha256 sha1 md5 md4

解説

脆弱性が発生したコミット
https://github.com/RsyncProject/rsync/commit/ae16850

修正コミット
https://github.com/RsyncProject/rsync/commit/0902b52

前提知識

脆弱性の部分を解説する前に、既存の挙動を理解する必要があります。

ファイル検証時のブロックサイズ(blength)

rsyncではファイルを転送する際、ハッシュ値を用いて正しく転送できたか検証しています。
ハッシュ処理はファイル全体ではなく、ファイルをあるサイズのブロックに分割して行われます。

--block-size / -B オプションで指定可能です。
options.c#L1672-L1683

$ rsync --help | grep block-size
--block-size=SIZE, -B    force a fixed checksum block-size


特に指定がない場合、700バイトでブロックが区切られハッシュ値が計算されます。12
rsync.h#L25, generator.c#L702-L705, match.c#L159-L163

// rsync.h
#define BLOCK_SIZE 700

// checksum.c
uint32 get_checksum1(char *buf1, int32 len);
void get_checksum2(char *buf, int32 len, char *sum);

// generator.c
static void sum_sizes_sqroot(struct sum_struct *sum, int64 len)
{
	int32 blength;
	int s2length;
	int64 l;

	if (block_size)
		blength = block_size;
	else if (len <= BLOCK_SIZE * BLOCK_SIZE)
		blength = BLOCK_SIZE;
...
    sum->blength = blength
}

// match.c
static void hash_search(int f,struct sum_struct *s,
			struct map_struct *buf, OFF_T len) {
...
	k = (int32)MIN(len, (OFF_T)s->blength);
	map = (schar *)map_ptr(buf, 0, k);
	sum = get_checksum1((char *)map, k);
...
	/* also make sure the two blocks are the same length */
	l = (int32)MIN((OFF_T)s->blength, len-offset);
	if (l != s->sums[i].len)
		continue;

	if (DEBUG_GTE(DELTASUM, 3)) {
		rprintf(FINFO,
			"potential match at %s i=%ld sum=%08x\n",
			big_num(offset), (long)i, sum);
	}

	if (!done_csum2) {
		map = (schar *)map_ptr(buf,offset,l);
		get_checksum2((char *)map,l,sum2);
		done_csum2 = 1;
	}
...
}

検証に利用するハッシュ長(s2length)

毎回16文字もハッシュを比較するのは非効率なので、ブロックサイズに合わせて比較に利用するハッシュの長さを変えます。基本的にはブロックサイズが大きいほど利用するハッシュの長さも大きくなります。generator.c#L730-L742, match.c#L235

// generator.c
static void sum_sizes_sqroot(struct sum_struct *sum, int64 len)
{
	int32 blength;
	int s2length;
	int64 l;
...
	if (protocol_version < 27) {
		s2length = csum_length;
	} else if (csum_length == SUM_LENGTH) {
		s2length = SUM_LENGTH;
	} else {
		int32 c;
		int b = BLOCKSUM_BIAS;
		for (l = len; l >>= 1; b += 2) {}
		for (c = blength; (c >>= 1) && b; b--) {}
		/* add a bit, subtract rollsum, round up. */
		s2length = (b + 1 - 32 + 7) / 8; /* --optimize in compiler-- */
		s2length = MAX(s2length, csum_length);
		s2length = MIN(s2length, SUM_LENGTH);
	}

	sum->flength	= len;
	sum->blength	= blength;
	sum->s2length	= s2length;
...
}

// match.c
static void hash_search(int f,struct sum_struct *s,
			struct map_struct *buf, OFF_T len)
{
...
    if (memcmp(sum2,s->sums[i].sum2,s->s2length) != 0) {
	    false_alarms++;
		continue;
...
}

これらのデータは sum_struct という構造体で管理されます。rsync.h#L955-L971

// rsync.h
#define SUM_LENGTH 16
struct sum_buf {
	OFF_T offset;		/**< offset in file of this chunk */
	int32 len;		/**< length of chunk of file */
	uint32 sum1;	        /**< simple checksum */
	int32 chain;		/**< next hash-table collision */
	short flags;		/**< flag bits */
	char sum2[SUM_LENGTH];	/**< checksum  */
};

struct sum_struct {
	OFF_T flength;		/**< total file length */
	struct sum_buf *sums;	/**< points to info for each chunk */
	int32 count;		/**< how many chunks */
	int32 blength;		/**< block_length */
	int32 remainder;	/**< flength % block_length */
	int s2length;		/**< sum2_length */
};

送信側はファイルサイズからブロックサイズとハッシュ長を計算し、sum_struct を送信します。io.c#L1964-L2008

// io.c
/* Send the values from a sum_struct over the socket.  Set sum to
 * NULL if there are no checksums to send.  This is called by both
 * the generator and the sender. */
void write_sum_head(int f, struct sum_struct *sum)
{
	static struct sum_struct null_sum;

	if (sum == NULL)
		sum = &null_sum;

	write_int(f, sum->count);
	write_int(f, sum->blength);
	if (protocol_version >= 27)
		write_int(f, sum->s2length);
	write_int(f, sum->remainder);
}

/* Populate a sum_struct with values from the socket.  This is
 * called by both the sender and the receiver. */
void read_sum_head(int f, struct sum_struct *sum)
{
	int32 max_blength = protocol_version < 30 ? OLD_MAX_BLOCK_SIZE : MAX_BLOCK_SIZE;
	sum->count = read_int(f);
	if (sum->count < 0) {
		rprintf(FERROR, "Invalid checksum count %ld [%s]\n",
			(long)sum->count, who_am_i());
		exit_cleanup(RERR_PROTOCOL);
	}
	sum->blength = read_int(f);
	if (sum->blength < 0 || sum->blength > max_blength) {
		rprintf(FERROR, "Invalid block length %ld [%s]\n",
			(long)sum->blength, who_am_i());
		exit_cleanup(RERR_PROTOCOL);
	}
	sum->s2length = protocol_version < 27 ? csum_length : (int)read_int(f);
	if (sum->s2length < 0 || sum->s2length > MAX_DIGEST_LEN) {
		rprintf(FERROR, "Invalid checksum length %d [%s]\n",
			sum->s2length, who_am_i());
		exit_cleanup(RERR_PROTOCOL);
	}
	sum->remainder = read_int(f);
	if (sum->remainder < 0 || sum->remainder > sum->blength) {
		rprintf(FERROR, "Invalid remainder length %ld [%s]\n",
			(long)sum->remainder, who_am_i());
		exit_cleanup(RERR_PROTOCOL);
	}
}

検証に利用可能なハッシュの追加

ここからが本題です。
v3.2.7 では、下記のコミットにて検証に利用可能なハッシュが追加されました。
https://github.com/RsyncProject/rsync/commit/ae16850

今まではMD5のみだったのが、SHA1/256/512を使えるようになりました。lib/md-defines.h#L4-L21

/* These allow something like CFLAGS=-DDISABLE_SHA512_DIGEST */
#ifdef DISABLE_SHA256_DIGEST
#undef SHA256_DIGEST_LENGTH
#endif
#ifdef DISABLE_SHA512_DIGEST
#undef SHA512_DIGEST_LENGTH
#endif

#define MD4_DIGEST_LEN 16
#define MD5_DIGEST_LEN 16
#if defined SHA512_DIGEST_LENGTH
#define MAX_DIGEST_LEN SHA512_DIGEST_LENGTH
#elif defined SHA256_DIGEST_LENGTH
#define MAX_DIGEST_LEN SHA256_DIGEST_LENGTH
#elif defined SHA_DIGEST_LENGTH
#define MAX_DIGEST_LEN SHA_DIGEST_LENGTH
#else
#define MAX_DIGEST_LEN MD5_DIGEST_LEN
#endif

これにより MAX_DIGEST_LEN は16から最大64まで拡張されました。
SHA512_DIGEST_LENGTH などは openssl/sha.h にて定義されています。openssl/sha.h#L84-L88

# define SHA_DIGEST_LENGTH 20
# define SHA256_192_DIGEST_LENGTH 24
# define SHA224_DIGEST_LENGTH    28
# define SHA256_DIGEST_LENGTH    32
# define SHA384_DIGEST_LENGTH    48
# define SHA512_DIGEST_LENGTH    64

一方で rsync.h にあるハッシュ値を格納する変数 sum2 のサイズについては修正がされませんでした。

// rsync.h
#define SUM_LENGTH 16
struct sum_buf {
	OFF_T offset;		/**< offset in file of this chunk */
	int32 len;		/**< length of chunk of file */
	uint32 sum1;	        /**< simple checksum */
	int32 chain;		/**< next hash-table collision */
	short flags;		/**< flag bits */
	char sum2[SUM_LENGTH];	/**< checksum  */
};

こうやって振り返ると、同じ数字になるべき SUM_LENGTHMAX_DIGEST_LEN で定義が散らばったのが問題だったと思います(コミットを追った感じ30年前の話になってしまいますが…)
https://github.com/RsyncProject/rsync/commit/ebb0a6f

sum_struct を受け取る際に数値をチェックしているのですが、 MAX_DIGEST_LEN を利用しています。

void read_sum_head(int f, struct sum_struct *sum)
{
...
	sum->s2length = protocol_version < 27 ? csum_length : (int)read_int(f);
	if (sum->s2length < 0 || sum->s2length > MAX_DIGEST_LEN) {
		rprintf(FERROR, "Invalid checksum length %d [%s]\n",
			sum->s2length, who_am_i());
		exit_cleanup(RERR_PROTOCOL);
	}
...
}

そのため、ハッシュを格納する配列 sum2=16 のまま s2length=64 という状況が成立してしまいます。

ハッシュ格納配列のメモリ破壊

read_sum_head() で送信側から sum_struct 構造体を受け取ったあと、ブロックの個数分 sum_buf 構造体を配列で確保します。
その後、ブロックの個数分 read_buf() で送信側からハッシュ値を受け取りますが、この際の書き込み先は 16バイトの sum2 です。一方で書き込み量は s2length(最大64バイト)ということで、over writeが発生し、配列から後続の最大48バイト分メモリが破壊されます。
sender.c#L100

// sender.c
static struct sum_struct *receive_sums(int f)
{
	struct sum_struct *s = new(struct sum_struct);
...
	read_sum_head(f, 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); // *over write
...
}

// rsync.h
#define SUM_LENGTH 16
struct sum_buf {
	OFF_T offset;		/**< offset in file of this chunk */
	int32 len;		/**< length of chunk of file */
	uint32 sum1;	        /**< simple checksum */
	int32 chain;		/**< next hash-table collision */
	short flags;		/**< flag bits */
	char sum2[SUM_LENGTH];	/**< checksum  */
};
struct sum_struct {
	OFF_T flength;		/**< total file length */
	struct sum_buf *sums;	/**< points to info for each chunk */
	int32 count;		/**< how many chunks */
	int32 blength;		/**< block_length */
	int32 remainder;	/**< flength % block_length */
	int s2length;		/**< sum2_length */
};

影響の範囲

確認手段

基本的に v3.2.7 以前のバージョンはSHA1/256/512を利用できないので影響はありません。

v3.2.7 以前でも該当機能をバックポートされた場合は影響がありますし、v3.2.7~v3.3.0でもビルド時に CFLAGS=-DDISABLE_SHA512_DIGEST などでSHAを無効化してたら影響はありません。

最も確実なのは rsync –version で Daemon auth list に SHAが入っているか確認することです。checksum.c#L71-L88, usage.c#L290

// checksum.c
struct name_num_item valid_auth_checksums_items[] = {
#ifdef SHA512_DIGEST_LENGTH
	{ CSUM_SHA512, NNI_EVP, "sha512", NULL },
#endif
#ifdef SHA256_DIGEST_LENGTH
	{ CSUM_SHA256, NNI_EVP, "sha256", NULL },
#endif
#ifdef SHA_DIGEST_LENGTH
	{ CSUM_SHA1, NNI_EVP, "sha1", NULL },
#endif
	{ CSUM_MD5, NNI_BUILTIN|NNI_EVP, "md5", NULL },
	{ CSUM_MD4, NNI_BUILTIN|NNI_EVP, "md4", NULL },
	{ 0, 0, NULL, NULL }
};
struct name_num_obj valid_auth_checksums = {
	"daemon auth checksum", NULL, 0, 0, valid_auth_checksums_items
};

// usage.c
void print_rsync_version(enum logcode f)
{
	char copyright[] = "(C) 1996-" LATEST_YEAR " by Andrew Tridgell, Wayne Davison, and others.";
	char url[] = "https://rsync.samba.org/";
	BOOL first_line = 1;
...
	output_nno_list(f, "Daemon auth list", &valid_auth_checksums);
}

エクスプロイトについて

記事作成時点 (2025/1/21) では確認できていません。

メモリ破壊は主にヒープ破壊とスタック破壊の2種類があり、今回の脆弱性は前者に分類されます。
スタックは上位アドレスから下位アドレスに伸びるので、over writeした場合はほぼ確実に影響が出ます。一方でヒープは下位アドレスから上位アドレスに伸びるため、破壊した領域が未使用という可能性があります。

今回破壊された領域は、mallocで確保した配列の最後のアドレスから最大48バイト分となっています。そして該当部分はそのファイルの転送が終わればfreeされます。
要するにファイルの転送が終わる前に新しくmallocを発行した場合、その領域の先頭部分が破壊される恐れがあるということです。(mallocのアドレスはアラインメントされているので被害は48バイト未満に収まる可能性が高いです、また破壊後に正常データで上書きされる可能性もあります)

修正内容

ハッシュを格納する配列が、mallocによる動的確保に変更されました。
https://github.com/RsyncProject/rsync/commit/0902b52

関連したCVE

連番として CVE-2024-12085 が存在します。
https://access.redhat.com/security/cve/cve-2024-12085

こちらはover writeによるメモリ破壊ではなく、over readによるメモリのデータがリークされるという内容です。match.c#L235

static void hash_search(int f,struct sum_struct *s,
			struct map_struct *buf, OFF_T len)
{
	OFF_T offset, aligned_offset, end;
	int32 k, want_i, aligned_i, backup;
	char sum2[MAX_DIGEST_LEN];
...
			if (memcmp(sum2,s->sums[i].sum2,s->s2length) != 0) { // *over read
				false_alarms++;
				continue;
			}
...
}

教訓

今回のCVEが発生した流れとしては

1. ハッシュはMD5しか使わないし配列は16バイト固定でいいよね
2. 比較に利用するハッシュ長を可変にするため、変数化しよう
3. SHA1/256/512を追加するには、この変数を変えれば良さそうだ

直接的には3の部分が脆弱性に繋がったわけですが
・そもそも MAX_DIGEST_LEN と SUM_LENGTH で定義を分けない
・1の時点で、書き込み前に配列サイズとの比較を行う
・2の時点で、格納配列を可変にする
など事前に防げた可能性もあったと思います。

もっとも、メモリセーフな言語を使うのが一番の対策だと思いますが…

参照リンク

  1. 700×700以上のファイルに関しては、ファイルサイズの平方根がブロックサイズになります。generator.c#L706-L723 ↩︎
  2. なぜ700?という疑問に対しては、500~1000が最もパフォーマンスが良かった とのこと。 ↩︎

シェアする

  • このエントリーをはてなブックマークに追加

フォローする