我用 Go 实现的极简、内存安全版 rsync 是如何避开漏洞的
原文由 Michael Stapelberg 于 发布,订阅该博客
早在 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 发行版研究项目(专注于快速包管理)的软件包。我想把它托管在 router7 上——我那台基于 Linux+Go 的小型家用路由器,而它又是基于 gokrazy(我的 Go 设备平台)构建的。
我至今仍为这一最初目的运行着多个 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] - 因此,边界检查是无效的!攻击者可以越界写入。
- 该问题是在2022 年 9 月的
ae16850提交中引入的,该提交添加了 SHA256/SHA512 校验和支持。
点击展开不正确的校验和长度校验的完整描述(引自 Google Security 报告)
当 daemon 读取校验和时,会读取两种不同的校验和:
- 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; /**< 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字节,如下面的代码片段所示: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 字节,具体取决于二进制文件编译时启用了哪种摘要支持:#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 服务端的机器上执行任意代码。客户端只需要对服务器拥有匿名读权限即可。
点击展开信息泄露的完整描述(引自 Google Security 报告)
daemon 在
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];随后 daemon 会遍历客户端发送的校验和,为每个块生成摘要,并与远端摘要进行比较:
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校验和,这会导致 daemon 向栈缓冲区sum2写入 8 字节。攻击者随后可以将s->s2length设为 9 字节。这样设置的结果是,前 8 字节匹配,而攻击者可控的第 9 个字节会与未初始化栈数据中的未知值进行比较。攻击者可以将一个文件分成 255 个块,从而在每次文件下载中泄露一个字节。攻击者可以逐步重复这一过程,无论是在同一连接中还是通过重置连接。
结果,他们可以泄露
MAX_DIGEST_LEN - 8字节未初始化的栈数据,其中可能包含指向堆对象的指针、栈 canary、局部变量以及指向全局变量的指针和返回指针。利用这些指针,他们可以绕过 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模式下(服务器可以为客户端启用),服务器会向客户端发送多个文件列表。条目的去重是按每个文件列表进行的。因此,恶意服务器可以向客户端发送多个文件列表,例如:# 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 Security 报告)
--safe-links命令行标志会让客户端校验从服务器接收到的所有符号链接。预期的行为是,符号链接目标只能 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 不存在此漏洞:--safe-links 功能在 gokrazy/rsync 中尚未实现。
CVE-2024-12086:任意文件泄露(6.8)
概要:
处于客户端模式的 rsync 接收方没有对 rsync 发送方提供的文件名进行净化,也没有以其他方式阻止打开目标目录树之外的文件。恶意发送方可以指示接收方对目标目录树之外的任意文件进行校验和比较。通过观察接收方对所提供的一字节校验和的反应,恶意发送方可以泄露任意文件。
点击展开文件泄露的完整描述(引自 Google Security 报告)
当客户端连接到恶意服务器时,服务器能够泄露客户端机器上任意文件的内容。在
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 表现如何?
gokrazy/rsync 在 1b1fbf6 提交之前是存在漏洞的,该提交引入了与上游 rsync 相同的 O_NOFOLLOW 缓解措施。
点击展开在 gokrazy/rsync 中触发该问题的复现步骤
要复现该问题,请按以下步骤操作:
检出 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 时,他指出:
我认为 针对 CVE-2024-12747 的 gokrazy 修复是不充分的。你在调用
os.Open时使用了O_NOFOLLOW,但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 条目)
在未使用 chroot 的 daemon 模式下,TOCTOU 符号链接竞态条件可导致本地权限提升。
配置为
use chroot = no的 rsync daemon 会在父路径组件上暴露检查时与使用时的竞态。具有模块写权限的本地攻击者可以在接收方的检查与open()之间将父目录组件替换为符号链接,从而将读取(基础文件泄露)和写入(文件覆盖)重定向到模块之外。在 daemon 拥有较高权限的情况下,这可导致权限提升。默认的
use chroot = yes不受影响。影响范围:daemon 主机上的本地攻击者、拥有模块路径写权限、daemon 配置为
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 并便于进一步利用。
影响范围:启用压缩的已认证 daemon 连接(当双方都声明支持时,协议 >= 30 默认启用)。在 daemon 上禁用压缩(在 rsyncd.conf 中设置“refuse options = compress”)是可行的缓解措施。
上游修复:
针对 CVE-2026-43618 的上游修复补上了缺失的检查。
gokrazy/rsync 表现如何?
gokrazy/rsync 不存在此漏洞,因为它没有实现压缩。关于为何压缩支持听起来简单实则并不简单,详见 gokrazy/rsync issue #35。
CVE-2026-43620:越界读取后的拒绝服务(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 还是 remote-shell 拉取均可触发。
inc_recurse是协议 30+ 的默认值;受害者无需任何特殊选项。缓解措施:客户端使用
--no-inc-recursive。
上游修复:
针对 CVE-2026-43620 的上游修复也在 recv_files() 中添加了 parent_ndx<0 保护。
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 daemon 上,拥有 daemon 主机文件系统访问权限的本地攻击者可以在接收方检查与上述任一系统调用之间,将父目录组件替换为符号链接,从而将其重定向到导出模块之外。修复方案是将每个受影响的基于路径的系统调用,都通过在等同于 RESOLVE_BENEATH 的内核强制约束下打开的父目录 dirfd 来路由(Linux 5.6+ 上使用 openat2,FreeBSD 13+ 和 macOS 15+ 上使用 O_RESOLVE_BENEATH,其他平台则逐组件使用 O_NOFOLLOW 遍历)。默认的“use chroot = yes”不受影响。影响范围:daemon 主机上的本地攻击者、拥有模块路径写权限、daemon 配置为 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 daemon 上,对连接客户端的反向 DNS 查询是在 daemon 已 chroot 到/X之后才执行的。如果/X中没有包含 glibc 解析所需的那些文件(/etc/resolv.conf、/etc/nsswitch.conf、/etc/hosts、NSS 服务模块),查询就会失败,连接主机名会被设为“UNKNOWN”。因此,基于主机名的拒绝规则(“hosts deny = *.evil.example”)无法匹配,控制着自己 PTR 记录的攻击者就可以从管理员本想拒绝的主机名连接上来。基于 IP 的 ACL 不受影响。按模块的use chroot设置与此问题无关。影响范围:配置了
daemon chroot = /X且使用基于主机名 ACL 且/X中未包含 libc 解析所需文件的 rsync daemon。
上游修复:
针对 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]。如果代理(或其前的中间人)返回的第一行响应在没有'\n'终止符的情况下达到 1023+ 字节,循环会以cp == &buffer[sizeof buffer - 1]退出——该位置循环从未写入,因此*cp中保存的是之前snprintf()格式化外发CONNECT请求时留在栈上的陈旧字节。循环之后的代码随后会执行: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]末尾之外的一个字节,破坏相邻栈槽中的内容。AddressSanitizer 在establish_proxy_connection帧的socket.c:95处报告stack-buffer-overflow。
上游修复:
针对 CVE-2026-45232 的上游修复对攻击者提供的数据进行了校验。
gokrazy/rsync 表现如何?
gokrazy/rsync 没有实现此类代理支持,因此不存在此漏洞。
Go 的结论
来总结一下 Go 的表现:
- Go 运行时的边界检查会将更严重的安全问题变为 panic。
- panic 仍然有拒绝服务的风险,但这要好得多。
- Go 会将内存初始化为零,使得像 CVE-2024-12085 这样的信息泄露成为不可能。
- Go 的
os.RootAPI 可以防止其余大多数漏洞。 - 12 个漏洞中只有一个(CVE-2026-43617)是 Go 也无法防止的、应用逻辑本身的正当 bug。
| CVE 编号 | 原因 | 风险(C) | Go 能否帮忙? |
|---|---|---|---|
| 2024-12084 | 校验不足 | 堆缓冲区溢出 | ✅ 边界检查(会 panic!) |
| 2024-12085 | 校验不足 | 信息泄露 | ✅ 零值初始化 |
| 2024-12086 | 缺失校验 | 任意文件泄露 | ✅ os.Root |
| 2024-12087 | 校验不足 | 写入任意文件 | ✅ os.Root |
| 2024-12088 | 校验不足 | 创建任意符号链接 | ✅ os.Root |
| 2024-12747 | TOCTOU | 泄露特权文件 | ✅ os.Root |
| 2026-29518 | TOCTOU | 泄露特权文件 | ✅ os.Root |
| 2026-43617 | 缺失校验 | 拒绝列表失效 | ❌ 逻辑 bug |
| 2026-43618 | 校验不足 | 信息泄露 | ✅ 边界检查(会 panic!) |
| 2026-43619 | TOCTOU | 泄露特权文件 | ✅ os.Root |
| 2026-43620 | 校验不足 | 崩溃(拒绝服务) | ✅ 边界检查(会 panic!) |
| 2026-45232 | 缺失校验 | 内存写入 | ✅ 边界检查(会 panic!) |
gokrazy/rsync 的结论
除了用 Go 编写之外,gokrazy/rsync 与官方上游 rsync 的另一个关键区别在于 gokrazy 的实现是极简的:
- gokrazy/rsync 不受许多漏洞影响,仅仅因为它没有实现相关功能,例如
--inc-recursive。 - 与所有其他与线协议兼容的 rsync 实现一样,gokrazy/rsync 目标是协议版本 27,因为更高版本的协议引入了显著的复杂性。
- 在某些情况下,一些值得实现的功能存在显著的阻碍,例如压缩就很棘手,详见 gokrazy/rsync issue #35。
来看看在发布时 gokrazy/rsync 是否受到每个 CVE 的影响:
| CVE 编号 | 原因 | gokrazy/rsync 是否实现? | gokrazy/rsync 是否受影响? |
|---|---|---|---|
| 2024-12084 | 校验不足 | 是 | ⚠️ panic |
| 2024-12085 | 校验不足 | 否(协议 30) | ✅ 不受影响 |
| 2024-12086 | 缺失校验 | 否(协议 29) | ✅ 不受影响 |
| 2024-12087 | 校验不足 | 否(inc-rec) | ✅ 不受影响 |
| 2024-12088 | 校验不足 | 否(safe-links) | ✅ 不受影响 |
| 2024-12747 | TOCTOU | 是 | ❌ 存在漏洞 |
| 2026-29518 | TOCTOU | 是 | ⚠️ 已修复 |
| 2026-43617 | 缺失校验 | 否(主机拒绝列表) | ✅ 不受影响 |
| 2026-43618 | 校验不足 | 否(压缩) | ✅ 不受影响 |
| 2026-43619 | TOCTOU | 是 | ⚠️ 已修复 |
| 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 服务器双方都可以担任发送方(上传文件)或接收方(下载文件)!
一些配置还带有进一步的限制,使得某些攻击更难或无法得逞。例如,在 daemon 模式下,文件系统访问可以被限制在预先配置的模块路径内(但在命令模式下则不行!)。
这里有一张图, overview 4 种不同配置以及角色/协议分层:

就我们的漏洞报告而言,我认为任意文件泄露漏洞(CVE-2024-12086)原标题“Server leaks arbitrary client files”很容易让人误解。
相反,我会说:rsync 接收方会向恶意发送方泄露任意文件。
我已验证,恶意客户端发送方可以在命令模式下(例如通过 SSH)让未修补的远端 rsync 打开目标目录树之外的文件(例如系统密码数据库 /etc/shadow)。(但是,在 daemon 模式下,服务器会启用额外的路径净化,从而阻止此攻击。)
类似地,符号链接路径遍历漏洞(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 上使用 OpenBSD 的 unveil(2) 和 pledge(2) 来限制文件系统访问,也会阻止攻击成功——至少在 OpenBSD 上是如此。
openrsync 不受 CVE-2024-12747 影响,因为它从实现符号链接支持的那一刻起就使用了 O_NOFOLLOW。但是,由于 O_NOFOLLOW 对此问题而言不是充分的修复,openrsync 确实受 CVE-2026-29518 影响!
以上涵盖了 2025 年 1 月批次的漏洞;2026 年 5 月批次的情况类似,大多数功能根本就没有实现。
总的来说,我想说:干得漂亮,Kristaps 及各位贡献者!通过 diligently 实现校验、限制攻击面并采用纵深防御措施,openrsync 成功地避开了几乎所有已报告的漏洞。
纵深防御
在 Linux 上,我们可以使用哪些 API 和环境来实现纵深防御措施?
我将按从传统到现代的顺序,介绍 gokrazy/rsync 支持的几种方式。
Linux 挂载命名空间
在启动 gokrazy/rsync 项目几周后,我就添加了在 Linux 上丢弃权限并使用挂载/pid 命名空间来限制 rsync 服务器可操作的文件系统对象的支持。
这种方法能很好地缓解路径遍历攻击,但需要特权,意味着我们需要以 root 身份运行,或在 Linux 用户命名空间中运行(如果你的发行版/系统启用了该功能)。
这一限制使得挂载命名空间非常适合服务器场景,但对于通常在人类用户账户下运行的交互式一次性传输来说通常不可用。
systemd 加固
在引入 Linux 挂载/pid 命名空间支持的同一提交中,我还提供了一个 systemd 服务文件,它将文件系统访问限制在家目录,并鼓励大家在 README 中根据各自用例进一步限制文件系统访问。
这些文件系统限制如果配置正确,可以缓解文件泄露(CVE-2024-12086)和路径遍历(CVE-2024-12087)漏洞。
符号链接竞态条件(CVE-2024-12747)依赖于通过 rsync 进程进行权限提升,但得益于 DynamicUser 功能,我们的进程比其他用户拥有更少的权限。
与挂载命名空间类似,这些措施非常适合服务器场景,但对于交互式一次性使用来说,配置起来太过繁琐。
Linux Landlock
我偶然看到 Justine 的博客文章 将 OpenBSD pledge() 移植到 Linux(2022),想起 Linux 提供了 Landlock API,用于非特权的、按进程的访问控制,类似于 OpenBSD 的 unveil(2) 系统调用,后者正是 openrsync 所使用的。其基本思想是,一旦你的程序知道了要操作的目录,就调用类似 unveil("/home/michael/backups", "rw"); 的调用,之后就无法再访问其他文件系统位置。
我之前在一次 Go Meetup 上听说过 Landlock,所以知道 Go 对 Landlock 有支持。早在 2022 年,我就在 gokrazy 内核镜像中启用了 Landlock 支持。
于是我在 2025 年 3 月尝试了一下,并实现了 Landlock 支持来限制文件系统访问。这花了我几个小时,比最初预期的要长一些。要让 Landlock 在我们的测试环境中正常工作(和/或跳过它)遇到了几个障碍:我们的测试定义了许多在同一进程中运行的函数,但反复添加规则集时,会超出每个进程 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,它能抵御路径遍历,详见 Go 博客:抗遍历的文件 API(Damien Neil,2025 年 3 月)。该 API 相比 Landlock 允许更细粒度的控制(按每次文件系统操作)。
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 中,是因为它们在某个时候对某些人是有价值的,当然我并不是说我们就应该……永远不再继续开发软件。
但是,我认为理想的做法是使用与用例复杂度相称、成比例的复杂度的实现。换句话说:对于简单的用例,就选用简单的实现。只有在需要时,才选用功能完备的实现。
随机一篇博客
评论
登录后参与讨论