vatt'ghern jaskier's ballads
本文 3 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

研究者 Konstantinos Maninakis 對著自己截下的封包發現一件事——連上一個正式的 Radicle seed 節點, Noise handshake 明明做完了,緊接著進來的第一個位元組,卻是明文的「r」,rad 這個字的開頭。

Radicle 的節點原來沒在驗身分

這是 Radicle 專案在 2026 年 9 月 23 日公開的一次協調揭露。官方自己把兩個漏洞定調為 critical,目前為止所有已發布版本都受影響,沒有一個版本是安全的。兩個漏洞是各自獨立回報的,攻擊條件也不一樣: 一個需要攻擊者剛好卡在網路路徑中間;另一個雖然不需要卡在路徑上,官方自己也特別提醒,單獨拿出來看,這個漏洞比聽起來難用。

七週裡,兩份各自獨立的回報

第一份回報來自 Konstantinos Maninakis,時間是 6 月 24 日。他當時在做 Grove——一個跑在瀏覽器分頁裡、 任何裝置上都能跑、沒有伺服器代為執行 Git 操作的 Radicle 節點,這代表它的傳輸層必須跟正式節點的協定完全相容。 他把 Grove 用的 Noise 協定傳輸層,照 Rust 版 heartwood 原始碼「rewriting it in TypeScript, byte for byte」——理論上應該跟任何遵守同一套規格的節點握手成功,卻連不上正式的 Radicle 節點。他把 Wireshark 接上連線:「I put Wireshark on the connection to see what the other side was actually sending me.」iris.radicle.network 是 Radicle 團隊的一個 production seed——這代表研究者一開始截到的,就是官方正式節點上的明文流量。對著這次連線截包, 他寫道:「From byte 32 on nothing is encrypted.」Grove 自己那一半做對了—— 「did what Noise says and encrypted everything past the peer-to-peer handshake」,沒做對的是對面。

往程式碼裡挖,問題出在 radicle-node 的讀寫路徑上——一個判斷式把「Noise handshake 已完成」跟 「SOCKS5 代理協商已完成」這兩種完全不同的狀態,共用了同一個 is_complete() 條件。Maninakis 寫道:「With SOCKS5 this is exactly what the spec asks for. Once the proxy handshake is done, you get out of the way and pass the bytes through as they are. With Noise, the end of the handshake is where the real work starts, because from then on you encrypt everything. Same wrapper, same is_complete(), opposite meaning. Only the SOCKS5 half of that ever got implemented.」貼出這段程式碼片段的 GitHub issue,開在 cyphernet-labs/netservices.rs—— 這是 Radicle 依賴的底層函式庫,不是 Radicle 自己的 repo;官方揭露文件也寫明「We reported upstream, see this issue」,確認這正是官方通報上游後留下的紀錄。issue 建立於 9 月 23 日,跟公開揭露同一天, 回報者是 lorenzleutgeb,內文點名感謝 Maninakis;截至公開當天,這個 issue 還是 open 狀態,沒有對應的 pull request。issue 寫道:「A successful handshake therefore does not imply that subsequent messages are encrypted and authenticated.」issue 裡貼出的程式碼片段是:

if self.state.is_complete() || !self.session.is_established() {
    return self.session.read(buf);
}

這個條件同時出現在讀跟寫兩條路徑上,一旦成立就直接把資料丟給底層的 TCP session,完全跳過 Noise 的加解密, 整條連線建立起來之後,後續全部的往返都是明文。

issue 也提了兩個修法方向:一是「Encrypt and decrypt writes and reads. Keep passthrough behavior for protocols that only negotiate a connection, such as SOCKS5.」,把加解密跟 SOCKS5 的穿透行為分開處理; 二是「Define transport framing over byte streams. Noise requires applications to supply any needed length fields; its specification recommends a two-byte big-endian message length.」,也就是額外定義 一個訊息長度欄位,把訊息邊界跟原始位元組流分開;同樣用這個函式庫的其他專案,也該檢查自己有沒有踩到 同一個誤用模式。

照這個讀法,審查的人容易放過這種 bug——兩條分支長得一模一樣,同一段程式碼、同一個判斷式,差別只在於呼叫它的 情境是 Noise handshake 還是 SOCKS5 協商,不會特地去區分這兩種「完成」背後的語意差異;issue 本身沒有 描述審查過程。

從封包裡直接讀得到的東西不只是協定開頭。Node 之間互相宣告用的三種位址——DNS 名稱、I2P 位址、Tor onion 位址,還有 repo 清單、git fetch 指令,以及 pack data——裡頭的 commit 作者身分、時間戳與簽章全部原封不動, 甚至包含 agent=radicle-fetch/0.21.1 這樣的 client 版本標記。研究者另外用 tcpdump 截了一次 完整的 repo clone,從自己的節點連到自己的 seed:1,522 個封包,進站流量 3.6 MB,開頭一樣是明文的 72 61 64 01,約三分之二之後才開始出現 Git 協定的交談。至於他從正式 seed 截到的第一段明文流量,研究者的歸類是 gossip:路由中繼資料、repository id、節點位址。光是這一層,就已經是私有網路的拓樸資訊。

按 Maninakis 自己的說法,雙方談定的是「a roughly three-month embargo」——後來公開的日期是 9 月 23 日, 剛好對上這個三個月的窗口。

第二份回報晚了七週,8 月 12 日,另一位研究者 cryptocode 送來一個完全不同的問題:連線 handshake 的身分驗證是假的, 跟第一個漏洞的觸發條件完全不一樣。這一天正好落在第一份回報三個月緩衝期的中段——第一個漏洞當時還沒對外公開。

漏洞 所在層 回報者 回報日期 攻擊者需要什麼條件
明文傳輸——沒有加密、沒有身分驗證 傳輸層(Noise wrapper) Konstantinos Maninakis 2026-06-24 要站在兩個節點之間的網路路徑上,才讀得到封包內容
Handshake 冒充——可宣告任意 Node ID 連線 handshake cryptocode 2026-08-12 不必在路徑上,但得先知道或猜中一個被允許的 Node ID(清單不公開)
兩份回報相隔七週,各自獨立送達;官方於 2026-09-23 一併公開揭露。

官方揭露頁面上找不到 CVSS 分數,兩個漏洞的嚴重程度全靠 critical 這個標籤跟上面這張表撐起來。

把時間攤開來看,兩份回報之間隔了七週:Maninakis 是在做自己的專案時意外撞見明文傳輸,cryptocode 回報的 則是連線身分驗證的問題,兩份回報的時間不同、觸發條件也不同。Maninakis 在文末的 自我介紹是「a software engineer busy with peer-to-peer and Web-native systems」,平常也接系統設計、 網頁應用跟開發工具方面的委託案。

研究者用來截包的,是 Wireshark 跟 tcpdump,兩套網路工程師書架上的常備工具,不是什麼特製的攻擊設備。

攻擊者在網路中間看到了什麼

官方寫道:「The main concern is information leakage, i.e., attackers along the network path between two nodes reading objects in transit」。任何站在兩個節點之間、能看到封包的人,都能直接讀走裡面的資料, 不需要破解什麼加密,因為根本沒有加密。

對維運私有 repo 的團隊來說,讀得到內容這幾個字換算成實際影響:程式碼本身、commit 訊息,以及可能出現在 裡頭的內部路徑或設定片段,只要曾經透過網路傳輸過,按官方的定義都算是暴露過。這種暴露大概不容易留下痕跡—— 被動竊聽是單純觀察路徑上的封包,不需要主動連線到任何一端的節點;依賴連線紀錄偵測異常的做法,可能也起不了 作用,因為被動竊聽的攻擊者從頭到尾不會出現在任何一方的連線紀錄裡。

這個風險對公開 repo 幾乎沒有意義——內容本來就公開,讀到也不算洩漏;但官方的原話是 「For public repositories, information leakage is less of a concern. However, encryption in transit is crucial for private repositories」。對只經營公開專案的節點來說,這則揭露的影響有限;對有私有 repo 在網路上跑的節點來說,這是修復出來之前每天都存在的風險。

這個漏洞打穿的是機密性——官方講得很明確:「An attacker cannot forge code or identities」。Git object 與 Signed References 的完整性驗證沒有被打穿,攻擊者讀得到內容,但沒辦法偽造程式碼、也不能假裝自己寫過某個 commit。 這個區分背後的實際意義是:如果在意的是「這段 commit 歷史是不是真的」,這次的漏洞不影響判斷;如果在意的是 「這份內容有沒有外流」,走過明文網路的內容都算數。

下面這個模擬把這件事畫出來:同一組往返的封包,在目前的協定下跟換成 iroh 之後,站在路徑中間的攻擊者實際看到的東西完全不一樣。

切換目前協定/iroh 之後,看攻擊者的視角怎麼變 · 可播放/暫停

t = 0.0s
目前協定:封包以明文往返,攻擊者讀得到內容。
示意動畫:目前協定的行為依官方揭露內容繪製;iroh 之後的攻擊者視角是本文根據換底層的目的所做的示意, 官方未描述其實際運作細節;封包時間與畫面比例同樣為示意,非官方釋出的真實封包紀錄。

示意動畫:目前協定的行為依官方揭露內容繪製;iroh 之後的攻擊者視角是本文根據換底層的目的所做的示意, 官方未描述其…

目前協定下,路徑中間的攻擊者能直接讀走封包內容、也能假冒任一端的身分;換成 iroh 之後,預期讀不到內容,冒充也預期被拒絕。

上面這個模擬同時也埋了一個伏筆——攻擊者除了讀封包,還嘗試冒充節點 B 的身分去騙過節點 A 的允許清單。 這正是第二個漏洞在做的事,跟被動竊聽是完全不同的路。

允許清單擋的是名字,不是身分

私有 repo 的存取控制邏輯很簡單:「Private repositories are shared only with allow-listed Node IDs」—— 節點只看對方宣告的 Node ID 在不在名單上。照一般 handshake 的設計來推,這個宣告應該要經過驗證、證明對方真的握有那個 Node ID 對應的私鑰;官方寫的是「Peer authentication in the connection handshake is broken and allows impersonation」,cryptocode 回報的漏洞正好打在這一步。

允許清單原本的設計假設是:能連上你、還能報出正確 Node ID 的,就是那個 Node ID 的主人——這個假設建立在 handshake 之上:後面所有「這個 Node ID 在不在允許清單上」的判斷,都得先通過對方證明自己身分的驗證。 本文的推論是,這一步應該包含握有該 Node ID 對應私鑰的證明,官方文件本身沒有描述 handshake 的設計細節。 cryptocode 回報的漏洞,打的正是這一整條鏈:handshake 驗證是壞的,允許清單這一步就跟著失守。

官方寫道:「An attacker can connect to your node and present a Node ID that is not its own」—— 它可以直接連上你的節點,自稱是任何一個 Node ID,節點沒有辦法戳破這個謊。組合起來,後果是: 「An attacker who fakes an allow-listed Node ID can fetch a private repository directly, without being on the network path」。這條路不需要攻擊者卡在網路中間,但官方自己也替它加了一個限定:「The second flaw is harder to exploit on its own than it sounds. To impersonate an allow-listed Node ID, an attacker must first know one. The allow-list is not public, so an attacker who is not on the network path has to guess」——不在路徑上的攻擊者,得先知道或猜中一個被允許的 Node ID 才動得了手,清單本身並不公開。 官方給的「現實威脅」畫像是:「The realistic threat is anyone on the path between your node and node it syncs with, and no setting or allow-list protects against them」——真正該提防的,還是那個本來就站在 路徑上的人。

點每一層看它該做什麼、實際做到了什麼 · 4 層

傳輸層(Noise wrapper) 該加密卻沒加密 Handshake(身分驗證) 可宣告任意 Node ID 允許清單(存取控制) 只檢查名字,不驗證身分 儲存層(Git object+Signed References) 仍然擋住偽造

傳輸層 · 該做什麼

應該把兩個節點之間的所有資料加密,讓路徑上的第三者讀不到內容。

實際狀況:程式碼把 Noise handshake 完成跟 SOCKS5 代理協商完成共用同一個 is_complete() 判斷式,該加密的資料被當成明文直接穿透。

Handshake · 該做什麼

這裡採信的推論是:這一步應該證明連線對面真的握有它所宣告 Node ID 對應的私鑰,是身分驗證,不是自我申報;官方文件沒有描述這一步的設計細節。

實際狀況:驗證是壞的,攻擊者可以直接宣告任何 Node ID,節點沒有辦法戳破。

允許清單 · 該做什麼

應該只把私有 repo 交給名單上、且身分已通過驗證的 Node ID。

實際狀況:這一層信任上一層已經驗證過身分,自己只比對名字,於是第二層的破口直接讓這層失守——攻擊者甚至不必在網路路徑上。

儲存層 · 該做什麼

Git object 與 Signed References 的完整性驗證,確保內容沒被竄改、作者身分沒被偽造。

實際狀況:這一層沒有被攻破。攻擊者能讀走內容,但沒辦法偽造程式碼或身分——這是官方自己劃出的安全邊界。

互動圖表

私有 repo 的存取控制疊了四層,破的是傳輸加密與 handshake 身分驗證,允許清單被繞過,儲存層仍擋住偽造。

往下一層看,壞掉的兩層剛好都在傳輸層跟 handshake,是這次修法要整個換掉的部分;允許清單的失守, 源頭就在這兩層。

把四層拆開來看還有一個好處:它讓「修好了沒有」這個問題有了具體的檢查清單——傳輸層加密回來了嗎? handshake 真的驗證私鑰了嗎?只要這兩層站得住,允許清單自然就恢復原本該有的作用。對正在用允許清單 管控私有 repo 的節點來說,清單內容本身應該不用改;需要處理的,是驗證這份清單的那一步。

換成 iroh:不相容,但沒有回頭路

官方給的解法不是修補,是整個換底層——「The resolution involves replacing Radicle's networking protocol (currently a custom protocol using Noise) with iroh, an open source peer-to-peer networking stack built on open standards」。現在在用的協定是自製的、以 Noise 為基礎,換成的是一套照開放標準做的 P2P 網路堆疊; 這次換底層並不是臨時起意的替代方案——官方自己寫道:「We already shared out plans to migrate, and the vulnerabilities have given us all the more reason to move forward with this」,遷移到 iroh 原本就在 計畫裡,這兩個漏洞給的是往前推進的理由。

官方也順帶點出這次換底層的額外好處:「Beyond addressing the vulnerabilities, iroh brings additional features like NAT traversal which improve the reliability and resilience of the Radicle network」。 NAT traversal 要處理的,是節點窩在家用路由器或防火牆後面,平常很難讓別人主動連進來的問題;有了這個機制, 不需要手動開 port forwarding,兩個節點也比較容易直接談成一條連線。換句話說,這不是單純為了補洞才做的遷移, NAT traversal 這類功能是修復漏洞之外,官方自己列出的附加價值。 真正的技術取捨(效能、相容性細節)要等實際的遷移文件或程式碼出來才看得到;對還在評估要不要自架 Radicle 節點的團隊來說,目前能確認的只有方向:換成 iroh、遲早要跳大版本。

為什麼不能只補一個安全性 patch?官方的說法是:「Due to a lack of version negotiation features, combined with the fix being incompatible on the wire, a backward compatible mitigation is not feasible; that means the release to fix this issue will be breaking, thus bump the major version number. Work towards this is under way」。協定裡沒有版本協商的機制,代表沒辦法讓新舊節點在同一個網路裡互相辨識、優雅降級,修復只能是一次打破相容性的大改版。

官方寫道:「Such a change to the network transport is by its nature backwards-incompatible. As such, it causes the network to partition into the upgraded and non-upgraded clusters that can not communicate with each other」。版號跳大版本,對使用者來說代表一次不能安靜跳過的升級——本文傾向的理解是, 舊版節點不會自動知道新版節點在講不同的協定,兩邊會嘗試握手、失敗,然後各自留在原本的網路裡,變成兩群連不上話 的網路;官方原文只講到分裂的結果,沒有描述握手失敗的具體過程。

Maninakis 對修法後果的推算更具體:「Whichever way you fix it, the wire changes. Every message gets 16 bytes longer, anything big gets chopped into pieces, and every piece needs a length header in front of it… an unfixed node reading a fixed node's stream sees garbage, and vice versa. It's a breaking change to the Radicle protocol, and that's why the fix ended up being a whole 2.0.」——這是他自己的 推算,官方揭露文件本身只寫「bump the major version number」,沒有點名版號。訊息變長的原因,issue 裡有 具體數字:「Noise ciphertext messages are limited to 65535 bytes, including the authentication tag (which is 16 bytes long)」——Maninakis 說的每則訊息多出來的 16 bytes,對應的正是這段驗證標籤。

官方也沒有給出明確的發布時程——對還在用 Radicle 傳輸私有 repo 的團隊來說,暫時解法(停用私有 repo 的網路功能) 目前沒有明確的終點。Radicle 是點對點網路,沒有中心伺服器可以統一推播升級——這類 breaking change 最終能不能 順利完成,得看有多少節點自己動手跟上。

官方自己也把這次的影響範圍講得很窄:「Both flaws are in the node transport layer, not in the repository data model」。官方另外說明的是努力方向:「we are working to make the upgrade path as smooth as possible, by focusing breakage on the network end, keeping storage layout compatible」——把破壞侷限在網路端、 儲存格式盡量維持相容,是官方正在朝著努力的目標,不是已經拍板的保證;真正確定要變動的,是節點之間怎麼談 連線這一層。

拖曳分隔線比較換協定前後的網路形狀

舊:自製協定(Noise) node node node node 單一網路,都講同一種協定 沒有版本協商欄位 新:iroh 已升級 已升級 未升級 未升級 ✕ 分裂成兩群 彼此連不上話
官方原文:「it causes the network to partition into the upgraded and non-upgraded clusters that can not communicate with each other」。圖中節點數量為示意,非官方公布的實際拓樸規模。

官方原文:「it causes the network to partition into the upgraded …

換成 iroh 後,沒有版本協商機制能相容,新舊節點會分裂成兩群完全連不上話的網路,修復只能是一次 breaking release。

分裂成兩群不代表資料會不見——同一個 repo 的內容,只要還留在舊協定的節點上,理論上還讀得到;只是新舊兩群節點 沒辦法再互相同步。

停用私有 repo,等修復版本

在修復版本出來、大家陸續升級之前,官方選擇先叫大家停用私有 repo 的網路功能。官方寫道:「We recommend to stop using private repositories until a fix is released」。另一句是:「Consider every private repository you have transmitted over the network to another node leaked」——只要曾經透過網路傳輸過的 私有 repo,就該當作已經外洩處理。官方也給了具體的操作指令:先用 rad ls --private --all 列出自己有哪些私有 repo,逐一用 rad block <RID> 停止散布,必要時直接 rad node stop 整個停掉節點。

這三個指令對應的,正是最前面那兩個漏洞疊加後的風險範圍:被動竊聽跟主動冒充,都得靠私有 repo 還在網路上分享才有效。

點每個指令看私有 repo 的狀態怎麼變 · 3 個指令

  • private-repo-a透過網路分享中
  • private-repo-b透過網路分享中
  • private-repo-c透過網路分享中

預設狀態:三個私有 repo 都還在透過網路分享——按官方的建議,這代表內容都該當作已經外洩處理。

示意圖,repo 名稱與畫面為示意,非官方畫面截圖;三個指令的用途依官方文件描述繪製,跑完指令不代表漏洞本身已修復。

從 6 月 24 日第一份回報,到 8 月 12 日第二份回報,再到 9 月 23 日公開,中間隔的是 Maninakis 自述的 「a roughly three-month embargo」。至於為什麼選在修復版本還沒出來就先公開,官方自己講得很直接: 「We are publishing this before the security update is available. You can act on it today, and no fix we release later can undo an exposure that has already happened」——修復還沒出來,但使用者現在就能 採取行動,而事後的修復沒辦法撤銷已經發生的外洩。官方在文末謝了兩位研究者——「for responsibly disclosing these vulnerabilities to us and staying in touch」。Maninakis 自己給的建議是:「If you have fetched, or still fetch, or seeded to others, a private repository over clearnet, treat its contents as having been exposed to public eyes」,跟官方的正式建議在結論上是一致的。

合理的推測是,這次事件對其他協定設計也是一個提醒:版本協商欄位看起來是可有可無的細節,一旦漏掉,往後任何 安全性修復都可能得用一次不相容的大改版才能生效。

眼下能做的事:如果你的節點上有透過網路傳輸過的私有 repo,先假設內容已經外洩,跑一次 rad ls --private --all 看看有哪些,用 rad block 逐一擋掉;修復版本出來之前, 不要再靠這條網路分享任何私有內容。