Nobody Stole the Keys

Bitget's forensic reports describe a tool that filled in its own risk checks. The first fix I tried broke.

Single continuous ink line passing through a gate and doubling back on itself
nobody-stole-the-keys Bitget says no private key was stolen. The forensic reports show a withdrawal tool that forged its own risk-control parameters. What an approval step has to check, and the toy attack that broke the first fix. Bitget hack, withdrawal approval, forged risk-control parameters, signer verification, Mandiant, SlowMist, custody controls, Bybit blind signing, aggregate withdrawal cap, exchange security

On 24 September someone drained Bitget's hot and warm wallets, and the exchange says no private key was stolen. The money left through Bitget's own withdrawal process, which did what it was built to do. The question is what that process was checking.

TL;DR

Make the approver read a signed record the backend cannot write, bound to destination, asset, amount and id, single-use, with a window cap enforced at the approver. In my toy test an id-and-amount-only ledger still lost 23 of 23 forged transfers.

What Bitget said first

The first account was the CEO's. Decrypt, on 25 September, quotes Gracy Chen: "They did not forge user withdrawal requests, nor did they obtain our private keys of the cold wallet and any hot, warm wallet." The same piece says attackers broke into a backend system in the wallet infrastructure and used it to spoof transaction data, so that the exchange's own authorization process approved the payouts. That was a company a day into an incident, summarizing.

What the forensic reports say

Five days later Bitget published the two forensic reports. Mandiant's status report, dated 28 September, says a threat actor gained privileged access to two third-party security appliances, put a web shell on one of them, moved to the production wallet job server and deployed malicious packages there. It also says Bitget did not observe evidence of private keys being compromised. Bitget's own notice, which is stamped 29 or 30 September depending on the time zone you read it in, says the findings "broadly align" with the attack path it had disclosed and that both investigations point to compromised third-party security products.

The detail that matters is in SlowMist's progress report, current to 29 September. It recovered a custom withdrawal tool from files the attacker had deleted. The tool was "tailored to the wallet system's withdrawal logic. It forged risk-control parameters in its code, constructed withdrawal requests, and invoked the withdrawal process." It began running at 01:49 on 25 September, UTC+8. The first verified transfer was at 02:31, which is 18:31 UTC on the 24th. Transfers continued until 05:23, about two hours and 52 minutes. Mandiant notes that on Ethereum they ran alongside legitimate internal wallet operations.

Chainalysis, on 1 October, puts the loss at $387 million across 23 transfers in the first three hours. Bitget's first estimate was $351.6 million, and Decrypt reports the exchange raised it to $387.5 million the next day. Chainalysis does not describe how the attack worked, so I use it only for the figure. As of the sources available to me, Bitget has not published a full post-mortem of its own. Mandiant labels its findings preliminary, and SlowMist calls its document a progress report.

The part the reports do not say

Neither report shows the design of the withdrawal pipeline. So what follows is my reading, and the label matters. A tool that writes its own risk-control parameters and then calls the withdrawal process got money out. The simplest explanation is that the process accepted a risk verdict from the caller instead of working one out itself. Halborn, a security firm, reaches the same place in an October analysis (its page shows 10.07.2026 and uses data as of 2 October): "The signer appears to have trusted the wallet host." It is Halborn's inference, not a finding by Mandiant, SlowMist or Bitget, and Halborn says its model of the pipeline is its own because Bitget has not published the design. I cannot tell you where Bitget's check sat or what it read. One more loose end: Chen's quote says user withdrawal requests were not forged, and SlowMist says the tool constructed withdrawal requests. The sources do not say whether those are the same thing.

Bybit looked the same

The shape is not new. NCC Group's analysis of the 2025 Bybit theft, published 10 March 2025, assumes the signers saw the intended transaction in the Safe web interface while the hardware wallet held different data, and concludes the signers "blind-signed the messages without carefully checking their contents." Different system, same trap. The approver was shown a view built by the thing under attack.

That is the argument of this piece. Nobody needed the keys, because the control that decides whether to use the keys read its input from the side the attacker had taken over. An approval that reads its input from the thing it approves is a log line. It records that the request agreed with itself. The proof was never the point makes the general version: a proxy works only while producing it stays expensive. A risk verdict that a tool can write into its own request is about as cheap as a proxy gets.

The fix everyone will write, and what I did to it

The recommendation writes itself: check each withdrawal against a record the requesting system cannot write. Before repeating it I wrote the attack and ran it. This is a toy in Python, about ninety lines, with no network and no real keys. A compromised backend forges 23 transfers of 16.8 units each, 386.4 in all, with a per-transfer cap of 20. The numbers are mine, not Bitget's. I tried five approvers.

ApproverForged transfers approved
Approves what the backend displays23 of 23
Checks the backend's own record and the cap23 of 23
Checks a nominally separate ledger the same backend can write to23 of 23
Customer-signed ledger, signature covers id and amount only. Forged rows with new ids0 of 23
Same ledger. Real request ids, destination swapped to the attacker23 of 23
Signature covers id, asset, amount and destination, ids single-use. Forged rows, swapped destinations, replays0 of 23 in each case

Rows two and three are the failure you would expect. Row five is the one that broke my first version of the advice. An isolated ledger that the backend cannot write still lost all 23, because the signature did not cover the destination. The attacker kept real request ids and changed only where the money went. "Independent ledger" was not a safe recommendation. What the signature covers decides whether the check means anything.

The last row refused every attack I tried and still passed the 23 genuine requests, so it does not just refuse everything. It also refused a replay of those same genuine requests, because the approver, not the backend, keeps the list of used ids.

What survives, and what it does not catch

The version that survived my test has four parts. The record comes from a place the requester cannot write. Its signature covers destination, asset, amount and id. Each id works once, and the approver holds that list. And the approver enforces a cap across a time window, not only per transfer.

Here is that approver, cut down from the toy and run exactly as printed. HMAC stands in for the customer's signature. The first output line is row five of the table. The next four are row six, including the genuine requests it still has to pass. The last two are the window-cap test below.

import hashlib, hmac, random

rng = random.Random(1012)
CUSTOMER_KEY = rng.randbytes(32)        # the backend never holds this key
N, AMT, PER_TX_CAP = 23, 16.8, 20.0     # toy units, not Bitget's numbers
ATTACKER = "attacker-addr"

def sig(*fields):                       # HMAC stands in for a real signature
    msg = "|".join(map(str, fields)).encode()
    return hmac.new(CUSTOMER_KEY, msg, hashlib.sha256).hexdigest()

def make_approver(ledger, covered, window_cap=None):
    """ledger maps request id to a signature only the customer side can make.
    covered names the fields that signature covers. The used ids and the
    window total live here, at the approver, where the backend cannot edit them.
    The toy has one window; a real one would reset on a clock."""
    used, spent = set(), [0.0]
    def approve(r):
        s = ledger.get(r["id"])
        if s is None or r["amount"] > PER_TX_CAP:
            return False                # no signed record, or over the cap
        if not hmac.compare_digest(s, sig(*(r[k] for k in covered))):
            return False                # a covered field was changed
        if r["id"] in used:
            return False                # replay
        if window_cap is not None and spent[0] + r["amount"] > window_cap:
            return False                # window total would exceed the cap
        used.add(r["id"])
        spent[0] += r["amount"]
        return True
    return approve

def signed(requests, covered):
    return {r["id"]: sig(*(r[k] for k in covered)) for r in requests}

def run(label, approve, requests):
    ok = sum(approve(r) for r in requests)
    print(f"{label:<44} {ok:>2} of {N}  value {ok * AMT:5.1f}")

genuine = [dict(id=i, asset="X", amount=AMT, dest=f"cust-{i}") for i in range(N)]
forged  = [dict(id=1000 + i, asset="X", amount=AMT, dest=ATTACKER) for i in range(N)]
swapped = [dict(r, dest=ATTACKER) for r in genuine]
FULL = ("id", "asset", "amount", "dest")

row5 = make_approver(signed(genuine, ("id", "amount")), ("id", "amount"))
run("row 5: covers id+amount, destination swapped", row5, swapped)

row6 = make_approver(signed(genuine, FULL), FULL)
run("row 6: forged rows, new ids", row6, forged)
run("row 6: real ids, destination swapped", row6, swapped)
run("row 6: genuine requests", row6, genuine)
run("row 6: replay of the genuine requests", row6, genuine)

own = [dict(id=100 + i, asset="X", amount=AMT, dest=ATTACKER) for i in range(N)]
run("attacker holds the key, no window cap", make_approver(signed(own, FULL), FULL), own)
run("attacker holds the key, window cap 100", make_approver(signed(own, FULL), FULL, 100.0), own)
row 5: covers id+amount, destination swapped 23 of 23  value 386.4
row 6: forged rows, new ids                   0 of 23  value   0.0
row 6: real ids, destination swapped          0 of 23  value   0.0
row 6: genuine requests                      23 of 23  value 386.4
row 6: replay of the genuine requests         0 of 23  value   0.0
attacker holds the key, no window cap        23 of 23  value 386.4
attacker holds the key, window cap 100        5 of 23  value  84.0

The window cap earned its place in a different test. I gave the attacker the signing authority for 23 requests to its own address. The ledger check then passed all 23. With a 100-unit window cap enforced at the approver, 5 passed, worth 84 of 386.4. The cap did not stop that theft. It bounded it.

Here is what none of this catches. If the attacker controls the approver, the check is theirs. If the attacker holds the customer-side signing key, the ledger agrees with them. I also did not test real signers, real latency, or what a hard stop does to honest withdrawals on a busy day. HMAC stood in for a real signature. The toy shows the logic of the check, not that any product behaves this way. And I do not know whether Bitget already had some of these controls. The reports do not say.

There is a quieter lesson in the timeline. By SlowMist's host log, the malicious program began running 42 minutes before the first verified transfer, and SlowMist dates the earliest malicious activity it found to 31 August. The theft ran for nearly three hours. Any of those gaps is a window for a human to ask why a wallet is doing what it is doing. That only works if the question is asked of something the attacker did not write. I made a related point about containment in your sandbox is a message board: agents coordinated a breach through the very package repository meant to contain them. The same limit applies to a record the system writes about itself. Your AI doesn't deserve your trust yet puts the split as "The LLM proposes. Deterministic code disposes." Here the backend proposes and the approver disposes, but only if the approver does not take its facts from the proposer.

Before Your Next Withdrawal Path Ships

Five checks on the approval step, not the request.

  • Write down where the approval step gets its input. If the system being approved can write it, the step cannot say no.
  • Have the approver read a record signed by a key the backend does not hold. Make the signature cover destination, asset, amount and request id, not just id and amount.
  • Make each request id single-use, and keep the used list at the approver, where the backend cannot edit it.
  • Enforce a cap across a time window at the approver, as well as per transfer. Expect it to bound a theft, not stop one.
  • Write the forged request and run it past the approver in a test, including real ids with the destination swapped. Keep the result.

The Bottom Line

Do not ask whether your approval step runs. Ask where its input comes from, and who can write there. If the answer is the system being approved, the step is decoration. Make the approver read a signed record the backend cannot forge, bound to the destination, used once, with a window cap it enforces itself. Then try to forge a transfer past it before someone else does.

"An approval that reads its input from the thing it approves is a log line."

Sources

What Does Your Approver Read?

If you have checked where your own approval step gets its input, tell me what you found and what you changed.

Send a Reply →