簽章都正確,交易為何還是錯的:Bybit 與 Safe 的簽署供應鏈

2025 年 2 月 21 日,Bybit 的 ETH 冷錢包在例行轉帳時簽出一筆惡意交易。這筆交易沒有直接轉走資產,它把錢包的邏輯換掉;約 40 萬 ETH,以及 stETH、cmETH、mETH,是換完之後另外轉走的,那時錢包已經不再檢查原本的 3 個簽章。換邏輯的那筆湊滿 3 個有效簽章,Safe 合約照程式執行,公開分析指出簽署者看到的內容都來自同一個被竄改的前端。簽章驗得過,代表這把金鑰簽了這組欄位,欄位和簽署者看到的是不是同一筆,要另外核對。

這筆交易用 delegatecall 執行攻擊者的合約,這種呼叫借別人的程式碼來跑,讀寫的卻是自己的儲存空間。Safe 錢包是 proxy 合約,呼叫都交給 implementation 合約,其位址存在 slot 0(儲存空間第一格),攻擊者把它改成自己的合約,錢包邏輯就換了。

公開調查確認了哪些步驟

  • 開發者工作站與雲端存取:Cointelegraph 轉述 Safe 與 Mandiant 在 2025 年 3 月公布的調查:Safe 的 AWS 設定每 12 小時要重新驗證,攻擊者幾次嘗試註冊自己的 MFA 裝置都沒成功。之後他們入侵一名 Safe 開發者的 macOS 電腦,文中寫可能透過惡意程式,再沿用還有效的 AWS session token,不必再過 MFA。
  • 部署產物:BleepingComputer 轉述 Bybit 執行長引用的 Sygnia 調查,惡意 JavaScript 由 Safe{Wallet} 的 AWS S3 bucket 提供,檔案在攻擊前兩天被改過。SlowMist 轉述的 Bybit 聲明寫的是 2 月 19 日。SlowMist 依 urlscan 紀錄指出檔案是 _app-52c9031bfa03da47.js,Wayback Machine 有 2 月 19 日 17:29 UTC 的快照。
  • 觸發:NCC Group 分析快照後認為,惡意碼只在 Safe 地址符合清單、原交易是一般 call(operation == 0)時才替換欄位。
  • 鏈上執行:Verichains 記錄交易時間為 2 月 21 日 14:13:35 UTC,to 是 0x9622…7242、operation 是 1、data 是 transfer(address,uint256) 的 calldata。
  • 前端換回:Sygnia 說交易執行後約兩分鐘,S3 的 JavaScript 換回沒有惡意碼的版本。
  • 排除範圍:Sygnia 在 Bybit 自家基礎設施沒發現入侵證據,Safe 說外部審查沒在智慧合約、前端與服務的原始碼找到漏洞。

FBI 在 2025 年 2 月 26 日的公告將攻擊歸於北韓,稱相關活動為 TraderTraitor。

Bybit 事件的公開調查路徑:攻擊者註冊 MFA 失敗後入侵 Safe 開發者的 macOS 工作站,沿用還有效的 AWS session token,S3 上的前端 JS 在 2 月 19 日被改,簽署者打開 app.safe.global 看到例行轉帳,硬體錢包收到 operation = 1 的交易,3 個有效簽章在 2 月 21 日執行,delegatecall 改寫 slot 0 後資產被轉出,約兩分鐘後 S3 換回乾淨的 JS

三種可能的失效,修法不一樣

本案的公開分析沒有指出攻擊利用了 Safe 合約漏洞,換邏輯時仍須湊齊 3 個有效簽章,門檻檢查有執行。這不能證明合約沒有其他漏洞。

部署產物被竄改,要查發布流程。即使原始碼沒問題,瀏覽器仍可能載到被換掉的 S3 檔案,因此要管 bucket 寫入權限與告警,上線的 JS 要能對回某個 commit 的建置。

顯示和實際簽的內容是否一致,也要在簽署端核對。BlockSec 引 Bybit 執行長 Ben Zhou 的說法:簽署者在 Safe 看到普通轉帳,送到 Ledger 的是另一組內容。依 NCC Group 貼出的惡意碼,簽完之後整份交易欄位(to、value、data、operation、safeTxGas)會換回原值,再送出 proposal,簽章仍對應換過的那份。proposal 是 Safe 後端先收下、等其他人補簽的待簽交易。NCC Group 據此推論,網頁上會一直顯示原本那筆。NCC Group 的結論是簽署者盲簽(blind signing):信任 Safe 網頁,沒在裝置上仔細核對內容。Safe 事後暫時移除原生 Ledger 整合,稱那是這次的簽署方式,並加強 transaction hash、data 與簽章驗證。

3 個簽署者看的是同一份 JS

這次 3 個簽署者各有金鑰,判斷時看的卻是同一份 JS,資料被換掉就可能一起簽錯。若新增的簽署者也用同一個前端,提高門檻仍不會多出查核來源。

除了金鑰數量,還要看簽署者各用什麼軟體查核、有沒有共用 CDN 或雲端帳號,硬體錢包能不能自己解出欄位。

拿畫面上的欄位重算 hash

SafeTx hash 是 Safe 交易的摘要,由 to、value、data、operation、safeTxGas 等欄位和 Safe 地址算出,也是 EIP-712 簽章簽的值。簽署前用獨立工具從畫面欄位重算,和硬體錢包要簽的 hash 比對,欄位被換過就對不上。

範例照 Safe 1.1.1 算,公開分析指出 Bybit 的 implementation 是 1.1.1,domain 只有 verifyingContract,沒有 chainId。畫面交易是虛構轉帳;送簽交易的 to、data、operation、safeTxGas 取自公開分析,nonce 虛構,hash 不會對應鏈上那筆。

from Crypto.Hash import keccak

def k(b: bytes) -> bytes:
    return keccak.new(digest_bits=256, data=b).digest()

def word(x) -> bytes:
    if isinstance(x, str):
        x = int(x, 16)
    return x.to_bytes(32, "big")

SAFE_TX_TYPEHASH = k(b"SafeTx(address to,uint256 value,bytes data,uint8 operation,"
                     b"uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,"
                     b"address gasToken,address refundReceiver,uint256 nonce)")
DOMAIN_TYPEHASH_V111 = k(b"EIP712Domain(address verifyingContract)")  # Safe 1.1.1 沒有 chainId

def safe_tx_hash(safe: str, tx: dict) -> str:
    domain = k(DOMAIN_TYPEHASH_V111 + word(safe))
    body = k(SAFE_TX_TYPEHASH + word(tx["to"]) + word(tx["value"]) + k(bytes.fromhex(tx["data"]))
             + word(tx["operation"]) + word(tx["safeTxGas"]) + word(0) + word(0)
             + word(0) + word(0) + word(tx["nonce"]))
    return "0x" + k(b"\x19\x01" + domain + body).hex()

SAFE = "0x1db92e2eebc8e0c075a02bea49a2935bcd2dfcf4"
# 畫面上顯示的交易:假設性的例行轉帳,收款地址是虛構的
shown = {"to": "0x00000000000000000000000000000000000000b0", "value": 10**18,
         "data": "", "operation": 0, "safeTxGas": 0, "nonce": 0}
# 送到硬體錢包簽的交易:to、data、operation、safeTxGas 取自公開分析,nonce 是虛構的
signed = {"to": "0x96221423681a6d52e184d440a8efcebb105c7242", "value": 0,
          "data": "a9059cbb" + "bdd077f651ebe7f7b3ce16fe5f2b025be2969516".rjust(64, "0") + "0" * 64,
          "operation": 1, "safeTxGas": 45746, "nonce": 0}

DELEGATECALL_ALLOWLIST = {"0x00000000000000000000000000000000000000c1"}  # 虛構:自己審過的 MultiSend 部署位址

def review(tx: dict) -> list[str]:
    findings = []
    if tx["operation"] == 1 and tx["to"].lower() not in DELEGATECALL_ALLOWLIST:
        findings.append("delegatecall 目標不在允許清單:%s" % tx["to"])
    if tx["data"][:8] == "a9059cbb" and tx["value"] == 0 and tx["operation"] == 1:
        findings.append("用 delegatecall 呼叫 transfer(address,uint256),函式名稱不代表行為")
    return findings

h_shown, h_signed = safe_tx_hash(SAFE, shown), safe_tx_hash(SAFE, signed)
print("domain type", "0x" + DOMAIN_TYPEHASH_V111.hex())
print("safetx type", "0x" + SAFE_TX_TYPEHASH.hex())
print("shown hash ", h_shown)
print("signed hash", h_signed)
print("match      ", h_shown == h_signed)
for f in review(signed):
    print("finding    ", f)
domain type 0x035aff83d86937d35b32e04f0ddc6ff469290eef2f1b692d8a815c89404d4749
safetx type 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8
shown hash  0xb1e3a4fa5d96c2c924777da4cf354a710ddc318f0f6688cb5df071e43f85e718
signed hash 0x20eb91008f8bcae8517c47d4311fc4016fef61ed72406eb2eee404dd9ca2369c
match       False
finding     delegatecall 目標不在允許清單:0x96221423681a6d52e184d440a8efcebb105c7242
finding     用 delegatecall 呼叫 transfer(address,uint256),函式名稱不代表行為

兩個 typehash 和 Safe 1.1.1 原始碼的常數一致,整段算法還要拿已知鏈上交易驗過(需驗證)。比對工具須使用獨立於受害前端的資料,發現 hash 不符就拒簽。只有在剩餘簽署者無法湊足門檻時,拒簽才會擋下交易。

review 的兩項檢查都針對 operation = 1 的 delegatecall。Verichains 與 NCC Group 的反組譯顯示,攻擊合約的 transfer 沒做 ERC-20 轉帳,只把第一個參數寫進 slot 0。所以 to 是被 delegatecall 執行的程式,寫進 slot 0、成為新 implementation 的是 calldata 裡另一個地址。解碼器只看選擇器 0xa9059cbb 會顯示轉 0 個代幣,簽署者就可能漏掉錢包邏輯被換掉。

畫面如實顯示惡意交易但簽署者沒看懂時,hash 比對幫不上忙,得靠 delegatecall 允許清單這類規則,在簽署者之外強制執行。

解碼和模擬工具也要有可信來源

解碼器或模擬器若和受害前端共用雲端帳號或 API,結果不能當獨立佐證。查 calldata、目的地址、delegatecall 和 implementation 變更前,先確認工具來源。

  • 解碼與 hash 計算用離線或本機工具,原始碼固定版本、自己建置,不從網頁即時載入。
  • 硬體錢包優先用能顯示 EIP-712 欄位的 clear signing,至少要在裝置上看到 SafeTx hash 供比對。BlockSec 的說法是,硬體錢包看得到實際要簽的內容,Ledger 畫面上的細節應該會和網頁不同。多簽原始資料很難在小螢幕上讀,不能只靠裝置畫面。
  • 簽署者可分用官方網頁、CLI 或自架前端,部署權限和資料來源也要分開才算隔離。
  • 模擬結果只當參考,模擬服務也得驗證。

Mastra:發布權限和安裝腳本

2026 年 6 月的 Mastra 事件說明套件安裝怎麼讓開發機失陷,但機制和 Bybit 不同,解釋不了那台工作站最初怎麼被入侵。

Socket 調查指出,2026-06-17 大約 01:15 到 02:36 UTC,141 個 @mastra/* 套件透過 ehindero 發布惡意 patch 版本,Socket 說這是一個有正式發布權限的 npm 使用者帳號。新增的依賴 easy-day-js 仿冒 dayjs,1.11.22 版用 postinstall(套件裝完後自動執行的腳本)執行 setup.cjs,環境允許 install script 就會在 npm install 時跑,不必等應用程式 import。

Socket 說惡意程式會讀 Chrome、Edge、Brave 瀏覽紀錄,比對 166 個錢包擴充套件 ID,偵察主機並設定登入自動啟動。分析樣本只列出錢包擴充套件,沒複製它們的儲存資料,後續模組可能有更多能力。

Socket 沒寫帳號是被盜用還是持有者本人發布的,持有者與權限狀態需驗證;Mastra 或 npm 說明前,發布紀錄不足以認定帳號被盜,也不足以歸責個人或認定權限管理疏失。

可以在隔離環境驗收的防護

驗收在本機 fork 或測試網做,用測試 Safe 和假資產,每項假設某層已失陷,看後面的防護是否仍有效。

防護驗收條件
簽署前用獨立工具重算 SafeTx hash前端被換成會改 to 與 operation 的版本時,至少一個簽署者比對失敗而拒簽,剩餘簽署者湊不到門檻
delegatecall 允許清單在簽署者之外強制執行送一筆 operation = 1、目標不在清單的交易,guard 或簽署政策拒絕,不靠簽署者肉眼判斷。guard 是掛在 execTransaction 上的合約檢查,Safe 1.1.1 沒有,要用 guard 得換到支援的版本(版本需驗證)
proposal 送出時伺服器檢查 hash 與簽章一致簽章對應的 hash 和 proposal 欄位算出的 hash 不同時,伺服器拒收
前端產物可追溯正式環境載入的每個 JS 檔,雜湊值都能對回某次 CI 建置;手動上傳到 bucket 會觸發告警
開發機的雲端 session 權限分離用開發者的一般 session 寫正式 bucket 會失敗,必須另走需要第二人核准的部署流程
CI 與開發機安裝套件時不執行 install script在測試 repo 引入一個帶 postinstall 的套件,安裝後腳本沒有執行

和 2026 年幣安事件的暫時比較

2026 年 10 月 29 日的幣安熱錢包事件同樣有有效簽章把資產轉走,目前只能當待驗證對照。EIP-7702 讓一般地址簽一份授權,授權上鏈生效後,對這個地址的呼叫才改跑指定合約的程式。TRM Labs 觀察到 Ethereum 一筆交易含 50 個由幣安熱錢包簽的 EIP-7702 授權,另外九條鏈有不經委派的簽章轉帳,時間不一,包含較晚的 Avalanche、Polygon 轉出。TRM 評估證據「指向」熱錢包簽署基礎設施遭入侵,這仍不足以確定入侵點。

幣安共同執行長 Richard Teng 在 10 月 30 日說,攻擊者用 EIP-7702 繞過正常核准程序控制熱錢包,調查也找到原先懷疑的員工筆電以外的入侵點,完整經過仍在調查。兩案入口、錢包與簽署流程是否相同,須等正式鑑識報告(需驗證)。

尚未釐清的事

  • Safe 開發者工作站最初如何遭入侵,Cointelegraph 只寫可能透過惡意程式,BlockSec 另提到社交工程。這兩種描述可以同時成立,但還不足以還原初始入侵過程,仍須讀 Safe 原始貼文與 Mandiant 報告。
  • 惡意 JS 觸發清單的其他地址與用途,NCC Group 未完整解釋。
  • Bybit 簽署者使用的裝置型號、韌體與實際畫面,須以 Bybit 或 Safe 說明為準;NCC Group 的 Trezor 截圖是研究示範。

補充筆記

  • 簽章都有效,問題在簽署者看到的和實際簽的對不上。
  • 金鑰獨立,簽署者共用前端時資訊來源仍只有一個。
  • 用不依賴前端的工具重算 SafeTx hash 可抓出欄位替換;delegatecall 仍要另設允許清單在簽署者之外執行。
  • Mastra 案中,有發布權限的帳號引入含 postinstall 的依賴,若環境允許安裝腳本執行,npm install 時就會執行惡意程式。帳號是否遭盜用,Socket 沒有說明。這個手法解釋不了 Safe 工作站最初怎麼被入侵。

延伸閱讀

想法與技術判斷出自 Sheng,和 Claude 一起起草 · 題目由 Sheng 選定,內文、案例整理與範例由 Claude 撰寫,範例在 Linux 上以 Python 3.13.18 與 pycryptodome 4.2.0 實測。