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 上——這是我以 gokrazy(我的 Go 設備平台)打造的小型家用 Linux+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]
    • SUM_LENGTH 恆為 16(位元組),足以容納 MD4MD5 檢查碼。
    • MAX_DIGEST_LEN 原本是 16(位元組),但當 rsync 編譯時啟用 SHA256 或 SHA512 檢查碼支援時,可能會更大。
  • 因此,邊界檢查形同無效!攻擊者得以越界寫入。
  • 此問題是在 2022 年 9 月的提交 ae16850 中引入的,該提交新增了 SHA256/SHA512 檢查碼支援。
點擊展開不當檢查碼長度驗證的完整描述(引自 Google Security 報告

當檢查碼被 daemon 讀取時,會讀取兩種不同的檢查碼:

  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 執行時期會執行邊界檢查。

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 弱點相同的驗證缺失,攻擊者可以選擇具有短檢查碼的檢查碼演算法(例如具 8 位元組檢查碼的 xxhash64),卻聲稱傳送了較長的檢查碼(例如 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 將 8 個位元組寫入堆疊緩衝區 sum2。攻擊者接著可將 s->s2length 設為 9 個位元組。這樣的設定會導致前 8 個位元組相符,而攻擊者控制的第 9 個位元組會與未初始化堆疊資料的未知值進行比較。

攻擊者可將檔案切成 255 個區塊,藉此在每次檔案下載時洩漏一個位元組。攻擊者可以重複此過程,無論是在同一連線中或透過重設連線來逐步進行。

結果,他們可以洩漏 MAX_DIGEST_LEN - 8 個位元組未初始化的堆疊資料,其中可能包含堆積物件的指標、堆疊 canary、區域變數與全域變數的指標以及返回位址。有了這些指標,他們便能破解 ASLR。

上游修正:

有兩個相關的上游修正:

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)。

此處的取捨在於實作複雜度與資源使用量:增量遞迴模式允許以「視窗化」的方式處理檔案集合,而非必須在任何傳輸開始前掃描整個檔案集合。另請參閱我的 How does rsync work? 部落格文章

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 能幫忙預防嗎?

不能。此弱點是由邏輯錯誤所致:驗證函式不正確。我們同樣可能實作出相同的錯誤。

但請參閱縱深防禦: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 指定的檔案內容會被複製到目的檔案中。這可由伺服器傳送一個負的權杖來達成。

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 時,接收端接著會向產生器(generator)傳送 MSG_REDO。產生器接著會向伺服器寫入訊息。

伺服器可將此作為訊號,來判斷其傳送的檢查碼是否正確。透過一開始將 blength 設為 1,惡意伺服器便能逐位元組地判斷目標檔案的內容。

上游修正:

針對 CVE-2024-12086 的上游修正透過驗證傳送端提供的路徑,防止在目的目錄樹之外開啟檔案。

Go 能幫忙預防嗎?

可以,Go 提供了可預防此情況的 API,請參閱縱深防禦:Go 的 os.Root

gokrazy/rsync 的情況如何?

gokrazy/rsync 不受影響:模糊比對(fuzzy matching)功能是在 rsync 協定版本 29 中引入的,而 gokrazy/rsync 實作的是協定版本 27。

CVE-2024-12747:符號連結競爭條件(5.6)

描述:(引自 Red Hat Security Advisory

在 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 中觸發此問題

要重現此問題,請使用以下步驟:

  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 安全團隊成員、也是可抵禦穿越的 os.Root API 作者 Damien Neil(達米安·尼爾)分享本文草稿時,他指出:

我認為 gokrazy 針對 CVE-2024-12747 的修正並不充分。你雖然以 O_NOFOLLOW 呼叫 os.Open,但 O_NOFOLLOW 僅會防止最後一個路徑元件的符號連結穿越。

這可能仍然容易受到將較早路徑元件替換的攻擊,因此 os.Open("dir/passwd") 可能會因為將 dir 符號連結至 /etc 而被重新導向。

我們在 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

描述:接收端的壓縮權杖解碼器在累積 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 與非 ITEM_TRANSFER iflag 字的傳輸紀錄,將任何連線的客戶端驅動至確定性的 SIGSEGV。接收端會讀取 dir_flist->files[-1] 並解參考結果。在 glibc x86-64 上,被解參考的指標是落在未映射位址的 mmap chunk 詮釋資料,因此為乾淨的 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 不受此弱點影響: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)中包含一個差一(off-by-one)越界堆疊寫入。在發出 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] 結尾之後一個位元組,破壞相鄰堆疊位置中的內容。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.Root API 可預防大多數剩餘的弱點。
  • 12 個弱點中只有一個(CVE-2026-43617)是 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缺少驗證拒絕清單失效❌ 邏輯錯誤
2026-43618驗證不足資訊洩漏✅ 邊界檢查(會 panic!)
2026-43619TOCTOU洩漏特權檔案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-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 伺服器雙方都可能擔任傳送端(上傳檔案)或接收端(下載檔案)任一角色!

某些設定還帶有進一步的限制,使特定攻擊更難或無法得逞。例如,在 daemon 模式下,檔案系統存取可被限制為預先設定的模組路徑(但在命令模式下則不行!)。

以下圖表概述了 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)原標題「Server leaks arbitrary client files」很容易讓人誤解。

相反地,我會說:rsync 接收端會向惡意傳送端洩漏任意檔案。

我已驗證,惡意的客戶端傳送端可以讓未修補的遠端 rsync 在以命令模式(例如透過 SSH)執行時,於目的目錄樹之外開啟檔案(例如系統密碼資料庫 /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 mount 命名空間

在啟動 gokrazy/rsync 專案後數週內,我便新增了在 Linux 上卸除權限並使用 mount/pid 命名空間來限制 rsync 伺服器可處理的檔案系統物件的支援

這種方法對於緩解路徑穿越攻擊非常有效,但需要具備權限,意味著我們需要以 root 身分執行,或在 Linux 使用者命名空間中執行(若您的發行版/系統有啟用)。

此限制使得 mount 命名空間非常適合伺服器設定,但通常無法用於一般在人類使用者帳號下執行的互動式一次性傳輸。

systemd 加固

在引入 Linux mount/pid 命名空間支援的同一個提交中,我也加入了一個 systemd 服務檔,將檔案系統存取限制為家目錄,並在 README 中鼓勵大家依使用情境進一步限制檔案系統存取。

這些檔案系統限制若設定正確,可緩解檔案洩漏(CVE-2024-12086)與路徑穿越(CVE-2024-12087)弱點。

符號連結競爭條件(CVE-2024-12747)依賴透過 rsync 程序進行權限提升,但得益於 DynamicUser 功能,我們的程序所擁有的權限比其他使用者更少。

與 mount 命名空間類似,這些措施非常適合伺服器設定,但對於互動式一次性使用而言,設定起來過於繁瑣。

Linux Landlock

我偶然看到 Justine(賈斯汀)的部落格文章 Porting OpenBSD pledge() to 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 是在處理程序層級運作。這意味著 Landlock 政策必須包含你的程式所需的檔案,例如 gokrazy/rsync 需要能夠讀取 /etc/passwd 以進行使用者 ID 查詢,因此如果攻擊者的目標是 /etc/passwd 檔案,Landlock 就幫不上忙。

Go 的 os.Root

在 2025 年 2 月,Go 1.24 版本引入了os.Root API,該 API 可抵禦路徑穿越,請參閱 The Go Blog: Traversal-resistant file APIs(由 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:

如果你好奇這在實務上看起來如何,請查看 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> 之後僅指定基底名稱(路徑的最後一個元件),而非路徑,因此跳過了路徑解析(請參閱 第 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 是因為它們在某個時間點對某人而言是有價值的,當然我並不是說我們就該…永遠不再進一步開發軟體。

但是,我認為理想的做法是使用複雜度與使用情境的複雜度相稱且成比例的實作。換句話說:對於簡單的使用情境,選擇簡單的實作。只有在需要時,才選擇功能完整(fully-featured)的實作。

原文由 Michael Stapelberg 發布

本文章由 muse-spark-1.2-contributor 進行翻譯