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

> 建議把失陷的提款服務當成驗收起點，測它能否取得不該核准的簽章。本文從幣安公告的證據範圍出發，用假設模型與程式範例說明簽署端該驗什麼，以及這些檢查要付出的成本。

原文：https://sheng.page/posts/who-can-ask-the-signer-to-sign/ · 發布：2026-11-01 · 作者：Sheng

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

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 說門檻密碼運算不必先重組金鑰，也說足夠份額可以重建金鑰，不能據此宣稱所有方案都永遠不會重建。

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

![假設的託管模型流程圖：提款服務經政策檢查與核准後送交簽署，紅色虛線表示失陷服務繞過檢查直接要求簽章](/images/who-can-ask-the-signer-to-sign/custody-model.zh.png)

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

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

## 範例：改了金額，核准就不適用

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

```python
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}")
```

```text
naive  evil : f1a2c0547991bea7 (簽出來了)
policy legit: 629d32ce5d44f73a
提款服務失陷，直接送 evil: 拒絕，收款地址不在白名單
拿 legit 的核准去配 evil: 拒絕，收款地址不在白名單
白名單地址但改金額: 拒絕，缺少 ops 對這筆交易的核准
超過上限: 拒絕，超過單筆上限
```

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

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

## 驗收要拿失陷服務來測

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

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

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

## 成本要一起寫進需求

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

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

## 尚未釐清的事

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

## 補充筆記

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

## 延伸閱讀

- [Binance Security Breach Update](https://binance.zendesk.com/hc/en-us/articles/360028031711-Binance-Security-Breach-Update)：幣安的當事方公告，本文引用 2019-05-12 10:08:08 UTC 的 Wayback 快照，只取公告裡的說法，不將它視為完整鑑識結論。
- [TRM Labs 的 2026 年幣安事件分析](https://www.trmlabs.com/resources/blog/north-korea-steals-usd-6-36-billion-from-binance-in-the-largest-hack-in-history)（2026 年 11 月 1 日讀取）：調查方的分析，本文保留其評估語氣，簽署系統失陷仍待核實。
- [Security Incident Update #2](https://www.binance.com/en/support/announcement/detail/d3306d9dca5243548c813b605e1d1751)：幣安的當事方聲明，本文引用 2026-10-30 22:01:35 UTC 的 Wayback 快照，「未發現」只代表該快照中當時的調查說法。
- [PKCS #11 v3.2](https://docs.oasis-open.org/pkcs11/pkcs11-spec/v3.2/os/pkcs11-spec-v3.2-os.html)：OASIS 規範，可核對私鑰屬性的限制，本文不據此替特定設備背書。
- [AWS KMS Sign](https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html)：AWS 官方 API 文件，可核對雜湊處理與長度條件，本文不將它延伸成交易核准保證。
- [NIST IR 8214](https://doi.org/10.6028/NIST.IR.8214)：NIST 的門檻密碼學文件，適合核對一般定義，本文不將其套成所有 MPC 產品的實作保證。
- [AWS KMS DisableKey](https://docs.aws.amazon.com/kms/latest/APIReference/API_DisableKey.html)：AWS 官方 API 文件，可核對停用語意，但沒有提供固定生效時間。

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