Once the key is safe, who gets to ask for a signature? Custody lessons from Binance in 2019

Assume an attacker controls the withdrawal service and still has valid credentials for the signer. Use those credentials in an acceptance test to check whether the service can obtain a signature for a withdrawal that should be refused, even while the key remains inside the signing system.

Treat the reported 2026 Binance incident as a lead awaiting verification. TRM assessed that the most likely explanation was an ability to make Binance’s hot wallet signing system sign transactions of the attacker’s choosing across chains. Its account describes EIP-7702 delegation, meaning wallet-signed authorisations pointing to code that acts for the wallet, alongside ordinary signed transfers on other chains.

What the 2019 announcement actually supports

The useful detail in Binance’s 2019 account is the timing: a transaction withdrawing 7,000 BTC triggered alarms after it had executed, and withdrawals were stopped afterwards. Binance said the transaction was structured in a way that passed its existing security checks. It named SAFU, the fund it promised would cover the incident in full.

The announcement identified stolen user API keys, 2FA codes and potentially other information; the archived text contains no mention of a private key. I would leave the key’s status unresolved on that evidence. For the design exercise, assume a compromised upstream service and make no claim about Binance’s internal attack path.

Give each responsibility a separate test

Use these five responsibilities when designing the system. Require evidence at each boundary instead of accepting an upstream service’s assurance that everything has been approved.

ResponsibilityProposed boundary
Account authenticationEstablish the caller’s identity and pass verifiable evidence of it.
API permissionsDecide which endpoints that identity may call; leave individual withdrawal approval to its own check.
Withdrawal approvalRecord an approver’s consent to the specific transaction contents.
Risk assessmentAssess unusual activity and decide whether to refuse or refer for manual review.
Cryptographic signingProduce a signature over the supplied contents, with the signing service separately enforcing its prerequisites.

Carry the approval to the signer

Assume an HSM holds the key: a hardware security module that keeps key material and performs cryptographic operations. PKCS #11 prohibits certain private-key attributes from being revealed in plaintext outside the token when CKA_SENSITIVE is true or CKA_EXTRACTABLE is false.

A KMS, in this model, is a service that manages keys and accepts signing requests through an API. AWS KMS’s DIGEST mode requires a message length matching the selected algorithm’s digest length. Do not count that length check as withdrawal approval.

MPC here means parties holding separate pieces of key material and cooperating to sign. NIST describes threshold computation without having to recombine those pieces, while also describing how enough shares can reconstruct a key. This does not establish that all schemes avoid reconstructing the key at all times.

The diagram is a hypothetical custody system, with no claim to describe Binance’s internals. In the intended path, a withdrawal passes policy checks and independent approval before signing and broadcast. The red path assumes the compromised withdrawal service calls the signer directly, and the signer trusts its identity alone.

Hypothetical custody flow through policy checks, approval, signing and broadcast; the red dotted path shows a compromised withdrawal service bypassing the checks

Treat approval evidence as part of the signing request, with the signer checking that it belongs to the transaction. Keep the authority to enrol approvers outside the withdrawal service too. Otherwise, in this model, the attacker could change who counts as an approver. An “approved” flag from that same service would be equally unhelpful.

An example with approval tied to contents

Model the decision at the signer with two functions: one signs whatever arrives, and the other enforces its own allowlist, amount limit and approvals. Use HMAC with dummy keys to stand in for the cryptography. This example does not describe AWS KMS behaviour.

import hashlib, hmac, json
from dataclasses import dataclass

# A hypothetical custody model. The HMAC key stands in for a non-exportable
# signing key inside an HSM; the point is who can ask it to sign, so no real 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:
    # Signs for any permitted caller without checking the transaction
    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("destination not on allowlist")
    if tx["amount"] > POLICY.per_tx_limit:
        raise PermissionError("over per-transaction limit")
    for who in sorted(POLICY.required):
        want = approve(who, tx)  # a real system verifies the approver's public-key signature
        if not hmac.compare_digest(approvals.get(who, ""), want):
            raise PermissionError(f"no {who} approval for this transaction")
    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], "(signed)")
print("policy legit:", policy_signer(legit, ok)[:16])
cases = [
    ("compromised withdrawal service sends evil", evil, {}),
    ("legit approvals reused for evil", evil, ok),
    ("allowlisted address, amount changed", {**legit, "amount": 4_999}, ok),
    ("over the limit", {**legit, "amount": 9_000}, ok),
]
for name, tx, ap in cases:
    try:
        policy_signer(tx, ap)
        print(f"{name}: signed")
    except PermissionError as e:
        print(f"{name}: refused, {e}")
naive  evil : f1a2c0547991bea7 (signed)
policy legit: 629d32ce5d44f73a
compromised withdrawal service sends evil: refused, destination not on allowlist
legit approvals reused for evil: refused, destination not on allowlist
allowlisted address, amount changed: refused, no ops approval for this transaction
over the limit: refused, over per-transaction limit

The amount-change case is the useful approval test in this model. A request for 4,999 still meets the amount limit, but the approval for 1,200 does not match it. Both requests to the attacker’s address stop earlier at the allowlist, so neither exercises approval verification. The request for 9,000 stops at the limit check.

Do not ship the example as a production approval service. Use separate approver private keys, verify their signatures at the signer and bind a policy version into each approval. Add expiry and a record of consumed approvals. The example merely includes nonce in the digest; treat replay prevention as work still to do.

Acceptance starts with a compromised service

Run acceptance tests with the withdrawal service’s credentials already in hostile hands. Check whether it can obtain signatures for any of these requests:

  • A direct call that skips policy checks and approvals.
  • An old approval attached to a different transaction, including a changed amount or recipient.
  • One approver presented as both required approvers.
  • A withdrawal above the limit or to an address still in its cooling-off period.
  • A request after credentials have been revoked or emergency signing suspension has taken effect.

Require refusal with a reason recorded at the signer, and run the tests in CI whenever policy changes. Add exact-request replays and expired approvals too. For related design questions, see default hard caps and kill switches.

Budget for the controls

Choose the approval arrangement with its staffing cost visible. If risk and operations must both approve manually, budget overnight cover. For address cooling-off periods, put the customer’s waiting time in the requirements. For cumulative limits, budget counter maintenance and a manual route for large withdrawals.

Plan the stop and its recovery together. Give revocation an independent route so the compromised service does not control it. Name who may lift an emergency stop, and include the cost of legitimate withdrawals being unavailable after an accidental stop. Accept those costs before relying on the controls.

Measure how long stopping actually takes. AWS’s DisableKey documentation says disabling prevents cryptographic use and notes the API’s eventual consistency model; it offers no immediate-effect guarantee or fixed number of minutes. Keep probing signing attempts during a stop and make the observed delay part of acceptance.

Still unclear

The incident sources cited here were checked on 1 November 2026. TRM then said Binance had yet to publish a root cause, while Binance’s 30 October 2026 announcement said it had found no evidence of leaked wallet private keys at that stage. These statements do not establish the investigation’s status on this article’s publication date. The 2019 “other information”, individual account permissions and internal signing architecture also remain unspecified here.

Notes

  • The 2019 announcement named API keys and 2FA codes; its archived text did not mention private keys.
  • Require the signer to verify approval bound to the complete transaction.
  • Start acceptance with a compromised caller and include replay attempts and stop propagation.
  • Include customer waiting time and human approval cover in the requirements.

Further reading

  • Binance Security Breach Update: Binance’s own account, cited from the Wayback snapshot of 12 May 2019, 10:08:08 UTC; use it for the company’s statements without treating it as a complete forensic finding.
  • TRM Labs’ analysis of the reported 2026 incident (accessed on 1 November 2026): an investigator’s assessment, whose proposed signing-system compromise remains an assessment here.
  • Security Incident Update #2: Binance’s statement, cited from the Wayback snapshot of 30 October 2026, 22:01:35 UTC; its absence-of-evidence claim is limited to that stage of the investigation in that snapshot.
  • PKCS #11 v3.2: the OASIS specification for the attribute restrictions cited here; use it to check the rule, without taking it as assurance about a particular device.
  • AWS KMS Sign: the official API reference for hashing and length checks; do not extend those documented checks into a transaction-approval guarantee.
  • NIST IR 8214: NIST’s treatment of threshold cryptography, suitable for checking the general definitions without assuming every MPC product implements the same scheme.
  • AWS KMS DisableKey: the official API reference for disabling a key, which provides no fixed propagation time.

Ideas and technical judgement by Sheng; drafted with Claude · topic chosen by Sheng; text, case notes and examples written by Claude, run on Linux with Python 3.13.18.