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

Michael Stapelberg

我那精簡、具備記憶體安全的 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 或更新版本。

歡迎跳過繁瑣的漏洞細節,直接前往:

背景:我自己的 rsync

為了提供脈絡,我在 2022 年 6 月曾發文介紹過 rsync、我的使用方式與運作原理。另可參考所有標註為「rsync」的文章

當初自己寫 rsync(當時只有伺服器端,現在已支援所有傳輸方向)的動機,是為了提供 distri(我那個專注於快速套件管理的 Linux 發行版研究專案)的軟體套件,我想把它架在 router7 上——那是我用 Linux + Go 打造的小型家用網際網路路由器,而 router7 本身則是建構在 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 的驗證不足:它從網路讀取(由攻擊者控制的)checksum 長度,並將該長度與 MAX_DIGEST_LEN 比較。
  • 然而,rsync 的資料結構始終宣告一個 16 位元組的緩衝區:char sum2[SUM_LENGTH]
    • SUM_LENGTH 恆為 16(位元組),剛好足以容納 MD4MD5 的 checksum。
    • MAX_DIGEST_LEN 原本是 16(位元組),但當 rsync 以支援 SHA256 或 SHA512 checksum 的方式編譯時,就會變得更大。
  • 因此,邊界檢查完全無效!攻擊者可以寫到邊界之外。
  • 這個問題是在 2022 年 9 月的 commit ae16850 引入的,該提交加入了 SHA256/SHA512 checksum 支援。
點此展開 checksum 長度驗證不當的完整說明(引自 Google Security 報告

當 daemon 讀取 checksum 時,會讀取兩種不同的 checksum:

  1. 32 位元的 Adler-CRC32 Checksum
  2. 檔案區塊的摘要(digest)。摘要演算法在協定協商一開始就決定。對應的程式碼如下所示: 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(此次傳輸所用演算法的 checksum 長度)為準。

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 漏洞相同的驗證缺失,攻擊者可以選擇具有較短 checksum 的演算法(例如 xxhash64 的 8 位元組 checksum),卻宣稱自己傳送的是較長的 checksum(例如 9 位元組),藉此讓受害者在回應中洩漏一個位元組未初始化的堆疊內容。

洩漏一個位元組的堆疊內容看似無害,但如 Google Security 報告所說:

前兩個漏洞分別是堆積緩衝區溢位與資訊外洩。兩者結合後,客戶端就能在 Rsync 伺服器運行的機器上執行任意程式碼。客戶端只需要對伺服器具備匿名讀取權限即可。

點此展開 資訊外洩的完整說明(引自 Google Security 報告

daemon 會在 hash_search() 中,將客戶端傳來的區塊 checksum 與本機檔案內容進行比對。該函式開頭會在堆疊上配置一個 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 會遍歷客戶端送來的 checksum,並為每個區塊產生摘要,再與遠端的摘要比較:

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 checksum,這會讓 daemon 在堆疊緩衝區 sum2 寫入 8 個位元組。攻擊者接著把 s->s2length 設為 9 個位元組。這樣的設定會導致前 8 個位元組比對成功,而攻擊者控制的第 9 個位元組則會與未知的未初始化堆疊資料進行比較。

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

結果,他們最多可以洩漏 MAX_DIGEST_LEN - 8 個位元組未初始化的堆疊資料,其中可能包含指向 Heap 物件的指標、Stack canary、區域變數與指向全域變數的指標以及返回位址。拿到這些指標後,他們就能繞過 ASLR。

上游修補:

有兩個相關的上游修補:

Go 能否預防這個問題?

可以:根據設計,Go 會將所有變數初始化為零值。Go 程式設計師不需要記得手動初始化變數。

gokrazy/rsync 的狀況如何?

gokrazy/rsync 不受此漏洞影響:在 Go 中變數永遠會被初始化。

此外,選擇 MD4 以外 checksum 的功能只在協定版本 30 才引入(gokrazy/rsync 實作的是協定版本 27)。

CVE-2024-12087:利用 Symlink 的路徑遍歷(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 能否預防這個問題?

不行。這個漏洞是由邏輯錯誤所導致:驗證函式本身不正確。我們同樣可能寫出相同的錯誤。

不過請參閱縱深防禦:Go 的 os.Root

gokrazy/rsync 的狀況如何?

gokrazy/rsync 並未受影響:--safe-links 功能在 gokrazy/rsync 中尚未實作。

CVE-2024-12086:任意檔案外洩(6.8)

摘要:

rsync 接收端(在客戶端模式下)並未對 rsync 傳送端提供的檔案名稱進行過濾,也未防止在目的目錄樹之外開啟檔案。惡意傳送端可以指示接收端去比對目的目錄樹之外任意檔案的 checksum。透過觀察接收端對所提供的一位元組 checksum 的反應,惡意傳送端就能外洩任意檔案。

點此展開 檔案外洩的完整說明(引自 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)

伺服器會送出一個 checksum 來比對。如果不相符,就會回傳 0。

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

當回傳值為 0 時,接收端會接著向 generator 送出 MSG_REDO。generator 接著會寫一則訊息給伺服器。

伺服器可以把這當作訊號,來判斷自己送出的 checksum 是否正確。透過一開始將 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:Symlink 競態條件(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 在 commit 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 的修補並不充分。你雖然在 os.Open 加上了 O_NOFOLLOW,但 O_NOFOLLOW 只會防止對路徑最後一個元件的符號連結遍歷。

這很可能仍會被繞過:只要把較前面的路徑元件替換掉,os.Open("dir/passwd") 就可以透過把 dir 符號連結到 /etc 來重新導向。

我們在 2025 年 4 月向 rsync 的資安聯絡窗口通報了此事。2025 年 12 月我得知,另有人也獨立發現並通報了同一個問題。

最終,這導致了在 2026 年 5 月 20 日公布的 CVE-2026-29518。

2026 年 5 月批次

CVE-2026-29518:Symlink 競態條件(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 位元有號計數器時未檢查溢位。惡意傳送端可觸發溢位,並透過精心操弄,將行程的記憶體內容外洩給攻擊者——包含環境變數、密碼、heap 與函式庫指標——大幅削弱 ASLR 並為後續利用鋪路。

影響範圍:已認證的 daemon 連線且啟用壓縮(當雙方皆宣告支援時,協定 >= 30 的預設值)。在 daemon 上停用壓縮(在 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,並送出一個經排序後第一筆不是以「.」開頭目錄的檔案清單(這會導致 recv_file_list()parent_ndx = -1),接著再送出一筆 ndx=0 且 iflag 字組非 ITEM_TRANSFER 的傳輸紀錄,來使任何連線的客戶端確定性地觸發 SIGSEGV。接收端會讀取 dir_flist->files[-1] 並對結果解參照。在 glibc x86-64 上,被解參照的指標是 mmap 區塊的中繼資料,剛好落在未映射的位址上,因而產生乾淨的 SEGV_MAPERR;非 glibc 的配置器尚未經過稽核。

影響範圍:任何從攻擊者控制的 URL 進行正常 pull 的 rsync 客戶端。無論是 rsync:// URL 或 remote-shell pull 皆可觸發。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:更多 symlink 競態(6.3)

說明:(引自 GitHub Security Advisory

說明:先前針對接收端 open() 呼叫中 symlink 競態的修補(CVE-2026-29518)遺漏了所有其他基於路徑的系統呼叫上的同類競態:chmod、lchown、utimes、rename、unlink、mkdir、symlink、mknod、link、rmdir、lstat。在「use chroot = no」的 rsync daemon 上,具備 daemon 主機檔案系統存取權的本地攻擊者,可以在接收端的檢查與上述任一系統呼叫之間,將父目錄元件換成 symlink,藉此將其重新導向至匯出模組之外。修補方式是將每個受影響、基於路徑的系統呼叫,改為透過在核心強制限制下開啟的父 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 的 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 設定與此問題無關。

影響範圍:rsync daemon 設定了 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)中存在一個差一錯誤(off-by-one)的堆疊越界寫入。在發出 CONNECT 請求後,rsync 會以一次讀一個位元組的方式,將代理的第一行回應讀入一個 1024 位元組的堆疊緩衝區,其邊界為 cp < &buffer[sizeof buffer - 1],因此迴圈永遠只會寫入 buffer[0..sizeof-2]。若代理(或其前方的中間人)回傳的第一行回應長達 1023+ 位元組且中間沒有 '\n' 結尾字元,迴圈會在 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.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驗證不足建立任意 Symlinkos.Root
2024-12747TOCTOU外洩特權檔案os.Root
2026-29518TOCTOU外洩特權檔案os.Root
2026-43617缺少驗證拒絕清單失效❌ 邏輯錯誤
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

來看看在本文發表時,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 接收端會將任意檔案外洩給惡意的傳送端

我已驗證過,在命令模式下(例如透過 SSH)執行時,惡意的客戶端傳送端確實可以讓未修補的遠端 rsync 在目的目錄樹之外開啟檔案(例如系統密碼資料庫 /etc/shadow)。(不過,在 daemon 模式下,伺服器會啟用額外的路徑過濾,從而阻擋此攻擊。)

同樣地,Symlink 路徑遍歷漏洞(CVE-2024-12087)雖然寫著「惡意伺服器」,但同樣應該是「惡意傳送端」,而傳送端可能是客戶端,也可能是伺服器。

與 OpenBSD 的 openrsync(C 語言)比較

OpenBSD 專案以注重安全性著稱,那麼 openrsync 的表現如何?

openrsync 不受堆積緩衝區溢位(CVE-2024-12084)與堆疊資訊外洩(CVE-2024-12085)影響,因為它會驗證 checksum 長度,且只支援一種 checksum 大小/演算法(MD4)。

openrsync 也不受 CVE-2024-12086、CVE-2024-12087 與 CVE-2024-12088 影響,因為它並未實作相關功能(與 gokrazy/rsync 相同)。即使它原本有漏洞,openrsync 的縱深防禦措施——例如使用 OpenBSD 的 unveil(2)pledge(2) 來限制檔案系統存取——至少在 OpenBSD 上執行時也能阻止成功利用。

openrsync 不受 CVE-2024-12747 影響,因為它從實作 symlink 支援的那一刻起就使用了 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 服務檔,將檔案系統存取限制在家目錄,並在 README 中鼓勵大家依使用情境進一步限縮檔案系統存取。

這些檔案系統限制若設定正確,就能緩解檔案外洩(CVE-2024-12086)與路徑遍歷(CVE-2024-12087)漏洞。

Symlink 競態條件(CVE-2024-12747)依賴透過 rsync 行程進行權限提升,但多虧了 DynamicUser 功能,我們的行程所擁有的權限比其他使用者還要少。

與 mount namespace 類似,這些措施對伺服器設定來說很棒,但對於互動式一次性使用情境來說,設定起來太過繁瑣。

Linux Landlock

我偶然看到 Justine 的部落格文章 Porting OpenBSD pledge() to Linux (2022),被提醒 Linux 提供了 Landlock API 這個非特權、針對單一行程的存取控制機制,類似 OpenBSD 的 unveil(2) 系統呼叫,openrsync 也有使用。其基本想法是,一旦你的程式知道自己要處理的目錄,就呼叫類似 unveil("/home/michael/backups", "rw"); 的函式,之後就無法再存取其他檔案系統位置。

我之前在某次 Go 聚會上就聽過 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,它能抵抗路徑遍歷,請參閱 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 協定的演進:程式碼原本已正確地做了充分驗證,但後來加入了新功能。例如,當加入 checksum 演算法協商(協定版本 30)時,驗證並未被正確更新。當加入遞增遞迴(同樣是協定版本 30)時,原本針對單一檔案清單合理的驗證,也未針對合併遞增檔案清單的新處理方式進行更新。

避免複雜度就能避免漏洞!無論是 gokrazy/rsync 還是 openrsync,都僅僅因為沒有實作有漏洞的功能,就避開了 12 個資安漏洞中的 8 個。

當然,這些功能當初會被加入 rsync,是因為它們在某些時刻對某些人是有價值的,我當然也不是在說我們就該……永遠不要再繼續開發軟體。

但我認為,理想的做法是使用一個複雜度與使用情境的複雜度相稱、成比例的實作。換句話說:對於簡單的使用情境,就選用簡單的實作。只有在真正需要時,才去使用功能完整的實作。

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

留言