返回 2026-05-25
🔒 安全

极简内存安全的 Go rsync 实现如何规避漏洞How my minimal, memory-safe Go rsync steers clear of vulnerabilities

Michael Stapelberg 分享了一个用 Go 编写的极简、内存安全的 rsync 实现,通过严格校验校验和长度避免了特定漏洞(引用 Google Security 报告)。该方案通过语言特性(Go 的内存安全)和代码设计双重保障,解决了传统 C/C++ 实现的校验和长度验证缺陷。

Michael Stapelberg

  • 背景:我的 rsync
  • 安全漏洞 2025年1月批次 2026年5月批次 裁决结果 gokrazy/rsync 裁决 术语不准确
  • 与 OpenBSD 的 openrsync(C 语言版)对比
  • 纵深防御 Linux 挂载命名空间 systemd 加固 Linux Landlock Go 的 os.Root
  • 结论
  • 回溯到 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 或更高版本。

    你可以直接跳过琐碎的安全问题细节,跳到以下部分:

  • 使用 Go 是否有帮助的裁决结果。
  • 类似 gokrazy/rsync 的精简重实现在帮助方面的裁决结果。
  • 与 OpenBSD 的 openrsync(用 C 语言编写)的对比。
  • 在 Linux 上可用的纵深防御机制。
  • 结论。
  • 背景:我的 rsync

    背景方面,我在 2022 年 6 月曾写过关于 rsync 的博客,介绍如何使用它以及它是如何工作的。也参见所有标记为“rsync”的帖子。

    最初写我自己的 rsync(那时仅作为服务器,如今支持所有方向)的目的是为 distri——我的 Linux 发行版研究项目提供软件包,该项目旨在实现快速包管理。我希望将软件包托管在我的小型家用 Linux+Go 路由器 router7 上,而该路由器又基于我的 Go 设备平台 gokrazy 构建。

    我仍在为这一原始目的以及其他许多用途运行多个 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 摘要包含更多细节!

    后续漏洞通过 GitHub 安全公告在 rsync 项目中发布。

    2025 年 1 月批次

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

    概述:

  • rsync 进行了不充分的验证:它从网络读取(攻击者控制的)校验和长度,并将其与 MAX_DIGEST_LEN 进行比较。
  • 然而,rsync 的数据结构始终声明了一个 16 字节的缓冲区:char sum2[SUM_LENGTH] SUM_LENGTH 始终是 16(字节),足以容纳 MD4 或 MD5 校验和。 MAX_DIGEST_LEN 原本是 16(字节),但当 rsync 编译时启用 SHA256 或 SHA512 校验和时,它可以更大。
  • 因此,边界检查无效!攻击者可越界写入。
  • 此问题由 2022 年 9 月提交 ae16850 引入,该提交添加了 SHA256/SHA512 校验和的支持。
  • 当守护进程读取校验和时,会读取两种不同的校验和: 一个 32 位的 Adler-CRC32 校验和 文件块内容的摘要。摘要算法在协议协商开始时确定。相关代码如下所示: 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; /**< 文件中块的偏移量 */ int32 len; /**< 文件块的长度 */ uint32 sum1; /**< 简单校验和 */ int32 chain; /**< 下一个哈希表碰撞项 */ short flags; /**< 标志位 */ char sum2[SUM_LENGTH]; /**< 校验和 */ }; 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 运行时执行边界检查。

    gokrazy/rsync 的情况如何?

    gokrazy/rsync 也存在验证不足的问题!不过我们的情况不同:并非大小混淆,而是根本没有对 sum 头部进行任何验证——糟糕!

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

    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 运行时输出以下 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 漏洞相同的验证缺失,攻击者可以选择使用短校验和的算法(例如 xxhash64 的 8 字节校验和),但声称发送了更长的校验和(例如 9 字节),导致受害者响应中泄露未初始化的栈内容的一个字节。

    泄露一个字节的内容看似无害,但正如 Google Security 报告所述:

    第一对漏洞是堆缓冲区溢出和信息泄露。当二者结合时,允许客户端在运行 Rsync 服务器的机器上执行任意代码。客户端仅需对服务器具有匿名读取权限。
    守护进程在 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 校验和以某个文件分块,导致守护进程将 8 字节写入栈缓冲区 sum2。攻击者可设置 s->s2length 为 9 字节。这种设置的结果是前 8 字节匹配,而第 9 字节是与未初始化栈数据的未知值进行比较的攻击者控制字节。攻击者可将文件分为 255 个分块,从而每次文件下载泄露一个字节。攻击者可通过相同连接或重置连接逐步重复该过程。最终可泄露 MAX_DIGEST_LEN - 8 字节的未初始化栈数据,这些数据可能包含指向堆对象的指针、栈 Cookie、局部变量以及全局变量和返回指针。通过这些指针,攻击者可绕过 ASLR。

    上游修复:

    有两个相关联的上游修复:

  • “Some checksum buffer fixes” 提交阻止了该攻击,因为攻击者控制的 s->s2length 再也不能大于传输的校验和长度。
  • “prevent information leak off the stack” 提交将 sum2 内存初始化为零,从而使得通过 sum2 的任何栈泄露变得不可能。
  • Go 能否帮助防止此问题?

    是的:Go 的设计将所有变量初始化为零值,Go 程序员无需记得显式初始化变量。

    gokrazy/rsync 的表现如何?

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

    此外,除了 MD4 之外的校验和选择仅在协议版本 30 中引入(gokrazy/rsync 实现的是协议版本 27)。

    CVE-2024-12087:利用符号链接的路径遍历漏洞(7.5)

    描述:(引用 Google Security 报告)

    当通过 -l 或 -a(--archive)标志启用符号链接同步时,恶意服务器可使客户端在目标目录外写入任意文件。恶意服务器可发送如下文件列表给客户端: symlink -> /arbitrary/directory symlink/poc.txt 默认情况下,符号链接可以是绝对路径或包含类似 ../../ 的字符。实践中,客户端会验证文件列表,若遇到 symlink/poc.txt 条目,则会查找名为 symlink 的目录,否则报错。如果服务器将 symlink 同时作为目录和符号链接发送,客户端仅保留目录条目,因此攻击需更多细节才能生效。 在 inc_recurse 模式下(服务器可为客户端启用),服务器会向客户端发送多个文件列表。去重操作基于每个文件列表独立进行。因此,恶意服务器可向客户端发送以下两个文件列表: # 文件列表 1: ./symlink(目录) ./symlink/poc.txt(普通文件) # 文件列表 2: ./symlink -> /arbitrary/path(符号链接) 此时,symlink 目录会先被创建,symlink/poc.txt 被视为有效条目。随后攻击者将 symlink 类型改为符号链接。当服务器指示客户端创建 symlink/poc.txt 文件时,客户端会跟随该符号链接,从而在目标目录外创建文件。

    Go 能否防止此问题?

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

    但纵深防御策略:Go 的 os.Root 模块可提供额外保护。

    上游修复方案:

    CVE-2024-12087 的上游修复补充了缺失的验证逻辑。

    gokrazy/rsync 的表现如何?

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

    这里的权衡是:实现复杂度 vs 资源占用。增量递归模式允许以“窗口化”方式处理文件集,无需在传输前扫描全部文件集。详见我的《rsync 工作原理》博客文章。

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

    描述:(引用 Google Security 报告)

    --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 能否帮助防止这一问题?

    不能。此漏洞由逻辑错误导致:验证函数实现有误。我们本可以复现同样的缺陷。

    但纵深防御:Go 的 os.Root 机制可提供额外保护。

    gokrazy/rsync 的表现如何?

    gokrazy/rsync 不受影响:--safe-links 功能尚未在 gokrazy/rsync 中实现。

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

    总结:

    rsync 接收器(客户端模式)未对 rsync 发送者提供的文件名进行清理,或阻止访问目标树外的文件。恶意发送者可指示接收器比较目标树外任意文件的校验和。通过观察接收器对单字节校验和的反应,攻击者可泄露任意文件内容。

    当客户端连接到恶意服务器时,服务器能够泄露客户端机器上任意文件的内容。在 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) { /* 客户端接收时未启用 */ 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 指定的文件内容被复制到目标文件中。这可以通过服务器发送负令牌实现。 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 的表现如何?

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

    要重现此问题,请按照以下步骤操作:

  • 检出 gokrazy/rsync v0.2.7 版本: git clone https://github.com/gokrazy/rsync cd rsync git checkout v0.2.7
  • 按如下方式修改代码以撤销修复并执行攻击: 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 安全团队成员、os.Root API 防御路径遍历的作者 Damien Neil 时,他指出:

    我认为 gokrazy 对 CVE-2024-12747 的修复并不充分。你虽然调用了 os.Open 并使用了 O_NOFOLLOW,但 O_NOFOLLOW 仅能阻止最后一级路径组件的符号链接遍历。因此仍可能通过替换更早的路径组件被利用——例如通过将 dir 符号链接到 /etc,使得 os.Open("dir/passwd") 被重定向。

    我们于 2025 年 4 月将此问题报告给 rsync 的安全联系人地址。2025 年 12 月我得知其他人也独立发现了并报告了该问题。

    最终,这导致了 CVE-2026-29518,并于 2026 年 5 月 20 日公开。

    2026 年 5 月批次

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

    描述:(引用 rsync 3.4.3 NEWS 条目)

    在未启用 chroot 的守护进程模式下存在 TOCTOU 符号链接竞争条件,允许本地权限提升。当 rsync 守护进程配置为 use chroot = no 时,会在父路径组件的检查与 open() 使用之间存在时间竞争。具有模块写入权限的本地攻击者可在检查阶段将父目录组件替换为符号链接(如指向 /etc/passwd),从而绕过模块边界,实现读取(文件信息泄露)和写入(文件覆盖)。若守护进程拥有更高权限,则可实现权限提升。默认配置 use chroot = yes 不受影响。 影响范围:攻击者需具备对 daemon 主机的本地访问权、模块路径写入权限,且守护进程必须配置为 use chroot = no。

    上游修复:

    CVE-2026-29518 的上游修复采用了 secure_relative_open(),其设计与 Go 的 os.Root API 类似。

    Go 能否帮助预防此类问题?

    是的,Go 提供了可防御此类问题的 API,参见纵深防御:Go 的 os.Root。

    gokrazy/rsync 的表现如何?

    直到我将 sender 和 receiver 切换至防御路径遍历的 os.Root API 后,gokrazy/rsync 才不再存在该漏洞。

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

    描述:(引用 GitHub 安全通告)

    接收端的压缩令牌解码器在累加一个 32 位有符号计数器时未进行溢出检查。恶意发送者可触发溢出,通过精心操纵将进程内存内容(环境变量、密码、堆和库指针)泄露给攻击者,显著削弱 ASLR 并进一步利用漏洞。影响范围:启用压缩的认证守护进程连接(协议 >= 30 且双方均声明的默认配置)。禁用守护进程压缩(rsyncd.conf 中设置 "refuse options = compress")是唯一缓解措施。

    上游修复:

    针对 CVE-2026-43618 的上游修复引入了缺失的检查机制。

    gokrazy/rsync 的情况如何?

    gokrazy/rsync 不受此漏洞影响,因为它未实现压缩功能。参见 gokrazy/rsync issue #35 了解为何压缩支持看似简单但实际非平凡。

    CVE-2026-43620: 越界读取后拒绝服务 (6.5)

    描述:(引用 GitHub 安全通告)

    2025 年修复在 send_files() 中添加的 parent_ndx<0 保护未应用于 recv_files() 中视觉相同的代码块。恶意 rsync 服务器可通过设置兼容性标志 CF_INC_RECURSE,发送首个排序条目不以“.”开头的 flist(导致 recv_file_list() 设置 parent_ndx = -1),随后发送 ndx=0 且含非 ITEM_TRANSFER iflag 字段的传输记录,使任何连接的客户端必然触发确定性 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 的上游修复同样在 recv_files() 中添加了 parent_ndx<0 保护。

    gokrazy/rsync 的情况如何?

    与 CVE-2024-12087 类似,gokrazy/rsync 不受此漏洞影响:它未实现增量递归模式(--inc-recursive)。

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

    描述:(引用 GitHub 安全通告)

    此前对接收端 open() 调用符号链接竞争条件的修复(CVE-2026-29518)遗漏了其他基于路径的系统调用(chmod、lchown、utimes、rename、unlink、mkdir、symlink、mknod、link、rmdir、lstat)的同类竞争问题。当 rsync 守护进程配置为 “use chroot = no” 时,主机上有文件系统访问权限的本地攻击者可在接收方检查与这些系统调用之间替换父目录组件中的符号链接,将其重定向到导出的模块外部。修复方案将所有受影响的路径系统调用通过内核强制限制的父目录 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 安全通告)

    当 rsync 守护进程配置了全局 daemon chroot = /X rsyncd.conf 设置时,连接客户端的反向 DNS 查找是在守护进程已 chroot 到 /X 之后进行的。如果 /X 不包含 glibc 解析所需的文件(/etc/resolv.conf、/etc/nsswitch.conf、/etc/hosts、NSS 服务模块),则查找失败,连接的主机名被设置为“UNKNOWN”。基于主机名的拒绝规则(“hosts deny = *.evil.example”)因此无法匹配,而控制其 PTR 记录的攻击者可以管理员意图拒绝的主机名进行连接。基于 IP 的 ACL 不受影响。每个模块使用 chroot 的设置与此问题无关。影响范围:rsync 守护进程配置了 daemon chroot = /X 且基于主机名的 ACL 且 /X 未包含 libc 解析固定件的组合情况。

    上游修复:

    对 CVE-2026-43617 的上游修复将 DNS 查找移到协议更早的阶段。

    gokrazy/rsync 的表现如何?

    gokrazy/rsync 不脆弱,因为我们仅实现了基于 IP 的允许/拒绝列表,而非基于主机名的允许/拒绝列表。

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

    描述:(引用 GitHub 安全通告)

    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 是未初始化的栈数据) */ cp++; /* cp 现在指向 &buffer[sizeof]:超出末尾 */ *cp-- = '\0'; /* 在栈缓冲区[1024]后的一字节越界写入 */ '\0' 落在栈缓冲区[1024]后的一个字节,破坏了相邻栈槽中的内容。AddressSanitizer 在 socket.c:95 的 establish_proxy_connection 框架报告栈缓冲区溢出。

    上游修复:

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

    gokrazy/rsync 的表现如何?

    gokrazy/rsync 未实现此类代理支持,因此不受影响。

    Go 裁决

    让我们总结 Go 的表现:

  • Go 运行时的边界检查将更严重的安全问题转化为 panic。 虽然 panic 仍是一种拒绝服务风险,但要好得多。
  • Go 初始化内存为零,使得像 CVE-2024-12085 这样的信息泄露不可能发生。
  • Go 的 os.Root API 阻止了其余的大部分漏洞。
  • 十二个漏洞中只有一个(CVE-2026-43617)是应用逻辑的真正缺陷,使用 Go 本可预防。
  • gokrazy/rsync 裁决

    除了用 Go 编写外,gokrazy/rsync 与官方上游 rsync 的关键区别在于 gokrazy 的实现是极简的:

  • gokrazy/rsync 并未实现相关功能(例如 --inc-recursive),因此不受许多漏洞影响。
  • 与其他所有兼容 rsync 协议实现的版本一样,gokrazy/rsync 的目标是协议版本 27,因为后续协议版本会引入显著复杂性。
  • 某些情况下,本应实现的功能却存在重大阻碍,例如压缩的实现较为复杂,详情见 gokrazy/rsync 问题 #35。
  • 让我们看看在发布时,gokrazy/rsync 是否受到每个 CVE 的影响:

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

    当 2025 年 1 月的漏洞发布时,gokrazy/rsync 曾出现崩溃(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 种不同设置及角色/协议分层:

    就我们的漏洞报告而言,“任意文件泄露”(CVE-2024-12086)原称“服务器泄露任意客户端文件”极易被误解。

    更准确的表述应为:rsync 接收方会将任意文件泄露给恶意发送方。

    经验证,未修补的远程 rsync 在命令行模式下运行(例如通过 SSH)时,恶意客户端发送方可使 rsync 打开目标树外的任意文件(如 /etc/shadow 系统密码数据库)。但守护进程模式下,服务器启用了额外的路径净化,可阻止此类攻击。

    类似地,“符号链接路径遍历”(CVE-2024-12087)提到“恶意服务器”,但实际应为“恶意发送方”,其既可以是客户端也可以是服务器。

    与 OpenBSD 的 openrsync (C) 对比

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

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

    openrsync 也未实现相关功能(如 gokrazy/rsync),故不受 CVE-2024-12086、CVE-2024-12087 和 CVE-2024-12088 影响。即使存在漏洞,OpenBSD 的 unveil(2) 和 pledge(2) 等纵深防御措施(限制文件系统访问)至少在其系统上可阻止成功利用。

    openrsync从一开始实现符号链接支持时就使用了O_NOFOLLOW,因此不受CVE-2024-12747影响。但由于O_NOFOLLOW并非该问题的充分解决方案,openrsync仍会受到CVE-2026-29518的影响!

    上述内容涉及2025年1月批次漏洞;2026年5月批次的情况类似,多数功能仍未实现。

    总体而言,我要说:Kristaps和贡献者们干得好!通过严格实施验证、限制攻击面并采用纵深防御措施,openrsync成功避开了几乎所有报告的漏洞。

    纵深防御

    Linux上可用于纵深防御的API和环境有哪些?

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

    Linux挂载命名空间

    启动gokrazy/rsync项目几周后,我增加了Linux下权限降级和使用mount/pid命名空间的支持,以限制rsync服务器可操作的文件系统对象。

    这种方法对缓解路径遍历攻击非常有效,但需要特权,意味着必须以root身份运行或在启用了用户命名空间的Linux发行版/系统中运行。

    这一局限性使挂载命名空间非常适合服务器环境,但对于通常以普通用户账户运行的交互式一次性传输则通常不可用。

    systemd加固

    在引入Linux mount/pid命名空间支持的同一提交中,我还包含了一个systemd服务文件,将文件系统访问限制在家目录,并在README中鼓励用户根据用例进一步限制文件系统访问。

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

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

    与挂载命名空间类似,这些措施对服务器环境非常适用,但对交互式一次性使用来说设置过于繁琐。

    Linux Landlock

    我偶然读到Justine的博客文章《Porting OpenBSD pledge() to Linux》(2022),想起Linux提供了类似于OpenBSD unveil(2)系统调用的Landlock API,用于无特权的进程级访问控制,而openrsync正是利用这一机制。基本思路是:一旦程序确定了工作目录,便可调用类似unveil("/home/michael/backups", "rw")的指令,此后便无法再访问其他文件系统位置。

    之前在Go Meetup上曾听过Landlock,因此知道Go有Landlock支持。早在2022年,我就已在gokrazy内核镜像中启用了Landlock支持。

    于是我在2025年3月尝试实现Landlock支持以限制文件系统访问。这花费了我几小时时间,比最初预期稍长一些。在我们的测试环境中启用或跳过Landlock时遇到了一些障碍:由于许多函数定义在同一进程中运行,反复添加规则集时,我们超过了每进程16层(!)的策略限制。

    一旦配置得当,Landlock就是一个完美的解决方案。现在即使以非特权方式运行gokrazy/rsync,也能将rsync传输限制为只读源目录或读写目标目录!🎉

    Landlock 的缺点是它在进程级别运行。这意味着 Landlock 策略必须包含程序所需的文件,例如 gokrazy/rsync 需要能够读取 /etc/passwd 以进行用户 ID 查找,因此如果攻击目标是 /etc/passwd 文件,Landlock 就无能为力。

    Go 的 os.Root

    2025 年 2 月,Go 1.24 版本引入了对路径遍历具有抵抗力的 os.Root API,参见 The Go Blog: Traversal-resistant file APIs(作者:Damien Neil,2025 年 3 月)。与 Landlock 相比,该 API 允许更细粒度的控制(针对每个文件系统操作)。

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

    我已将 gokrazy/rsync 的所有文件系统使用转换为使用 os.Root,这是一个非常合适的解决方案:用户配置输入/输出目录,但通过网络接收的文件名是不可信的。这正是 os.Root 的设计初衷!

    当我最初考虑使用 os.Root 时,我想到某些系统调用可能无法通过此 API 实现,例如 mknod(2) 用于创建设备节点文件。Damien 解释道:

    它不支持 mknod,不过你可以用以下方式实现安全的 mknod: os.Root.OpenFile 目标父目录, File.Fd 获取该目录的文件描述符, https://pkg.go.dev/golang.org/x/sys/unix#Mknodat 创建文件。

    如果你想知道实际效果如何,可以查看 gokrazy/rsync 在 internal/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> 后只指定了文件名(路径的最后部分),而非完整路径(见第 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,是因为它们在某个时刻对某些人而言很有价值;当然,我也并非主张我们从此永远停止开发软件。

    但我认为,理想的方案是使用与用例复杂度相匹配、成比例的实现方式。换句话说:简单用例选择简单实现,仅在需要时选用功能完备的实现。

    喜欢这篇文章吗?订阅此博客的 RSS Feed,不错过任何新文章!

    我从 2005 年开始运营博客,至今已传播知识和经验超过 20 年!:)

    所有内容均为人工撰写。我确实会用大语言模型辅助研究和知识工作,甚至审阅我的文章,但所有文字都是我亲自完成的,字字皆出自我的笔端。

    需要完整排版与评论请前往来源站点阅读。