Reading the Binance EIP-7702 attack from raw chain data
Decoding the 50 signed authorizations that turned a Binance hot wallet into a proxy for the attacker, with Node scripts you can run yourself. On October 29, 2026, about $6.3 billion left Binance hot wallets across severa
Decoding the 50 signed authorizations that turned a Binance hot wallet into a proxy for the attacker, with Node scripts you can run yourself.
On October 29, 2026, about $6.3 billion left Binance hot wallets across several chains. The Ethereum portion is unusually easy to study, because the attacker used EIP-7702 and the whole takeover sits in public state. This post walks through it with plain JSON-RPC calls. Everything below ran against mainnet, and you can reproduce it with Node 18+ and an archive-capable RPC endpoint.
The scripts can't tell you how the attacker got the signatures. Binance has not explained that, and it says it found no evidence of leaked private keys.
EIP-7702 in short
A type 0x04 transaction carries an authorization_list of tuples:
[chain_id, address, nonce, y_parity, r, s]
Each tuple is signed by the account being delegated, called the authority, over this message:
keccak(0x05 || rlp([chain_id, address, nonce]))
For each tuple, the client recovers the authority, checks that the tuple's nonce equals the authority's current nonce, sets the authority's code to 0xef0100 || address, and increments its nonce. If a check fails, that tuple is skipped and the next one runs. Anyone can submit the transaction. The signed message has no recipient, no amount and no expiry. The spec has the full list of steps.
A small RPC helper
All the scripts share this file. Historical state needs an archive-capable endpoint, so set RPC_URL if the default stops working for you.
// rpc.mjs
const RPC = process.env.RPC_URL ?? 'https://eth.drpc.org';
export const rpc = async (method, params = []) => {
const res = await fetch(RPC, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
const { result, error } = await res.json();
if (error) throw new Error(error.message);
return result;
};
export const toHex = (n) => '0x' + Number(n).toString(16);
Watching the code change
A delegated account's code is exactly 23 bytes: the 0xef0100 prefix plus the delegate's address. That makes the check trivial.
// check-delegation.mjs
import { rpc, toHex } from './rpc.mjs';
const [address, block] = process.argv.slice(2);
const code = await rpc('eth_getCode', [address, block ? toHex(block) : 'latest']);
if (code === '0x') console.log('no code: plain EOA');
else if (code.startsWith('0xef0100')) console.log('delegated to 0x' + code.slice(8));
else console.log(`contract, ${(code.length - 2) / 2} bytes`);
Run it on Binance 14 around the attack:
$ node check-delegation.mjs 0x28C6c06298d514Db089934071355E5743bf21d60 26300752
no code: plain EOA
$ node check-delegation.mjs 0x28C6c06298d514Db089934071355E5743bf21d60 26300753
delegated to 0x63e842b054bd624a465454d533c33f573e9c6fae
$ node check-delegation.mjs 0x28C6c06298d514Db089934071355E5743bf21d60 26301895
delegated to 0x63e842b054bd624a465454d533c33f573e9c6fae
$ node check-delegation.mjs 0x28C6c06298d514Db089934071355E5743bf21d60 26301896
no code: plain EOA
The delegation lived for 1,143 blocks, from 20:20:59 UTC to 00:10:35 UTC, about 3 hours and 49 minutes.
Why there are 50 authorizations
The transaction has no value and no calldata. It exists only to carry the authorization list.
The tuple nonce has to match the authority's nonce at execution time, and a hot wallet's nonce moves constantly. So the attacker signed a range and let the protocol throw away the misses. This script finds the one that hit:
// match-nonce.mjs
import { rpc, toHex } from './rpc.mjs';
const TX = '0xad99264d5532ae0dd04b2004eebda05f25657e70dacb764f7ac8d4ccd051298b';
const WALLET = '0x28c6c06298d514db089934071355e5743bf21d60';
const tx = await rpc('eth_getTransactionByHash', [TX]);
const nonces = tx.authorizationList.map((a) => parseInt(a.nonce, 16));
const height = parseInt(tx.blockNumber, 16);
const index = parseInt(tx.transactionIndex, 16);
// wallet nonce at the end of the previous block
const start = parseInt(await rpc('eth_getTransactionCount', [WALLET, toHex(height - 1)]), 16);
// plus anything the wallet sent earlier in this block
const block = await rpc('eth_getBlockByNumber', [tx.blockNumber, true]);
const earlier = block.transactions.filter(
(t) => t.from === WALLET && parseInt(t.transactionIndex, 16) < index,
);
const live = start + earlier.length;
console.log(`authorizations: ${nonces.length}, nonces ${Math.min(...nonces)} to ${Math.max(...nonces)}`);
console.log(`wallet nonce after block ${height - 1}: ${start}`);
console.log(`wallet txs earlier in block ${height}: ${earlier.length} (index ${earlier.map((t) => parseInt(t.transactionIndex, 16))})`);
console.log(`wallet nonce at index ${index}: ${live}`);
console.log(`matching authorizations: ${nonces.filter((n) => n === live).length}`);
authorizations: 50, nonces 18914775 to 18914824
wallet nonce after block 26300752: 18914808
wallet txs earlier in block 26300753: 1 (index 133)
wallet nonce at index 138: 18914809
matching authorizations: 1
The wallet's own withdrawal at index 133 moved the nonce to 18914809, and the attacker's transaction at index 138 landed on it. The sender was a different account, so the wallet's nonce wasn't bumped first, which is why the tuple's nonce equals the live value. A self-sponsored delegation would need nonce + 1.
Who signed them
Recovering the signer needs a little RLP and a secp256k1 library:
npm install @noble/curves @noble/hashes
// recover-signers.mjs
import { secp256k1 } from '@noble/curves/secp256k1.js';
import { keccak_256 } from '@noble/hashes/sha3.js';
import { rpc } from './rpc.mjs';
const TX = '0xad99264d5532ae0dd04b2004eebda05f25657e70dacb764f7ac8d4ccd051298b';
const bytes = (hex) => {
const h = hex.slice(2);
return Uint8Array.from((h.length % 2 ? '0' + h : h).match(/../g) ?? [], (b) => parseInt(b, 16));
};
const toHexStr = (b) => [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
const trim = (b) => b.slice(b.findIndex((x) => x !== 0));
// minimal RLP for short strings and one short list, enough for this tuple
const rlpString = (b) => (b.length === 1 && b[0] < 0x80 ? b : Uint8Array.from([0x80 + b.length, ...b]));
const rlpList = (items) => {
const body = items.flatMap((i) => [...rlpString(i)]);
return Uint8Array.from([0xc0 + body.length, ...body]);
};
const { authorizationList } = await rpc('eth_getTransactionByHash', [TX]);
const signers = {};
for (const a of authorizationList) {
const payload = rlpList([trim(bytes(a.chainId)), bytes(a.address), trim(bytes(a.nonce))]);
const digest = keccak_256(Uint8Array.from([0x05, ...payload]));
const sig = new secp256k1.Signature(BigInt(a.r), BigInt(a.s)).addRecoveryBit(Number(a.yParity));
const pub = sig.recoverPublicKey(digest).toBytes(false);
const signer = '0x' + toHexStr(keccak_256(pub.slice(1)).slice(-20));
signers[signer] = (signers[signer] ?? 0) + 1;
}
console.log(signers);
{ '0x28c6c06298d514db089934071355e5743bf21d60': 50 }
All 50 recover to Binance 14. That shows something able to use that key signed them. It doesn't say whether that was a stolen key, a tricked signing service or something else.
What the delegation exposed
Once an EOA is delegated, a call to it runs the delegate's code. Nothing in the tuple limits that to the balance at signing time.
CoinDesk's reconstruction has the first sweep at 20:24:23 UTC, moving about 167,464 ETH and 63 tokens. Customer deposits kept landing in the wallet and were swept again at 21:04 and 21:44 UTC. TRM Labs reported that the delegated code was also used to direct a separate smart-contract wallet, Hot Wallet 34, which lost 823 million USDC.
The same night, about 18,270 BTC left in 11 validly signed bitcoin transactions. Bitcoin has no EIP-7702, so as far as the chain data shows, this feature behaved as specified and the failure sits upstream, wherever those signatures came from.
Detecting it
A plain EOA hot wallet should always return 0x from eth_getCode. That gives you a one-line invariant to monitor:
// watch-code.mjs
import { rpc, toHex } from './rpc.mjs';
// Replays a block range. In production, run the same check on every new head.
const WALLETS = ['0x28c6c06298d514db089934071355e5743bf21d60'];
const [from, to] = process.argv.slice(2).map(Number);
const seen = new Map();
for (let n = from; n <= to; n++) {
for (const wallet of WALLETS) {
const code = await rpc('eth_getCode', [wallet, toHex(n)]);
if (seen.has(wallet) && seen.get(wallet) !== code) {
const { timestamp } = await rpc('eth_getBlockByNumber', [toHex(n), false]);
const when = new Date(parseInt(timestamp, 16) * 1000).toISOString();
console.log(`ALERT block ${n} (${when}): ${wallet} code ${seen.get(wallet)} -> ${code}`);
}
seen.set(wallet, code);
}
}
$ node watch-code.mjs 26300750 26300756
ALERT block 26300753 (2026-10-29T20:20:59.000Z): 0x28c6c06298d514db089934071355e5743bf21d60 code 0x -> 0xef010063e842b054bd624a465454d533c33f573e9c6fae
That alert fires 3 minutes and 24 seconds before the first sweep. It's a short window, but it's long enough for automation to pause a signer.
Prevention at the signer
If your hot wallets don't use delegation, the signing service should refuse EIP-7702 authorizations outright. The preimage always starts with the byte 0x05, so a service that sees structured payloads can block it. A service that signs opaque 32-byte digests can't tell the difference, which is a good reason to stop exposing raw digest signing to anything on the network.
If you do use delegation, pin an allowlist of delegate addresses and treat anything else as an incident.
Freezes are visible too
USDT and USDC both expose their blacklists as view functions, isBlackListed(address) and isBlacklisted(address).
// check-freeze.mjs
import { rpc } from './rpc.mjs';
const USDT = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const [address] = process.argv.slice(2);
const arg = address.slice(2).toLowerCase().padStart(64, '0');
const check = async (token, selector) => BigInt(await rpc('eth_call', [{ to: token, data: selector + arg }, 'latest'])) === 1n;
console.log('USDT isBlackListed:', await check(USDT, '0xe47d6060'));
console.log('USDC isBlacklisted:', await check(USDC, '0xfe575a87'));
$ node check-freeze.mjs 0x28C6c06298d514Db089934071355E5743bf21d60
USDT isBlackListed: true
USDC isBlacklisted: true
The AddedBlackList and Blacklisted event logs give the timeline. The first USDT freeze came in block 26,300,954 at 21:01:35 UTC, about 37 minutes after the first sweep. The first USDC freeze came in block 26,300,971 at 21:04:59 UTC. Both named 0x5227f603c1ee34ce3c06efe6477003bdfccb2f8c, which held 971,149,299 USDT and 823,000,000 USDC at those blocks. Tether blacklisted Binance 14 itself in block 26,301,278 at 22:06:23 UTC, and Circle followed in block 26,301,812 at 23:53:35 UTC.
What is still unknown
The chain shows what the 50 signatures did and doesn't show how they were produced. Binance first pointed to a compromised engineer's laptop and later reported more breach points, with cloud systems, key management and transaction signing all under review. Its full report should fill in that part.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.