保住私鑰之後,誰有權要求系統簽章:從 2019 年幣安事件看託管簽署的授權邊界

假設提款服務已被入侵,驗收時就讓它試著取得一張不該核准的交易簽章。本文要測這道授權邊界:即使金鑰留在原處,只認呼叫者身分的簽署端,仍可能照攻擊者的要求簽章。

2026 年幣安事件在這裡只作待驗證的引子:TRM 的評估是,最可能的解釋是攻擊者已能讓幣安的熱錢包簽署系統,跨鏈簽下其選擇的交易。報告所述的 EIP-7702 委派,是讓錢包簽出授權,交由指定程式代為執行;其他鏈則有一般簽署轉帳。

2019 年公告說了什麼

幣安在 2019 年公告中列出的失竊資料,是使用者 API key、2FA 驗證碼及「可能的其他資訊」。公告稱,一筆交易提走 7,000 BTC,交易執行後才觸發警報,接著暫停提款。它也稱這筆交易的結構通過了既有安全檢查。公告說會用 #SAFU fund 全額覆蓋這次事件,使用者資金不受影響。

這份公告的存檔全文沒有出現「private key」。我的解讀是,不能拿它證明熱錢包私鑰遭竊,也不能反過來證明私鑰安全。後文只假設上游失陷,金鑰仍在簽署端,不替事件補完攻擊鏈。

五個職責,分開驗收

建議用以下分工設計系統,別讓「前一關放行」成為後一關唯一的理由。

職責在本文模型中負責什麼
帳號驗證確認登入者身分,交付可驗證的身分證明。
API 權限決定該身分可呼叫哪些端點,不能代替單筆提款核准。
提款核准核准者同意這筆具體交易,產出綁定內容的核准證明。
風控判斷交易是否異常,決定拒絕或轉人工檢查。
密碼學簽署對指定內容產生簽章,前置條件須由簽署服務另行驗證。

把核准帶到簽署端

假設金鑰放在 HSM,也就是保管金鑰、執行密碼運算的硬體安全模組。PKCS #11 規範在 CKA_SENSITIVE 為真或 CKA_EXTRACTABLE 為假時,禁止部分私鑰屬性以明文在 token 外揭露。

KMS 在這個模型裡指代管金鑰的服務,程式透過 API 請它簽章。AWS KMS 的 DIGEST 模式要求輸入長度符合指定演算法的雜湊長度。我的建議是,別把這個長度檢查算成提款核准。

MPC 在這裡指多方各持一份金鑰材料,合作完成簽章。NIST 說門檻密碼運算不必先重組金鑰,也說足夠份額可以重建金鑰,不能據此宣稱所有方案都永遠不會重建。

下圖是本文假設,與任何公司的實際架構無關。正常流程經過政策檢查及獨立核准,再送簽署與廣播;紅色虛線假設提款服務失陷,直接呼叫只驗身分的簽署端。無論選哪種金鑰方案,都先假設它不替你決定這筆提款該不該放行。

假設的託管模型流程圖:提款服務經政策檢查與核准後送交簽署,紅色虛線表示失陷服務繞過檢查直接要求簽章

建議讓簽署端自己驗核准證明,並把核准綁定交易內容。若上游只要送一句「已核准」就過關,這個假設中的攻擊者也送得出來。

核准者的管理權限也應分開,提款服務不能自行新增核准者,或改掉簽署端認得的核准金鑰。否則在這個模型裡,失陷服務連誰算核准者都能決定,這道檢查也就失去獨立性。

範例:改了金額,核准就不適用

以下用 HMAC 模擬簽章,金鑰字串只是範例材料,不描述 AWS KMS 的行為。先把白名單與單筆上限放在簽署端,再要求風控與營運各自核准同一筆內容。

import hashlib, hmac, json
from dataclasses import dataclass

# 假設性的託管模型。HMAC 金鑰當成 HSM 裡不可匯出的簽章金鑰的替身,
# 重點在「誰能叫它簽」,所以不用真的 ECDSA。
HSM_KEY = b"never-leaves-the-box"
APPROVER_KEYS = {"risk": b"risk-approver-key", "ops": b"ops-approver-key"}

def tx_digest(tx: dict) -> str:
    return hashlib.sha256(json.dumps(tx, sort_keys=True).encode()).hexdigest()

def approve(who: str, tx: dict) -> str:
    return hmac.new(APPROVER_KEYS[who], tx_digest(tx).encode(), "sha256").hexdigest()

@dataclass(frozen=True, slots=True)
class Policy:
    allowlist: frozenset[str]
    per_tx_limit: int
    required: frozenset[str]

POLICY = Policy(frozenset({"addr-user-1", "addr-cold-1"}), 5_000, frozenset({"risk", "ops"}))

def naive_signer(tx: dict) -> str:
    # 呼叫者有權限就簽,不檢查交易內容
    return hmac.new(HSM_KEY, tx_digest(tx).encode(), "sha256").hexdigest()

def policy_signer(tx: dict, approvals: dict[str, str]) -> str:
    if tx["to"] not in POLICY.allowlist:
        raise PermissionError("收款地址不在白名單")
    if tx["amount"] > POLICY.per_tx_limit:
        raise PermissionError("超過單筆上限")
    for who in sorted(POLICY.required):
        want = approve(who, tx)  # 真實系統是驗核准者的公鑰簽章,這裡簡化
        if not hmac.compare_digest(approvals.get(who, ""), want):
            raise PermissionError(f"缺少 {who} 對這筆交易的核准")
    return naive_signer(tx)

legit = {"to": "addr-user-1", "amount": 1_200, "chain": "eth", "nonce": 7}
evil = {"to": "addr-attacker", "amount": 1_200, "chain": "eth", "nonce": 7}
ok = {w: approve(w, legit) for w in POLICY.required}

print("naive  evil :", naive_signer(evil)[:16], "(簽出來了)")
print("policy legit:", policy_signer(legit, ok)[:16])
cases = [
    ("提款服務失陷,直接送 evil", evil, {}),
    ("拿 legit 的核准去配 evil", evil, ok),
    ("白名單地址但改金額", {**legit, "amount": 4_999}, ok),
    ("超過上限", {**legit, "amount": 9_000}, ok),
]
for name, tx, ap in cases:
    try:
        policy_signer(tx, ap)
        print(f"{name}: 簽出來了")
    except PermissionError as e:
        print(f"{name}: 拒絕,{e}")
naive  evil : f1a2c0547991bea7 (簽出來了)
policy legit: 629d32ce5d44f73a
提款服務失陷,直接送 evil: 拒絕,收款地址不在白名單
拿 legit 的核准去配 evil: 拒絕,收款地址不在白名單
白名單地址但改金額: 拒絕,缺少 ops 對這筆交易的核准
超過上限: 拒絕,超過單筆上限

這個模型的前兩個惡意請求都先被白名單擋下,尚未測到核准檢查。把合法交易金額由 1,200 改成 4,999,才走到核准檢查並失敗;9,000 則先超過上限。請用這些差異判讀輸出,別把每次拒絕都算成同一道防線的功勞。

實作時應讓核准者持有各自的私鑰,由簽署端驗公鑰簽章,避免照抄共享 HMAC 金鑰。也應加入核准有效期與防重放紀錄,並綁定政策版本;範例中的 nonce 只列入雜湊,請勿當成已完成防重放。

驗收要拿失陷服務來測

建議用失陷提款服務的憑證跑驗收,檢查它能否讓下列請求取得簽章:

  • 跳過政策與核准,直接要求簽章。
  • 挪用舊核准,或核准後改收款地址、金額。
  • 用同一位核准者冒充第二位。
  • 超過限額,或提到仍在冷卻期的新地址。
  • 憑證已撤銷,或緊急停用後繼續送請求。

這些測試應全部拒絕,原因要留在簽署端紀錄,每次修改政策都放進 CI 重跑。另測同筆請求重送與過期核准,不要只測內容被改過的情況。限額與停用可接著看〈沒設定就當成零:預設硬上限、four-eyes 門檻與 kill switch 故障方向〉。

成本要一起寫進需求

建議把等待時間與營運負擔一起估進去。地址冷卻期要讓使用者等;累計限額要維護計數,大額提款要安排人工處理。獨立核准應分開保管權限,若由人操作,就要排夜間輪值。撤銷應有獨立路徑,緊急停用則要訂好解除權限,並承擔誤停時合法提款也停擺的代價。

停用多久生效,請實測。AWS 的 DisableKey 文件說停用會阻止金鑰用於密碼運算,也註明 API 採最終一致性,沒有提供立即生效或固定分鐘數的保證。建議持續探測簽署結果,把停用延遲列入驗收。

尚未釐清的事

本文引用的事件來源查核至 2026 年 11 月 1 日。TRM 當時稱幣安尚未公布根因,幣安 2026 年 10 月 30 日的公告則稱當時未發現錢包私鑰外洩的證據。這些說法不能代表本文發文日的調查進度,2019 年公告所稱的「其他資訊」、個別帳號權限與內部簽署架構,也不在本文可確認的範圍內。

補充筆記

  • 2019 年公告列出 API key 與 2FA 碼,該公告存檔沒有提到私鑰。
  • 建議簽署端自行驗核准,並讓核准綁定完整交易內容。
  • 驗收從服務已失陷開始,補測重放與停用生效時間。
  • 白名單等待時間與人工核准輪值,應一起列入需求。

延伸閱讀

  • Binance Security Breach Update:幣安的當事方公告,本文引用 2019-05-12 10:08:08 UTC 的 Wayback 快照,只取公告裡的說法,不將它視為完整鑑識結論。
  • TRM Labs 的 2026 年幣安事件分析(2026 年 11 月 1 日讀取):調查方的分析,本文保留其評估語氣,簽署系統失陷仍待核實。
  • Security Incident Update #2:幣安的當事方聲明,本文引用 2026-10-30 22:01:35 UTC 的 Wayback 快照,「未發現」只代表該快照中當時的調查說法。
  • PKCS #11 v3.2:OASIS 規範,可核對私鑰屬性的限制,本文不據此替特定設備背書。
  • AWS KMS Sign:AWS 官方 API 文件,可核對雜湊處理與長度條件,本文不將它延伸成交易核准保證。
  • NIST IR 8214:NIST 的門檻密碼學文件,適合核對一般定義,本文不將其套成所有 MPC 產品的實作保證。
  • AWS KMS DisableKey:AWS 官方 API 文件,可核對停用語意,但沒有提供固定生效時間。

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