How my minimal, memory-safe Go rsync steers clear of vulnerabilities

Michael Stapelberg

我的极简、内存安全 Go rsync 如何避开漏洞

2025 年 1 月,多位不同的安全研究人员总共发布了 rsync 中的 6 个安全漏洞,其中一些漏洞允许任意代码执行和文件泄露,所以我自然想知道,我的 gokrazy/rsync 实现是否受到了影响。在 Go 这种现代且内存安全的编程语言中实现一个自己的(兼容但极简的)rsync,真的能彻底排除某些类别的安全漏洞吗?

这篇深度解析文章从 2025 年 1 月就开始准备,但由于我们在过程中发现了更多尚未公开的漏洞,发布时间有所推迟!“安全漏洞”一节现在涵盖 2025 年 1 月批次和 2026 年 5 月批次的全部 12 个漏洞。

如果你在生产环境中运行(上游的、samba 项目中的)rsync,请升级到 3.4.3 或更高版本。

如果你在生产环境中运行 gokrazy/rsync,请升级到 v0.3.3 或更高版本。

你可以跳过安全问题的细节,直接阅读:

背景:我自己的 rsync

作为背景介绍,早在 2022 年 6 月,我就写过一篇关于 rsync、我的使用方式以及它的工作原理的博客文章。另请参阅所有带有“rsync”标签的文章

当初编写自己的 rsync(那时只有服务器端,如今支持所有方向)的最初动机,是为 distri——我为实现快速包管理而进行的 Linux 发行版研究项目提供软件包。我希望把这些软件包托管在 router7 上;router7 是我家中使用的小型 Linux+Go 互联网路由器,而它又构建在 gokrazy(我的 Go appliance 平台)之上。

我至今仍在为这一最初目的,以及许多其他用途,运行着多个 gokrazy/rsync 服务器!把 rsync 作为一种原语(可以链接进你的 Go 程序中)使用,确实非常方便。

安全漏洞

本文涵盖以下安全漏洞:

  • CVE-2024-12084 至 12088(原始报告
  • CVE-2024-12747(由 Aleksei Gorban “loqpa” 单独发现)
  • CVE-2026-29518(由 Damien Neil 和我发现!同时也由 Nullx3D 独立发现)
  • CVE-2026-43617 至 43620
  • CVE-2026-45232

上述漏洞中的第一批发布在 oss-security 邮件列表上,但请注意,原始报告比 oss-security 的摘要包含更多细节!

后续漏洞则通过 rsync 项目的 GitHub Security Advisories 发布。

2025 年 1 月批次

CVE-2024-12084:堆缓冲区溢出(9.8)

摘要:

  • rsync 的验证不足:它从网络读取由攻击者控制的校验和长度,并将该长度与 MAX_DIGEST_LEN 比较。
  • 然而,rsync 的数据结构始终声明了一个 16 字节的缓冲区:char sum2[SUM_LENGTH]
    • SUM_LENGTH 始终为 16(字节),足以容纳 MD4MD5 校验和。
    • MAX_DIGEST_LEN 过去是 16(字节),但在编译 rsync 并启用 SHA256 或 SHA512 校验和支持时可能更大。
  • 因此,边界检查没有起到作用!攻击者可以越界写入。
  • 该问题由 2022 年 9 月的提交 ae16850 引入,该提交增加了 SHA256/SHA512 校验和支持。
点击展开校验和长度验证不当的完整说明(引自 Google 安全报告

守护进程读取校验和时,会读取两种不同的校验和:

  1. 32 位 Adler-CRC32 校验和
  2. 文件块的摘要。摘要算法在协议协商开始时确定。相关代码如下:sender.c
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);

最重要的是,注意 sum2 字段会被填入 s->s2length 个字节。sum2 的大小始终为 16: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  */
};

s2length 是由攻击者控制的值,最大可以达到 MAX_DIGEST_LEN 字节,如下面的代码片段所示:

io.c

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);
}

问题在于,根据二进制程序编译时支持的摘要算法,MAX_DIGEST_LEN 可能大于 16 字节:

md-defines.h

#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 /* 16 bytes */
#endif

SHA256 支持很常见,会将 MAX_DIGEST_LENGTH 的值设为 64。因此,攻击者最多可以在 sum2 缓冲区边界之后再写入 48 个字节。

上游修复:

针对 CVE-2024-12084 的上游修复sum2 字段改为动态分配的 sum2_array 字段,按 xfer_sum_len 长度分配,并将边界检查改为与 xfer_sum_len(本次传输所用算法的校验和长度)比较。

Go 能帮助防止这个问题吗?

能:在 Go 中,缺失或错误的边界检查不会导致堆缓冲区溢出!相反,尝试越界写入会触发 panic,因为 Go runtime 会执行边界检查。

gokrazy/rsync 的情况如何?

gokrazy/rsync 同样存在验证不足!不过,我们的问题不同:不是大小混淆,而是我们完全没有验证 sum header——哎呀!

我们可以通过如下修改代码并运行测试,确认 Go runtime 的边界检查会在尝试越界写入时触发:

diff --git i/types.go w/types.go
index 5601697..899fcb8 100644
--- i/types.go
+++ w/types.go
@@ -59,7 +59,7 @@ func (sh *SumHead) WriteTo(c *rsyncwire.Conn) error {
 	var buf rsyncwire.Buffer
 	buf.WriteInt32(sh.ChecksumCount)
 	buf.WriteInt32(sh.BlockLength)
-	buf.WriteInt32(sh.ChecksumLength)
+	buf.WriteInt32(512 /*sh.ChecksumLength*/)
 	buf.WriteInt32(sh.RemainderLength)
 	return c.WriteString(buf.String())
 }

正如预期,Go runtime panic,并显示以下信息:

panic: runtime error: slice bounds out of range [:512] with length 16

goroutine 277 [running]:
github.com/gokrazy/rsync/rsyncd.(*sendTransfer).receiveSums(0xc0000d7b68)
	/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/sender.go:136 +0x339
github.com/gokrazy/rsync/rsyncd.(*sendTransfer).sendFiles(0xc0000d7b68, 0xc000120820)
	/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/sender.go:46 +0x134
github.com/gokrazy/rsync/rsyncd.(*Server).handleConnSender(0xc000476090, {{0x95ed9b, 0x7}, {0xc000426810, 0x2a}, {0x0, 0x0, 0x0}}, {0xa2a120, 0xc0000b2ba0}, ...)
	/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:397 +0x26a
github.com/gokrazy/rsync/rsyncd.(*Server).HandleConn(0xc000476090, {{0x95ed9b, 0x7}, {0xc000426810, 0x2a}, {0x0, 0x0, 0x0}}, {0xa2a120, 0xc0000b2ba0}, ...)
	/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:351 +0x37a
github.com/gokrazy/rsync/rsyncd.(*Server).HandleDaemonConn(0xc000476090, {0x94db80?, 0xc00018a040?}, {0x7fd15838b118, 0xc000428028}, {0xa2bd90, 0xc0002303c0})
	/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:307 +0xdbb
github.com/gokrazy/rsync/rsyncd.(*Server).Serve.func2()
	/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:450 +0xaf
created by github.com/gokrazy/rsync/rsyncd.(*Server).Serve in goroutine 260
	/home/michael/go/src/github.com/gokrazy/rsync/rsyncd/rsyncd.go:448 +0xd2

当然,让整个服务器崩溃并不是最理想的失败处理方式,所以我补上了缺失的边界检查,将 panic 转换为错误

CVE-2024-12085:栈信息泄露攻破 ASLR(7.5)

摘要:

由于存在与前一个 CVE-2024-12084 漏洞相同的验证缺失,攻击者可以选择校验和较短的算法(例如使用 8 字节校验和的 xxhash64),却声称自己发送了更长的校验和(例如 9 字节),从而使受害者在响应中泄露 1 个字节的未初始化栈内容。

泄露 1 个字节的栈内容看似无害,但正如 Google 安全报告所说:

前两个漏洞分别是堆缓冲区溢出和信息泄露。两者结合后,客户端可以在运行 Rsync 服务器的机器上执行任意代码。客户端只需要对服务器拥有匿名读取权限。

点击展开信息泄露的完整说明(引自 Google 安全报告

守护进程会在 hash_search() 中,将客户端发送的块校验和与本地文件内容进行匹配。函数序言的一部分会在栈上分配一个大小为 MAX_DIGEST_LEN 的缓冲区:

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 (!done_csum2) {
    map = (schar *)map_ptr(buf, offset, l);
    get_checksum2((char *)map, l, sum2);
    done_csum2 = 1;
}

if (memcmp(sum2, s->sums[i].sum2, s->s2length) != 0) {
    false_alarms++;
    continue;
}

值得注意的是,比较的字节数是 s->s2length。在此情况下,比较不会越界,因为 s->s2length 最大只能达到 MAX_DIGEST_LEN

然而,本地的 sum2 缓冲区(不要与由攻击者控制的 s->sums[i].sum2 混淆)位于栈上,未被清零,因此包含未初始化的栈内容。

恶意客户端可以针对文件的某个块发送一个已知的 xxhash64 校验和,使守护进程向栈缓冲区 sum2 写入 8 个字节。然后,攻击者可以将 s->s2length 设为 9 字节。这样,前 8 个字节会匹配,而攻击者控制的第 9 个字节会与未初始化栈数据中的未知值进行比较。

攻击者可以将文件分成 255 个块,从而每次下载文件泄露 1 个字节。攻击者还可以在同一连接中或通过重置连接逐步重复这一过程。

最终,他们可以泄露 MAX_DIGEST_LEN - 8 个字节的未初始化栈数据,其中可能包含指向堆对象、栈保护值、局部变量、全局变量的指针以及返回指针。借助这些指针,他们可以攻破 ASLR。

上游修复:

有两个相关的上游修复:

Go 能帮助防止这个问题吗?

能:按照设计,Go 会将所有变量初始化为零值。Go 程序员不需要记住显式初始化变量。

gokrazy/rsync 的情况如何?

gokrazy/rsync 不受此漏洞影响:Go 中的变量总会被初始化。

此外,选择 MD4 以外的校验和是在协议版本 30 中才引入的,而 gokrazy/rsync 实现的是协议版本 27。

CVE-2024-12087:利用符号链接进行路径遍历(7.5)

说明:(引自 Google 安全报告

启用符号链接同步后(通过 -l-a--archive)标志),恶意服务器可以让客户端将任意文件写入目标目录之外。恶意服务器可以向客户端发送如下文件列表:

symlink -> /arbitrary/directory
symlink/poc.txt

默认情况下,符号链接可以是绝对路径,也可以包含诸如 ../../ 的字符。

实际上,客户端会验证文件列表;当看到 symlink/poc.txt 条目时,它会查找名为 symlink 的目录,否则就会报错。如果服务器同时将 symlink 作为目录和符号链接发送,客户端只会保留目录条目,因此攻击还需要更多条件才能成功。

在服务器可以为客户端启用的 inc_recurse 模式下,服务器会向客户端发送多个文件列表。条目去重以单个文件列表为单位进行。因此,恶意服务器可以向客户端发送多个文件列表,例如:

# file list 1:
.
./symlink (directory)
./symlink/poc.txt (regular file)

# file list 2:
./symlink -> /arbitrary/path (symlink)

这样,symlink 目录会先被创建,symlink/poc.txt 也会被视为文件列表中的有效条目。随后,攻击者将 symlink 的类型改为符号链接。

当服务器随后指示客户端创建 symlink/poc.txt 文件时,客户端会跟随该符号链接,因此可以在目标目录之外创建文件。

Go 能帮助防止这个问题吗?

不能。此漏洞由逻辑错误造成:使用多个文件列表时,需要重新验证合并后的文件列表。

但请参阅纵深防御:Go 的 os.Root

上游修复:

针对 CVE-2024-12087 的上游修复补上了缺失的验证。

gokrazy/rsync 的情况如何?

gokrazy/rsync 不受此漏洞影响:gokrazy/rsync 没有实现增量递归模式(--inc-recursive)。

这里的取舍是实现复杂度与资源使用之间的权衡:增量递归模式允许以“窗口化”的方式处理文件集,而不必在传输开始前扫描整个文件集。另请参阅我的博客文章 rsync 是如何工作的?

CVE-2024-12088:绕过 --safe-links(7.5)

说明:(引自 Google 安全报告

--safe-links CLI 标志会让客户端验证从服务器接收的所有符号链接。预期行为是,符号链接目标只能满足以下条件:1)相对于目标目录;2)绝不指向目标目录之外。

unsafe_symlink() 函数负责验证这些符号链接。该函数会计算符号链接目标相对于其在目标目录中所在位置的遍历深度。

例如,下面的符号链接会被视为不安全:

{DESTINATION}/foo -> ../../

因为它指向目标目录之外。另一方面,下面的符号链接被视为安全,因为它仍然指向目标目录内部:

{DESTINATION}/foo -> a/b/c/d/e/f/../../

如果符号链接目标路径中包含其他符号链接,该函数不会考虑这一点,因此可以绕过验证。例如,考虑以下两个符号链接:

{DESTINATION}/a -> . {DESTINATION}/foo -> a/a/a/a/a/a/../../

在这种情况下,foo 实际上会指向目标目录之外。然而,unsafe_symlink() 函数假定 a/ 是目录,并认为该符号链接是安全的。

上游修复:

针对 CVE-2024-12088 的上游修复使 unsafe_symlink() 更加严格:除非位于路径最开头,否则不允许路径中的任何位置出现 ../

Go 能帮助防止这个问题吗?

不能。此漏洞由逻辑错误造成:验证函数本身不正确。我们完全可能实现出同样的 bug。

但请参阅纵深防御:Go 的 os.Root

gokrazy/rsync 的情况如何?

gokrazy/rsync 不存在此漏洞:gokrazy/rsync 尚未实现 --safe-links 功能。

CVE-2024-12086:任意文件泄露(6.8)

摘要:

rsync 接收端(客户端模式)没有清理 rsync 发送端提供的文件名,也没有以其他方式阻止打开目标目录树之外的文件。恶意发送端可以指示接收端比较目标目录树之外任意文件的校验和。恶意发送端通过观察接收端对所提供的单字节校验和的反应,就能泄露任意文件。

点击展开文件泄露的完整说明(引自 Google 安全报告

当客户端连接到恶意服务器时,服务器可以泄露客户端机器上任意文件的内容。在 read_ndx_and_attrs() 中,如果服务器设置了相应标志,客户端会从服务器读取 fnamecmp 类型以及 xname。客户端不会设置 sanitize_paths 标志。

if (iflags & ITEM_BASIS_TYPE_FOLLOWS)
    fnamecmp_type = read_byte(f_in);
*type_ptr = fnamecmp_type;

if (iflags & ITEM_XNAME_FOLLOWS) {
    if ((len = read_vstring(f_in, xname, MAXPATHLEN)) < 0)
        exit_cleanup(RERR_PROTOCOL);

    if (sanitize_paths) { /* not enabled when client receives */
        sanitize_path(xname, xname, "", 0, SP_DEFAULT);
        len = strlen(buf);
    }
} else {
    *buf = '\0';
    len = -1;
}
*len_ptr = len;

调用方(recv_files())随后使用服务器提供的值,确定要与传入数据进行比较的文件。

    case FNAMECMP_FUZZY:
    if (file->dirname) {
        pathjoin(fnamecmpbuf, sizeof fnamecmpbuf, file->dirname, xname);
        fnamecmp = fnamecmpbuf;
    } else
        fnamecmp = xname;
        break;
…
fd1 = do_open(fnamecmp, O_RDONLY, 0);

receive_data() 中,xname 指定文件的内容会被复制到目标文件。服务器可以通过发送负 token 来实现这一点。

while ((i = recv_token(f_in, &data)) != 0) {
..snip..
    if (i > 0) {
..snip..
    }
..snip..
    if (fd != -1 && map && write_file(fd, 0, offset, map, len) != (int)len)

服务器发送一个校验和进行比较。如果两者不匹配,就会返回 0。

if (fd != -1 && memcmp(file_sum1, sender_file_sum, xfer_sum_len) != 0)
    return 0;

返回值为 0 时,接收端会向生成器发送 MSG_REDO。生成器随后会向服务器写入一条消息。

服务器可以将此作为信号,判断它发送的校验和是否正确。恶意服务器从 blength 为 1 开始,便能逐字节确定目标文件的内容。

上游修复:

针对 CVE-2024-12086 的上游修复通过验证发送端提供的路径,阻止打开目标目录树之外的文件。

Go 能帮助防止这个问题吗?

能,Go 提供了防止此问题的 API,参见纵深防御:Go 的 os.Root

gokrazy/rsync 的情况如何?

gokrazy/rsync 不存在此漏洞:模糊匹配功能是在 rsync 协议版本 29 中引入的,而 gokrazy/rsync 实现的是协议版本 27。

CVE-2024-12747:符号链接竞争条件(5.6)

说明:(引自 Red Hat 安全公告

rsync 中存在一个缺陷。该漏洞源于 rsync 处理符号链接时的竞争条件。rsync 遇到符号链接时的默认行为是跳过它们。如果攻击者在恰当时机将普通文件替换为符号链接,就可能绕过默认行为并遍历符号链接。根据 rsync 进程所拥有的权限,攻击者可能泄露敏感信息,并可能进一步提升权限。

上游修复:

针对 CVE-2024-12747 的上游修复将 rsync 发送端中的 open() 调用改为使用 O_NOFOLLOW 选项。按照当时算法的流程,这些路径不应是符号链接(符号链接会通过 readlink(2) 处理)。

Go 能帮助防止这个问题吗?

能,Go 提供了防止此问题的 API,参见纵深防御:Go 的 os.Root

gokrazy/rsync 的情况如何?

提交 1b1fbf6之前,gokrazy/rsync 存在漏洞;该提交引入了与上游 rsync 相同的 O_NOFOLLOW 缓解措施。

点击展开触发 gokrazy/rsync 问题的复现步骤

要复现该问题,请执行以下步骤:

  1. 检出 gokrazy/rsync v0.2.7:

    git clone https://github.com/gokrazy/rsync
    cd rsync
    git checkout v0.2.7
    
  2. 按如下方式修改代码,撤销修复并执行攻击:

    diff --git i/internal/nofollow/nofollow_unix.go w/internal/nofollow/nofollow_unix.go
    --- i/internal/nofollow/nofollow_unix.go
    +++ w/internal/nofollow/nofollow_unix.go
    @@ -2,8 +2,6 @@
     
     package nofollow
     
    -import "golang.org/x/sys/unix"
    -
     // Maybe resolves to unix.O_NOFOLLOW on unix systems,
     // 0 on other platforms.
    -const Maybe = unix.O_NOFOLLOW
    +const Maybe = 0 // unix.O_NOFOLLOW
     
     diff --git i/internal/sender/do.go w/internal/sender/do.go
    --- i/internal/sender/do.go
    +++ w/internal/sender/do.go
    @@ -2,6 +2,8 @@ package sender
     
     import (
     	"fmt"
    +	"os"
    +	"path/filepath"
     	"sort"
     
     	"github.com/gokrazy/rsync/internal/log"
    @@ -55,6 +57,15 @@ func (st *Transfer) Do(crd *rsyncwire.CountingReader, cwr *rsyncwire.CountingWri
     		st.Logger.Printf("file list sent")
     	}
     
    +	// HACK: swap out the passwd file with a symlink to /etc/passwd
    +	if err := os.Remove(filepath.Join(modPath, "passwd")); err != nil {
    +		return nil, err
    +	}
    +	if err := os.Symlink("../passwd", filepath.Join(modPath, "passwd")); err != nil {
    +		return nil, err
    +	}
    +	st.Logger.Printf("HACK: swapped passwd file for symlink")
    +
     	// Sort the file list. The client sorts, so we need to sort, too (in the
     	// same way!), otherwise our indices do not match what the client will
     	// request.
    

现在运行 TestReceiverSymlinkTraversal 测试,可以看到服务器遍历了符号链接:

    receiver_test.go:371: unexpected file contents: diff (-want +got):
          bytes.Join({
        - 	"benign",
        + 	"secret",
          }, "")

一个令人意外的发现

我把本文草稿分享给 Go Security Team 成员、也是抗路径遍历的 os.Root API作者的 Damien Neil(达米恩·尼尔)后,他指出:

我认为 gokrazy 针对 CVE-2024-12747 的修复并不充分。你在调用带有 O_NOFOLLOWos.Open,但 O_NOFOLLOW 只能阻止路径最后一个组成部分的符号链接遍历。

通过替换更早的路径组成部分,仍然可能存在漏洞。例如,将 dir 链接到 /etc,就可以把 os.Open("dir/passwd") 重定向到其他位置。

我们于 2025 年 4 月向 rsync 的安全联系地址报告了此问题。2025 年 12 月,我得知另一个人也独立发现并报告了这个问题。

最终,该问题形成了 CVE-2026-29518,并于 2026-05-20 发布。

2026 年 5 月批次

CVE-2026-29518:符号链接竞争条件(7.0)

说明:(引自 rsync 3.4.3 NEWS 条目

TOCTOU 符号链接竞争条件:在未启用 chroot 的守护进程模式下允许本地权限提升。

配置了 use chroot = no 的 rsync 守护进程,在父路径组成部分上暴露于检查时与使用时之间的竞争条件。本地攻击者只要拥有模块的写入权限,就可以在接收端检查与调用 open() 之间将父目录组成部分替换为符号链接,从而把读取(基础文件泄露)和写入(文件覆盖)重定向到模块之外。在守护进程以提升的权限运行时,这可以导致权限提升。

默认的 use chroot = yes 不受影响。

影响条件:守护进程主机上的本地攻击者、对模块路径拥有写入权限、守护进程配置为 use chroot = no

上游修复:

针对 CVE-2026-29518 的上游修复使用了 secure_relative_open(),它与 Go 的 os.Root API 类似。

Go 能帮助防止这个问题吗?

能,Go 提供了防止此问题的 API,参见纵深防御:Go 的 os.Root

gokrazy/rsync 的情况如何?

gokrazy/rsync 曾存在漏洞,直到我将发送端接收端切换到抗路径遍历的 os.Root API

CVE-2026-43618:整数溢出泄露远程内存(8.1)

说明:(引自 GitHub Security Advisory

接收端的压缩 token 解码器累加了一个 32 位有符号计数器,却没有进行溢出检查。恶意发送端可以触发溢出,并通过精心操控,使进程内存内容泄露给攻击者——包括环境变量、密码、堆指针和库指针——从而显著削弱 ASLR 并助力进一步利用。

影响条件:启用压缩的、经过身份验证的守护进程连接(当双方都声明支持压缩时,对于协议版本 >= 30,这是默认设置)。可用的临时解决办法是在守护进程中禁用压缩(在 rsyncd.conf 中设置“refuse options = compress”)。

上游修复:

针对 CVE-2026-43618 的上游修复补上了缺失的检查。

gokrazy/rsync 的情况如何?

gokrazy/rsync 不存在此漏洞,因为它没有实现压缩。有关压缩支持看似简单、实则并不简单的原因,请参阅 gokrazy/rsync issue #35

CVE-2026-43620:越界读取后的 DOS(6.5)

说明:(引自 GitHub Security Advisory

2025 年修复 send_files() 时加入的 parent_ndx<0 保护,没有应用到 recv_files() 中视觉上完全相同的代码块。恶意 rsync 服务器可以通过在兼容性标志中设置 CF_INC_RECURSE,发送一个排序后的第一项不是以“.”开头的目录的 flist(这会使 recv_file_list()parent_ndx 设为 -1),再发送一个 ndx=0 且 iflag 字不为 ITEM_TRANSFER 的传输记录,让任何连接的客户端确定性地触发 SIGSEGV。接收端读取 dir_flist->files[-1] 并对结果解引用。在 glibc x86-64 上,被解引用的指针是 mmap 块元数据,落在未映射地址,因此会产生明确的 SEGV_MAPERR;非 glibc 分配器尚未经过审计。

影响范围:任何从攻击者控制的 URL 执行正常拉取的 rsync 客户端。rsync:// URL 和远程 shell 拉取均受影响。inc_recurse 是协议 30 及更高版本的默认设置;受害者无需设置特殊选项。

临时解决办法:在客户端使用 --no-inc-recursive

上游修复:

针对 CVE-2026-43620 的上游修复也将 parent_ndx<0 保护加入了 recv_files()

gokrazy/rsync 的情况如何?

CVE-2024-12087 一样,gokrazy/rsync 不受此漏洞影响:gokrazy/rsync 没有实现增量递归模式(--inc-recursive)。

CVE-2026-43619:更多符号链接竞争条件(6.3)

说明:(引自 GitHub Security Advisory

接收端 open() 调用中的早期符号链接竞争修复(CVE-2026-29518)遗漏了其他所有基于路径的系统调用中同类的竞争条件:chmod、lchown、utimes、rename、unlink、mkdir、symlink、mknod、link、rmdir、lstat。在配置了“use chroot = no”的 rsync 守护进程上,本地攻击者只要拥有守护进程主机上的文件系统访问权限,就可以在接收端检查与这些系统调用之间,将符号链接替换进父目录组成部分,从而把操作重定向到导出模块之外。修复方案通过在使用 RESOLVE_BENEATH 等价的内核强制隔离机制下打开父目录 dirfd,让每个受影响的基于路径的系统调用都经过该 dirfd 路由(Linux 5.6+ 使用 openat2,FreeBSD 13+ 和 macOS 15+ 使用 O_RESOLVE_BENEATH,其他系统则逐组成部分执行 O_NOFOLLOW 遍历)。默认的“use chroot = yes”不受影响。

影响条件:守护进程主机上的本地攻击者、对模块路径拥有写入权限、守护进程配置为 use chroot = no。

上游修复:

针对 CVE-2026-43619 的上游修复使用了 *at 系列系统调用,就像 Go 的 os.Root 一样。

Go 能帮助防止这个问题吗?

能,Go 提供了防止此问题的 API,参见纵深防御:Go 的 os.Root

gokrazy/rsync 的情况如何?

gokrazy/rsync 不受影响,因为它在所有地方都使用 Go 的 os.Root API。

CVE-2026-43617:主机名/ACL 绕过(4.8)

说明:(引自 GitHub Security Advisory

对于配置了全局 daemon chroot = /X rsyncd.conf 设置的 rsync 守护进程,连接客户端的反向 DNS 查询是在守护进程 chroot 到 /X 之后执行的。如果 /X 不包含解析所需的文件(/etc/resolv.conf/etc/nsswitch.conf/etc/hosts 以及 NSS 服务模块),查询就会失败,连接主机名会被设为“UNKNOWN”。因此,基于主机名的拒绝规则(“hosts deny = *.evil.example”)无法匹配;控制其 PTR 记录的攻击者便可以使用管理员原本打算拒绝的主机名进行连接。基于 IP 的 ACL 不受影响。每个模块的 use chroot 设置与此问题无关。

影响条件:rsync 守护进程配置了 daemon chroot = /X,同时使用基于主机名的 ACL,并且 /X 不包含 libc 解析器所需的文件。

上游修复:

针对 CVE-2026-43617 的上游修复将 DNS 查询移到了协议处理流程中更早的位置。

gokrazy/rsync 的情况如何?

gokrazy/rsync 不存在此漏洞,因为我们只实现基于 IP 的允许/拒绝列表,不实现基于主机名的允许/拒绝列表。

CVE-2026-45232:栈越界写入(3.1)

说明:(引自 GitHub Security Advisory

rsync 客户端的 HTTP CONNECT 代理支持在 establish_proxy_connection()socket.c)中包含一个差一错误导致的栈越界写入。发出 CONNECT 请求后,rsync 会将代理的第一行响应逐字节读入一个 1024 字节的栈缓冲区,并使用 cp < &buffer[sizeof buffer - 1] 作为边界,因此循环最多只会写入 buffer[0..sizeof-2]。如果代理(或位于代理前方的中间人)返回 1023 个或更多字节的第一行响应,却没有 '\n' 终止符,循环退出时 cp == &buffer[sizeof buffer - 1]——这是循环从未写入的位置,因此 *cp 保存的是之前用于格式化传出 CONNECT 请求的 snprintf() 留在那里的过期栈字节。循环后的代码随后执行:

if (*cp != '\n')   /* (*cp is uninitialised stack data) */
    cp++;          /* cp now &buffer[sizeof]: one past end */
*cp-- = '\0';      /* one-byte OOB write on the stack */

'\0' 会落在栈上 buffer[1024] 末尾之后 1 个字节的位置,破坏相邻栈槽中保存的内容。AddressSanitizer 会在 establish_proxy_connection 栈帧的 socket.c:95 处报告 stack-buffer-overflow

上游修复:

针对 CVE-2026-45232 的上游修复验证了攻击者提供的数据。

gokrazy/rsync 的情况如何?

gokrazy/rsync 没有实现这种代理支持,因此不存在此漏洞。

Go 的结论

让我们总结一下 Go 的表现:

  • Go runtime 的边界检查会将更严重的安全问题转化为 panic。
    • panic 仍然存在拒绝服务风险,但这要好得多。
  • Go 会将内存初始化为零,因此 CVE-2024-12085 这类信息泄露不可能发生。
  • Go 的 os.Root API 可以防止其余大多数漏洞。
  • 12 个漏洞中只有一个(CVE-2026-43617)是真正的应用逻辑 bug,无法通过使用 Go 来防止。
CVE 编号原因风险(C)Go 是否有帮助?
2024-12084验证不足堆缓冲区溢出✅ 边界检查(会 panic!)
2024-12085验证不足信息泄露✅ 零初始化
2024-12086缺少验证任意文件泄露os.Root
2024-12087验证不足写入任意文件os.Root
2024-12088验证不足创建任意符号链接os.Root
2024-12747TOCTOU泄露特权文件os.Root
2026-29518TOCTOU泄露特权文件os.Root
2026-43617缺少验证拒绝列表失效❌ 逻辑 bug
2026-43618验证不足信息泄露✅ 边界检查(会 panic!)
2026-43619TOCTOU泄露特权文件os.Root
2026-43620验证不足崩溃(DOS)✅ 边界检查(会 panic!)
2026-45232缺少验证内存写入✅ 边界检查(会 panic!)

gokrazy/rsync 的结论

除了使用 Go 编写之外,gokrazy/rsync 与官方上游 rsync 之间的另一个关键区别是,gokrazy 实现是极简的

  • gokrazy/rsync 没有实现相关功能,例如 --inc-recursive,因此不受许多漏洞影响。
  • 与所有其他兼容线路协议的 rsync 实现一样,gokrazy/rsync 以协议版本 27 为目标,因为更高版本会引入显著的复杂性。
  • 某些值得实现的功能存在很大的阻碍,例如压缩就很棘手,详情请参阅 gokrazy/rsync issue #35

下面看看每个 CVE 发布时,gokrazy/rsync 是否受到影响:

CVE 编号原因gokrazy/rsync 是否实现?gokrazy/rsync 是否受影响?
2024-12084验证不足⚠️ panic
2024-12085验证不足否(proto 30)✅ 不存在漏洞
2024-12086缺少验证否(proto 29)✅ 不存在漏洞
2024-12087验证不足否(inc-rec✅ 不存在漏洞
2024-12088验证不足否(safe-links✅ 不存在漏洞
2024-12747TOCTOU❌ 存在漏洞
2026-29518TOCTOU⚠️ 已修补
2026-43617缺少验证否(主机拒绝列表)✅ 不存在漏洞
2026-43618验证不足否(压缩)✅ 不存在漏洞
2026-43619TOCTOU⚠️ 已修补
2026-43620验证不足否(inc-rec✅ 不存在漏洞
2026-45232缺少验证否(代理)✅ 不存在漏洞

需要明确的是:所有已知漏洞都已在 gokrazy/rsync 中修复!上表记录的是每个 CVE 发布时的状态。换句话说:

2025 年 1 月的漏洞发布时,gokrazy/rsync 会触发 panic(CVE-2024-12084),并且存在 TOCTOU 竞争漏洞(CVE-2024-12747)。在修复 TOCTOU 问题的过程中,我们发现了 CVE-2026-29518,并在该 CVE 发布前就在 gokrazy/rsync 中修复了它。CVE-2026-43619 发现得更晚,但也已经通过同样的修复——在所有地方使用 Go 的 os.Root——在 gokrazy/rsync 中修复。

不够精确的术语

阅读漏洞报告时,我注意到报告所用的措辞有些误导:大多数报告只谈到“服务器”和“客户端”。然而,在 rsync 传输中,rsync 客户端和 rsync 服务器双方都可以扮演任一角色:发送端(上传文件)或接收端(下载文件)!

某些部署还存在进一步的限制,使特定攻击更难实施或根本无法实施。例如,在守护进程模式下运行时,文件系统访问可以限制在预先配置的模块路径内(但命令模式下不能)。

下面这张图展示了 4 种不同的部署方式,以及角色/协议的分层关系:

展示 4 种不同 rsync 部署方式的示意图:(1) rsync --daemon (2) rsync -e ssh;rsync://server/module/dir (3) rsync server:/some/path (4) rsync /src

就我们的漏洞报告而言,我认为任意文件泄露漏洞(CVE-2024-12086)的原始标题“服务器泄露任意客户端文件”很容易造成误解。

我会改成:rsync 接收端会将任意文件泄露给恶意发送端

我已经确认,恶意客户端发送端可以让未修补的远程 rsync 打开目标目录树之外的文件(例如 /etc/shadow 系统密码数据库),只要它以命令模式运行,例如通过 SSH 运行。(但在守护进程模式下,服务器会启用额外的路径清理,从而阻止这类攻击。)

同样,符号链接路径遍历漏洞(CVE-2024-12087)谈的是“恶意服务器”,但准确说应该是“恶意发送端”,而发送端可以是客户端,也可以是服务器。

与 OpenBSD 的 openrsync(C)比较

OpenBSD 项目以重视安全著称,那么 openrsync 的表现如何?

openrsync 不受堆缓冲区溢出(CVE-2024-12084)和栈信息泄露(CVE-2024-12085)影响,因为它会验证校验和长度,并且只支持一种校验和大小/算法(MD4)。

openrsync 不受 CVE-2024-12086、CVE-2024-12087 和 CVE-2024-12088 影响,因为它没有实现相关功能(与 gokrazy/rsync 一样)。即使它存在漏洞,openrsync 的纵深防御措施——例如使用 OpenBSD 的 unveil(2)pledge(2) 限制文件系统访问——也会阻止攻击成功,至少在 OpenBSD 上运行时如此。

openrsync 不受 CVE-2024-12747 影响,因为它从实现符号链接支持之初就使用了 O_NOFOLLOW。但是,由于 O_NOFOLLOW 对这个问题来说并不是充分的修复措施,openrsync 受到 CVE-2026-29518 影响!

以上涵盖了 2025 年 1 月批次的漏洞;2026 年 5 月批次的情况类似,大多数相关功能都没有实现。

总的来说,我要说:Kristaps 和各位贡献者,干得漂亮!通过认真实现验证、限制攻击面并采用纵深防御措施,openrsync 成功避开了几乎所有已报告的漏洞。

纵深防御

在 Linux 上,我们可以使用哪些 API 和环境来实现纵深防御措施?

我将按照从传统到现代的顺序,介绍 gokrazy/rsync 支持的方案。

Linux mount namespace

开始 gokrazy/rsync 项目几周后,我就增加了在 Linux 上丢弃权限以及使用 mount/pid namespace 的支持,以限制我的 rsync 服务器可以操作的文件系统对象。

这种方法对于缓解路径遍历攻击非常有效,但需要权限,也就是说,我们必须以 root 身份运行,或者在 Linux user namespace 中运行(前提是你的发行版/系统启用了它)。

这一限制使 mount namespace 很适合服务器部署,但通常无法用于交互式的一次性传输,因为后者一般以用户账户运行。

systemd 加固

在引入 Linux mount/pid namespace 支持的同一个提交中,我还加入了一个 systemd 服务文件,将文件系统访问限制在 home 目录内,并在 README 中建议大家根据自身使用场景进一步限制文件系统访问。

如果正确设置,这些文件系统限制可以缓解文件泄露(CVE-2024-12086)和路径遍历(CVE-2024-12087)漏洞。

符号链接竞争条件(CVE-2024-12747)依赖通过 rsync 进程提升权限,但得益于 DynamicUser 功能,我们的进程拥有的权限少于其他用户。

与 mount namespace 类似,这些措施非常适合服务器部署,但对于交互式的一次性使用来说,设置起来过于繁琐。

Linux Landlock

我偶然读到 Justine 的博客文章 将 OpenBSD pledge() 移植到 Linux(2022),这让我想起 Linux 提供了 Landlock API,用于执行非特权的、针对每个进程的访问控制,类似于 openrsync 使用的 OpenBSD unveil(2) 系统调用。基本思路是:程序知道自己要操作的目录后,调用类似 unveil("/home/michael/backups", "rw"); 的函数,此后便无法访问其他文件系统位置。

我此前在一次 Go Meetup 上听说过 Landlock,因此知道 Go 支持 Landlock。早在 2022 年,我就在 gokrazy 的 kernel 镜像中启用了 Landlock 支持。

于是我在 2025 年 3 月试了一下,并实现了 Landlock 支持,以限制文件系统访问。这花了我几个小时,似乎比一开始预想的稍长一些。要让 Landlock 在我们的测试环境中正常工作(以及/或者跳过它),遇到了一些障碍:我们的测试定义了许多会在同一进程中运行的函数,但反复添加 ruleset 时,会超过每个进程 16 层的策略限制!

把它恰当地配置好后,这真是一个漂亮的解决方案。现在,即使是非特权调用 gokrazy/rsync,我们也可以将 rsync 传输限制在源目录(只读)或目标目录(读写)内!🎉


Landlock 的缺点是,它在进程级别运行。这意味着 Landlock 策略必须包含程序需要的文件。例如,gokrazy/rsync 需要读取 /etc/passwd 来查询用户 ID,所以如果攻击者的目标是 /etc/passwd 文件,Landlock 就帮不上忙。

Go 的 os.Root

2025 年 2 月发布的 Go 1.24 引入了os.Root API,它能够抵抗路径遍历,参见 Damien Neil(达米恩·尼尔)于 2025 年 3 月撰写的 Go Blog:抗路径遍历的文件 API。与 Landlock 相比,该 API 可以提供更细粒度的控制(精确到每个文件系统操作)。

Go 1.25(2025 年 8 月发布)为 os.Root 增加了更多方法,使其成为大多数文件系统操作的便利选择。

我已经将 gokrazy/rsync 的全部文件系统操作转换为使用 os.Root,这非常合适:用户配置输入/输出目录,但通过网络接收的文件名是不可信的。这正是 os.Root 的设计目标!


我第一次研究使用 os.Root 时,以为某些系统调用本质上无法通过这个 API 执行,例如用于创建设备节点文件的 mknod(2)。Damien 解释说:

不过它不会支持 mknod。

但你应该可以用它来安全地启用 mknod:

如果你想知道实际代码是什么样的,可以查看 gokrazy/rsyncinternal/receiver/generatormknod_linux.go 第 15–29 行中的用法。

另一个障碍是,我意识到与 mknodat(2) 不同,Linux 只实现了 bind(2),却没有实现 bindat(截至 Linux 7.0)!

幸运的是,Lennart Poettering 指出了一种无需 bindat 即可跳过路径解析的技巧:

在此期间,你大概可以绑定到 /proc/self/<fd>/foobar……

事实证明,这确实可行!因为在已知安全的 /proc/self/<fd> 后面,我们只指定了一个 basename(路径的最后一个组成部分),而不是一个路径,所以路径解析会被跳过(参见第 49–56 行)。

有了这两条建议,gokrazy/rsync v0.3.1 及更高版本就完全使用 os.Root 了,这意味着所有文件系统访问都能安全抵抗路径遍历!🥳

结论

缺少验证会导致漏洞

值得注意的是,除了 TOCTOU 漏洞(CVE-2024-12747、CVE-2026-29518 和 CVE-2026-43619)之外,其他所有漏洞都是由缺少或错误的输入验证造成的。其中三个漏洞从一开始就完全没有验证。另一个漏洞(CVE-2024-12088)则是因为文件系统路径解析这个主题足够棘手,现有验证没有覆盖所有边界情况。

正如 Go 的结论一节更详细解释的那样,最有价值的结构性修复,是提供边界检查(即始终启用的验证)以及 Go 的 os.Root 这类默认安全的 API。

复杂度过高

其中一些漏洞源于 rsync 协议的演进:代码原本能够正确执行充分的验证,但后来加入了新功能。例如,加入校验和算法协商功能(协议版本 30)后,验证没有得到正确更新。加入增量递归功能(同样是协议版本 30)后,原本适用于单个文件列表的验证,也没有针对合并增量文件列表这一新的处理方式进行更新。

避免复杂性,就能避免漏洞!gokrazy/rsync 和 openrsync 都有 12 个安全漏洞中的 8 个不会影响它们,原因很简单:它们没有实现存在漏洞的功能。

当然,这些功能之所以被加入 rsync,是因为它们在某些时候对某些人很有价值;当然,我并不是说我们应该就这样永远停止开发软件。

但我认为,理想的做法是使用复杂度与使用场景的复杂度相匹配、并与之成比例的实现。换句话说:对于简单的使用场景,选择简单的实现;只有在确实需要时,才选择功能齐全的实现。

原文由 Michael Stapelberg 发布

本文章由 openai/gpt-5.6-luna 进行翻译