Skip to content
Real finding · Verifiable on-chain

The function named “Liquidity” that quietly drained investors

During an investor-commissioned audit of the FastBNB yield contract on BNB Chain, we found the owner could sweep every depositor's BNB out of the contract — through a public function whose name was built to look harmless. Below is the finding, its impact, how we caught it, and the on-chain evidence you can verify yourself.

ProjectFastBNB Yield
ChainBNB Chain (BSC)
AuditedJan 16, 2022
VerdictInsecure
CRITICAL
Severity of the finding
3
Critical issues found
100%
Of deposits at owner's mercy
Investor
Who commissioned it
↓ ALSO ON THIS PAGE The 20 biggest smart-contract hacks ever — with the actual vulnerable code
The finding

A withdrawal wearing the label of its opposite

In the FastBNB contract's public interface, one function appears under the name Liquidity(uint256). To anyone scanning the contract — even a careful investor reading the function list — that name reads like liquidity management: adding to the pool, topping it up, protecting holders. It sounds like one of the good, defensive functions a project would want.

It does the opposite. Calling it transfers BNB straight out of the contract and into the owner's private wallet. No liquidity is added. It is a plain owner withdrawal — dressed in the name of the very thing it destroys. That mismatch between what the name implies and what the code does is exactly what misleads users into trusting a contract that can be emptied at any moment.

What the name implies

“Liquidity”

Reads as adding or protecting liquidity for holders — a reassuring, pro-investor action.

What the code does

Owner-only drain

Sends BNB from the contract to the deployer's wallet. Repeatable, and used repeatedly.

The smoking gun is the decoded on-chain call itself — public, permanent, and reproducible on BscScan:

decoded transaction · bscscan.com
// Function called on the FastBNB contract (selector 0x198b2eae)
Liquidity(uint256 amount)

  From    : owner wallet   0xf7a4…9b57
  To      : FastBNB        0x8C70…2469
  amount  : 18000000000000000000   // = 18 BNB

  → result: 18 BNB transferred OUT of the contract to the owner

The contract's own design compounds the risk: users' principal deposits can't be withdrawn by users at all — the only path moving that BNB out is the owner's Liquidity() function. Ownership was never renounced.

Why it matters

! Impact

Total loss of principalThe owner can withdraw the entire contract balance at will — every depositor's BNB, at any time, without warning.
Asymmetric by designInvestors cannot withdraw their principal by any function. Only the owner's disguised drain moves those funds — a textbook rug-pull structure.
Already exercised, repeatedlyThis isn't theoretical. The owner wallet shows the withdrawal being executed again and again against the contract — the transactions are listed as evidence below.
Verdict: InsecureBecause of this one power, the whole contract is unsafe for depositors regardless of its yield promises. We marked it Insecure and advised the investor accordingly.
The method

How we caught it

01 · Independent mandate Commissioned by an investor

Not the project team. With no incentive to soften findings, we reviewed the contract purely in the depositor's interest.

02 · Manual review Every state-changing entry point

We read each public/owner function by hand — invest, withdraw, and the innocuously named Liquidity — not just automated tool output.

03 · On-chain forensics Traced the money

We followed the owner wallet's outgoing BNB and matched every drain back to Liquidity() calls on the live contract via BscScan.

04 · Reproducible proof Documented with real txns

The result: a Critical finding backed by permanent, public transaction hashes anyone can open and verify — no trust required.

The proof

On-chain evidence — verify it yourself

🛈

Every claim on this page is checkable. Open any link and confirm the transfer from the FastBNB contract to the owner wallet on BscScan. We hold nothing back — this is exactly what the investor received.

Addresses
FastBNB Yield contract Contract
0x8C705C8814D5A1E4b7b12ca4c6c745FFf5C42469
View on BscScan
Owner / beneficiary wallet Receives the funds
0xf7a457999cf09a9da7463ff3105fe5d1760a9b57
View on BscScan
Withdrawal transactions — contract → owner (click any row to open on BscScan)
01
Owner withdrawal 18 BNB · decoded above
0xbe5c5a16…19a8018c
Verify on Explorer
02
Owner withdrawal
0x7417b58a…bacf0df3
Verify on Explorer
03
Owner withdrawal
0xad2a8a1a…2b295385
Verify on Explorer
04
Owner withdrawal
0xd898015a…f767642e
Verify on Explorer
05
Owner withdrawal
0x86b7845b…8f373fc1
Verify on Explorer
06
Owner withdrawal
0xf8387cfe…a9698f46
Verify on Explorer
07
Owner withdrawal
0x66ef057c…aae80f38
Verify on Explorer
08
Owner withdrawal
0x475fc0ed…e3d0c5c5
Verify on Explorer
09
Owner withdrawal
0x08b563f8…876089a8
Verify on Explorer
10
Owner withdrawal
0xd6b97b93…de298a7f
Verify on Explorer
The bigger picture

The 20 biggest smart-contract hacks in history

FastBNB was one contract and one investor. What follows is the industry's permanent record: the twenty largest smart-contract failures ever recorded, ordered by what they cost. Each one is laid out the same way — a step-by-step diagram of how the attack actually ran, the real vulnerable code with the flawed line marked, the one-line difference that would have stopped it, and links to the post-mortems so you can check every word of it yourself.

Read them in order and a pattern shows up fast. Almost none of these were exotic cryptography. They were a missing check, a wrong comparison operator, a default value nobody thought about, a permission nobody revoked. Every single one is the kind of thing a line-by-line review is built to catch — and the ones that got away teach you exactly where reviews go blind.

$5.5B+
Lost across these 20
2016→2026
Ten years, same mistakes
8
Tagged “unaudited” by Rekt
1
Character, in the cheapest fix
Loss per incident — ranked, US$ at the time
One bar per incident, on a single linear scale, so the shape is honest: Bybit alone is larger than the next two combined. Click any row to jump to it.
01 Bybit2025 $1.43B 02 Ronin Network2022 $624M 03 Poly Network2021 $611M 04 BNB Chain Token Hub2022 $586M 05 Wormhole2022 $326M 06 KelpDAO rsETH2026 $291M 07 Cetus Protocol2025 $223M 08 Euler Finance2023 $197M 09 Nomad Bridge2022 $190M 10 Beanstalk Farms2022 $181M 11 Parity Multisig2017 ~$150M 12 Compound Finance2021 $147M 13 Cream Finance2021 $130M 14 Balancer v22025 $128M 15 Mango Markets2022 $115M 16 Fei / Rari Fuse2022 $80M 17 Qubit Finance2022 $80M 18 Curve · Vyper2023 $69.3M 19 The DAO2016 ~$60M 20 Uranium Finance2021 ~$50M
Scale: 0 → $1.43B · single hue, magnitude only · every value is labelled, so the chart is its own table Source: each incident's post-mortem + Rekt
What actually broke — root cause across all 20
Access control & key handling5
Missing validation check5
Arithmetic & precision4
Reentrancy3
Oracle & economic design2
Governance design1
Compiler / toolchain1

No incidents in that category.

01 Access control Bybit21 Feb 2025 · Ethereum · Safe{Wallet} multisig cold wallet $1.43B401,000 ETH

The largest theft in the history of the asset class, and the contract was not broken. Attackers compromised a Safe{Wallet} developer's machine and pushed a tampered JavaScript bundle to the S3 bucket that served the signing interface. Bybit's signers saw a routine 30,000 ETH transfer on screen; the payload actually handed to their hardware wallets was a delegatecall into an unverified contract deployed three days earlier. Three people approved it. In a Safe, storage slot 0 is masterCopy — one SSTORE through a delegatecall replaces the wallet's entire implementation.

How the attack ran
  1. Poisoned signing UIA tampered JS bundle is served from Safe{Wallet}’s S3 bucket
  2. Three signers approveTheir screens read “transfer 30,000 ETH to the hot wallet”
  3. operation = 1DELEGATECALL, not CALL — the target’s code runs as the wallet
  4. masterCopy overwrittenSlot 0 replaced → 401,000 ETH swept out
Safe.execTransaction — the parameter that decides everything
// operation = 0 → CALL         value leaves the wallet, wallet storage untouched
// operation = 1 → DELEGATECALL the target's code runs AS the wallet, on its storage

execTransaction(
  to:        0x9622…7242,   // unverified, deployed 3 days before
  value:     0,
  data:      transfer(address,uint256),   // reads harmless in the UI
  operation: 1                // ← DELEGATECALL
)

// Safe storage layout:  slot 0 == masterCopy (the implementation address)
// The delegated code writes slot 0. The wallet is now the attacker's contract.
→ result: 401,000 ETH swept in the following transactions
What an audit looks for: operational review, not just Solidity. Any signing flow that lets operation = 1 reach a non-allowlisted target is a single-click total loss. Signers must verify the raw hash on the hardware device — never trust what the browser draws.
02 Access control Ronin Network23 Mar 2022 · Ronin / Ethereum · Axie Infinity bridge $624M173,600 ETH + 25.5M USDC

The bridge needed 5 of 9 validator signatures. Sky Mavis operated four of those validators itself. For the fifth, the attacker did not need a bug — in November 2021 the Axie DAO had allowlisted Sky Mavis's gas-free RPC node to sign on its behalf during a traffic surge. The arrangement stopped in December. The allowlist entry was never revoked. Compromise one company's infrastructure and you hold five keys. Nobody noticed for six days, until a user complained they could not withdraw 5,000 ETH.

How the attack ran
  1. Sky Mavis is breached4 of the 9 validator keys sit on one company’s servers
  2. A stale allowlist is reusedAxie DAO’s gas-free RPC grant from November, never revoked
  3. Quorum reached by one party5-of-9 signatures, all traceable to a single compromise
  4. Withdrawal signed173,600 ETH + 25.5M USDC — unnoticed for six days
the code was correct — the trust graph was not
// The bridge contract did exactly what it was written to do:
require(_signatures.length >= _threshold, "!quorum");   // threshold = 5 of 9

// The real security math:
  4 validator keys  → one company's servers
+ 1 validator key   → a stale allowlist entry from 4 months earlier
= a 5-of-9 multisig with an effective security of 1
What an audit looks for: who really holds the keys, not how many keys the contract counts. Every temporary permission needs an expiry date written into the contract, and validator independence has to be verified off-chain — a multisig is only as strong as the number of independent parties in it.
03 Access control Poly Network10 Aug 2021 · Ethereum + BNB Chain + Polygon $611Mlater returned by the attacker

The cross-chain executor EthCrossChainManager was itself the owner of the contract that stored the bridge's trusted keeper keys. Its executor function let an incoming message name any target contract and any method by string. The attacker brute-forced a method name — f1121318093 — whose first four bytes of keccak collide exactly with putCurEpochConPubKeyBytes(bytes) = 0x41973cd9, then made the bridge call its own privileged storage contract and replace every keeper with his own key. After that he simply signed his own withdrawals.

How the attack ran
  1. A cross-chain message is craftedTarget contract and method name are both attacker-chosen
  2. A method name is brute-forcedf1121318093, picked for its keccak prefix
  3. Selector collision0x41973cd9 ≡ putCurEpochConPubKeyBytes, and the bridge owns that contract
  4. Keepers replacedThe attacker now signs the bridge’s own withdrawals
EthCrossChainManager.sol — untrusted input picks the selector
function _executeCrossChainTx(
    address _toContract, bytes memory _method,
    bytes memory _args, bytes memory _fromContractAddr, uint64 _fromChainId
) internal returns (bool){
    require(isContract(_toContract), "...not a contract");
    (success, returnData) = _toContract.call(
        abi.encodePacked(
            bytes4(keccak256(abi.encodePacked(_method, "(bytes,bytes,uint64)"))),
            abi.encode(_args, _fromContractAddr, _fromChainId)
        )
    );
    ...
}

// _method = "f1121318093"
// keccak256("f1121318093(bytes,bytes,uint64)")[0:4]  ==  0x41973cd9
// keccak256("putCurEpochConPubKeyBytes(bytes)")[0:4] ==  0x41973cd9   ← same selector
// and EthCrossChainManager is the OWNER of EthCrossChainData.
→ result: the attacker's key becomes the bridge's only keeper
What an audit looks for: never let untrusted input choose which function gets called. A four-byte selector is not a namespace — collisions are cheap to brute-force. And a privileged contract must never be reachable from a generic execute path: put an explicit deny-list of privileged targets in the executor.
04 Missing check BNB Chain Token Hub6 Oct 2022 · BNB Chain · cross-chain bridge $586M2,000,000 BNB

The bridge verified withdrawals with an IAVL Merkle proof from the Cosmos library. The verifier confirmed that the proof's computed root matched the trusted root — but never confirmed that every leaf inside the proof was actually part of that computation. The attacker appended a forged leaf node that the hash walk simply never visited. Root matched. Payload was his. He minted himself 1,000,000 BNB, twice. The same flawed library sat under a large part of the Cosmos ecosystem.

How the attack ran
  1. Register as a relayerA legitimate, open bridge role
  2. Submit an IAVL range proofWith one extra leaf node appended to it
  3. The verifier hashes only what it walksThe forged leaf is never visited, so the root still matches
  4. Released twice1,000,000 BNB × 2 against a proof of nothing
cosmos/iavl RangeProof — annotated, simplified from the real verifier
func (proof *RangeProof) Verify(root []byte) error {
    rootHash, err := proof.computeRootHash()   // walks only the nodes it needs
    if err != nil { return err }
    if !bytes.Equal(rootHash, root) {
        return ErrInvalidRoot
    }
    return nil
    // ❌ never asserts that every leaf in proof.Leaves was consumed
    //    by the walk — an extra, unvisited leaf changes nothing about
    //    the root hash, but the caller reads it as "proven".
}

// The forged proof:
  leaves = [ real_leaf , forged_leaf ]      // forged_leaf: "send 1,000,000 BNB to me"
  computeRootHash(leaves) == trusted_root   // ✅ passes
→ result: 2,000,000 BNB released against a proof of nothing
What an audit looks for: proof verifiers must be complete, not just consistent. "The root matches" is a weaker statement than "this data, and only this data, produced the root." Any verifier that reads more input than it hashes is forgeable.
05 Missing check Wormhole2 Feb 2022 · Solana ↔ Ethereum · token bridge $326M120,000 wETH minted from nothing

On Solana, a program proves that the signature-verification precompile ran by reading the Instructions sysvar. Wormhole read it with the deprecated load_instruction_at, which parses whatever account you hand it and does not check that the account is the real sysvar. The attacker supplied his own account, containing a fabricated record saying "Secp256k1 verified these signatures," and the bridge accepted a guardian message it had never verified — minting 120,000 wETH on Solana with zero ETH behind it.

How the attack ran
  1. Build a fake guardian messageNo real signatures behind it
  2. Pass your own accountIn the slot where the Instructions sysvar belongs
  3. load_instruction_at trusts itIt parses the data without checking the account’s address
  4. Minted from nothing120,000 wETH on Solana, no ETH locked
solana/bridge/program/src/api/verify_signature.rs
// ❌ BEFORE — trusts whatever account sits in the "instructions" slot
let secp_ix = solana_program::sysvar::instructions::load_instruction_at(
    secp_ix_index as usize,
    &accs.instruction_acc.try_borrow_mut_data()?,   // ← ANY account works
)?;

// ✅ AFTER — the checked variant verifies the address for you
let secp_ix = solana_program::sysvar::instructions::load_instruction_at_checked(
    secp_ix_index as usize,
    &accs.instruction_acc,   // must == Sysvar1nstructions1111111111111111111111111
)?;
What an audit looks for: on Solana, every account passed into an instruction is attacker-controlled until the program proves otherwise. Owner checks, address checks, signer checks. "It came in the sysvar position" is not a check — this is still the number-one bug class on the chain.
06 Access control KelpDAO rsETH18 Apr 2026 · Ethereum ↔ Unichain · LayerZero OFT adapter $291M116,500 rsETH

The most recent entry on this list, and the one that should worry builders most: the contracts had no bug at all. Kelp's rsETH adapter on the Unichain→Ethereum path was configured to require exactly one verifier (DVN) — LayerZero Labs — despite LayerZero's own integration checklist recommending a redundant multi-verifier setup. Six weeks earlier, attackers had socially engineered a LayerZero developer, pivoted into the RPC cloud environment, and poisoned the nodes that verifier reads from. The adapter then released 116,500 rsETH in response to a message that no Unichain transaction ever emitted.

How the attack ran
  1. Social-engineer a developerLayerZero Labs, six weeks before the theft
  2. Poison the verifier’s RPC nodesThe DVN now reports attacker-supplied chain state
  3. requiredDVNs.length == 1One verifier means compromising it IS compromising the bridge
  4. Released on a ghost message116,500 rsETH for a Unichain tx that never existed
the vulnerability was a config value, not a line of Solidity
// LayerZero endpoint config for the Unichain → Ethereum pathway
UlnConfig {
  requiredDVNs:          [ LayerZeroLabsDVN ],   // ❌ length = 1
  optionalDVNs:          [ ],
  optionalDVNThreshold:  0
}

// ✅ what the integration checklist recommends
UlnConfig {
  requiredDVNs:          [ LayerZeroLabsDVN, PartnerDVN ],
  optionalDVNs:          [ DVN_A, DVN_B, DVN_C ],
  optionalDVNThreshold:  2
}

// With one verifier, compromising one verifier's data feed
// IS compromising the bridge. Poison its RPC → forge any message.
→ result: $291M released for a message that never existed
What an audit looks for: deployed configuration, not just source code. Trust thresholds, oracle sets, verifier counts, timelock delays and admin keys are all part of the attack surface — and unlike code, they can be silently changed after the audit. A review that stops at the .sol file would have signed off on this one.
07 Arithmetic Cetus Protocol22 May 2025 · Sui · concentrated-liquidity DEX $223Mlargest DeFi hack of 2025

A custom overflow guard with the wrong threshold. checked_shlw was supposed to reject any value that would overflow when shifted left by 64 bits — that limit is 1 << 192. The code compared against 0xffffffffffffffff << 192 instead: a bound roughly 2⁶⁴ times too high. Everything in that gap sailed through the "safe" branch and silently truncated. In Move, << does not abort on overflow. The attacker opened a position credited with astronomic liquidity while depositing 1 unit of token A.

How the attack ran
  1. Open a liquidity positionWith an absurdly large liquidity value
  2. The math calls checked_shlwThe guard that should reject an overflowing shift
  3. Mask is 0xffff…<<192~2⁶⁴× too high, and Move’s << never aborts
  4. Required deposit rounds to 1Near-infinite liquidity for one unit of token A
integer_mate::math_u256 — the guard that guarded nothing
// ❌ vulnerable
public fun checked_shlw(n: u256): (u256, bool) {
    let mask = 0xffffffffffffffff << 192;   // ≈ 2^256 − 2^192
    if (n > mask) {
        (0, true)                            // "overflow"
    } else {
        ((n << 64), false)                   // silently truncates
    }
}

// ✅ fixed
public fun checked_shlw(n: u256): (u256, bool) {
    let mask = 1 << 192;                    // the real overflow boundary
    if (n >= mask) { (0, true) } else { ((n << 64), false) }
}

// get_delta_a() uses this to price how much token A a position must deposit.
// Truncated numerator → required deposit rounds down to 1.
→ result: near-infinite liquidity minted for ~1 unit of token A
What an audit looks for: hand-written safe-math is a red flag by itself. Every custom guard needs a boundary test at exactly the limit, one below and one above — 1<<192 versus 0xffff…<<192 is invisible on a read-through and obvious the moment you fuzz it.
08 Missing check Euler Finance13 Mar 2023 · Ethereum · lending protocol $197Mbug lived on-chain for 8 months

Euler added a donation feature in eIP-14: donateToReserves lets you give your own eToken balance to the protocol's reserves. Every other function that reduces a user's collateral ends with a solvency check. This one did not. So the attacker built layered leverage, then deliberately donated his own collateral away to make himself insolvent, and immediately self-liquidated — collecting Euler's own liquidation discount (up to 20%) on a debt he had manufactured. The feature had been live for eight months and sat outside the scope of the protocol's primary auditor.

How the attack ran
  1. Flash-loan and loop depositsLayered leverage — every step legitimate
  2. Donate your own collateraldonateToReserves: equity falls, debt does not
  3. No checkLiquidity()The one balance-reducing path that skips the solvency check
  4. Self-liquidate at a discountTake your own position at up to 20% off — $197M
EToken.sol (eIP-14) — annotated
function donateToReserves(uint subAccountId, uint amount) external nonReentrant {
    address account = getSubAccount(msg.sender, subAccountId);
    ...
    decreaseBalance(assetStorage, assetCache, proxyAddr, account, amountInternal);
    increaseReserves(assetStorage, assetCache, amountInternal);
    ...
    // ❌ every other balance-reducing path ends with:
    //       checkLiquidity(account);
    //    this one returns without it.
}

// The three-step attack, all inside one transaction:
  1. flash-loan & loop deposits  → heavily leveraged position
  2. donateToReserves(...)       → collateral drops, debt untouched, no check
  3. liquidate(self)             → take the position at up to 20% discount
→ result: $197M across six Euler markets
What an audit looks for: enumerate every path that can reduce collateral or increase debt, and prove each one ends in the same solvency check. A new feature is exactly where this invariant breaks — and "it was added after the audit" is how most of these end.
09 Missing check Nomad Bridge1 Aug 2022 · Ethereum ↔ Moonbeam · the first crowdsourced robbery $190Mdrained by ~300 different addresses

A routine upgrade initialized the Replica contract with a committed root of 0x00…00. Because initialize() marks the committed root as confirmed, confirmAt[0x00] = 1 — the zero hash became a permanently trusted root. And in Solidity, an unset mapping entry is the zero hash. So messages[anyUnknownHash] returned 0x00, which acceptableRoot happily approved. Every unproven message was "proven". Then it went viral: people copied the first attacker's calldata, pasted in their own address, and sent it. Hundreds of them.

How the attack ran
  1. An upgrade sets the root to zeroinitialize() then marks it confirmed
  2. Send a message that was never provenAn unwritten mapping entry returns 0x00
  3. acceptableRoot(0x00) == trueSo every unproven message passes the !proven check
  4. Copy-paste looting~300 addresses reused the calldata — $190M
Replica.sol — three functions, one default value
function initialize(..., bytes32 _committedRoot, ...) public initializer {
    ...
    confirmAt[_committedRoot] = 1;   // upgrade passed 0x00 ⇒ confirmAt[0x00] = 1
}

function acceptableRoot(bytes32 _root) public view returns (bool) {
    uint256 _time = confirmAt[_root];
    if (_time == 0) return false;
    return block.timestamp >= _time;   // 0x00 → confirmAt = 1 → true, forever
}

function process(bytes memory _message) public returns (bool _success) {
    bytes32 _messageHash = keccak256(_message);
    // an un-proven message: messages[_messageHash] == bytes32(0)
    require(acceptableRoot(messages[_messageHash]), "!proven");   // ← always true
    ...
}
→ result: any message, from anyone, executed as if the bridge had signed it
What an audit looks for: the zero value is a real input. Ask of every mapping: "what does an entry that was never written mean here?" And treat upgrades as new deployments — the vulnerable value here was introduced by an initialization argument, not by any change in the code.
10 Governance Beanstalk Farms17 Apr 2022 · Ethereum · algorithmic stablecoin $181Mvoted, passed and executed in one transaction

Beanstalk's governance measured voting power from a wallet's current token balance, and offered an emergencyCommit path that executes a proposal 24 hours after nomination if it holds a two-thirds supermajority. The attacker submitted BIP-18 (transfer everything to me) and BIP-19 (a $250k donation to Ukraine, as cover), waited out the 24 hours, then flash-loaned roughly $1 billion from Aave, Uniswap and SushiSwap, converted it into Beanstalk LP tokens, voted with it, committed, repaid the loan and left — all inside a single block.

How the attack ran
  1. Submit BIP-18“Transfer the protocol’s assets to me”
  2. Wait 24 hoursThe emergency-commit window opens
  3. Votes = current balanceFlash-loan ~$1B, vote, commit — all in one transaction
  4. Executed in a single block$181M out, the loan repaid before the block closed
Governance.sol — a flash loan is a balance
function emergencyCommit(uint32 bip) external {
    require(isNominated(bip),  "Governance: Not nominated.");
    require(block.timestamp >= timestamp(bip).add(C.getGovernanceEmergencyPeriod()),
                              "Governance: Too early.");
    require(isActive(bip),     "Governance: Ended.");
    require(canPropose(msg.sender), "Governance: Not enough Stalk.");
    // ❌ voting power read from the CURRENT balance, this block
    require(bipVotePercent(bip).greaterThanOrEqualTo(C.getSuperMajority()),
                              "Governance: Must have supermajority.");
    _execute(msg.sender, bip, false, true);
}

// ✅ the fix is one word: snapshot.
//    votes = balanceOfAt(voter, proposal.snapshotBlock)
//    A flash loan cannot rewrite a block that already happened.
What an audit looks for: any privilege priced by a spot balance is for rent. Voting power, collateral weight, fee tiers, reward multipliers — if it reads balanceOf() at execution time, assume an attacker holds an unlimited amount of it for one transaction.
11 Access control Parity Multisig WalletJul & Nov 2017 · Ethereum · the same bug, twice ~$150M153,037 ETH stolen · 513,774 ETH frozen

Parity's wallets were thin proxies: any call they did not recognise was forwarded to one shared library with delegatecall, meaning the library's code ran against your wallet's storage. The library's initWallet had no guard against being called twice, so anyone could re-initialize any wallet and make themselves the sole owner. In July, 153,037 ETH left three ICO wallets. The replacement library shipped the next day still had no guard — and in November a user called initWallet on the library itself, became its owner, and called kill(). Every wallet built on it now delegates into an empty address. 513,774 ETH have been unreachable ever since.

How the attack ran
  1. Call the wallet’s fallbackAnything unmatched is delegatecalled into the library
  2. Call initWallet([me], 1)Re-initialise it and become the only owner
  3. No initializer guard — on the library eitherSo the library itself can be claimed, then kill()ed
  4. Stolen, then bricked153,037 ETH taken · 513,774 ETH frozen forever
Wallet.sol + WalletLibrary.sol
// Wallet.sol — anything unmatched runs in OUR storage
function() payable {
    if (msg.value > 0) Deposit(msg.sender, msg.value);
    else if (msg.data.length > 0) _walletLibrary.delegatecall(msg.data);
}

// WalletLibrary.sol
function initWallet(address[] _owners, uint _required, uint _daylimit) {
    initDaylimit(_daylimit);
    initMultiowned(_owners, _required);
    // ❌ no modifier. no "already initialised" flag. public to the world.
}

// July 2017 — against a WALLET:
  wallet.initWallet([attacker], 1, ...)   → sole owner → execute() → 153,037 ETH
// November 2017 — against the LIBRARY:
  library.initWallet([anon], 1, ...)      → owner of the library
  library.kill(anon)                     → SELFDESTRUCT → 513,774 ETH frozen forever
What an audit looks for: every initializer needs an initializer modifier, and every logic/library contract needs to be initialized at deployment so nobody else can claim it. A delegatecall fallback turns every public function in the library into a public function of your wallet — including the ones you forgot about.
12 Arithmetic Compound Finance29 Sep 2021 · Ethereum · the $147M character $147Mgiven away, not stolen

Governance Proposal 62 split COMP rewards into separate supply-side and borrow-side speeds. Inside distributeSupplierComp, the guard that initializes a first-time supplier's index used > where it needed >=. For a market whose supply index was still exactly compInitialIndex, the branch never fired, the supplier's index stayed at 0, and the reward delta became the entire 1e36 index. Users started claiming COMP by the truckload — and because Compound governance requires a 7-day process, everyone watched the bug pay out for a week with no way to stop it.

How the attack ran
  1. Proposal 62 shipsCOMP rewards split into supply and borrow speeds
  2. A first-time supplier claimsTheir stored index is still 0
  3. > where >= was neededThe init branch is skipped, so the delta becomes the full 1e36
  4. Paid out for a week~$147M — a 7-day timelock and no pause switch
Comptroller.sol — distributeSupplierComp
// ❌ shipped in Proposal 62
if (supplierIndex.mantissa == 0 && supplyIndex.mantissa > compInitialIndex) {
    supplierIndex.mantissa = compInitialIndex;   // 1e36
}
Double memory deltaIndex = sub_(supplyIndex, supplierIndex);
uint supplierDelta = mul_(supplierTokens, deltaIndex);

// ✅ fixed in Proposal 64
if (supplierIndex.mantissa == 0 && supplyIndex.mantissa >= compInitialIndex) {
    supplierIndex.mantissa = compInitialIndex;
}

// With > :  supplyIndex == compInitialIndex == 1e36 → branch skipped
//            supplierIndex stays 0  →  deltaIndex = 1e36 − 0 = 1e36
//            rewards paid against an index of 1e36 instead of 0.
→ result: ~$147M of COMP accrued and claimable in error
What an audit looks for: off-by-one boundaries in every comparison, and the fact that a bug fix is a code change like any other — this one was introduced by a fix for a previous reward bug. Also worth auditing: your own emergency response. A 7-day timelock with no pause switch means a live bug runs to completion.
13 Oracle Cream Finance27 Oct 2021 · Ethereum · lending market $130Mfifth incident that year

Cream priced Yearn vault shares as totalAssets / totalSupply, read live from the vault. A vault's asset balance counts tokens that were simply sent to it — no mint required. So the attacker used flash loans to shrink the vault's supply to about $8M, then donated ~$8M of yUSD straight into the vault, instantly doubling the price per share without a single trade. His $1.5B of crYUSD collateral was revalued at $3B, and he borrowed out everything Cream had on the shelf.

How the attack ran
  1. Flash-loan, then burn sharesVault supply squeezed down to ~$8M
  2. Transfer $8M into the vaultA plain ERC-20 transfer — no mint, no swap, no fee
  3. Price = balance() / totalSupplyA donation doubles the price per share instantly
  4. $1.5B collateral reads $3BBorrow out everything on the shelf — $130M
PriceOracleProxy → yVault.getPricePerFullShare()
// Cream's price for a vault-share collateral token:
function getUnderlyingPrice(CToken cToken) returns (uint) {
    ...
    return vault.getPricePerFullShare() * underlyingPrice / 1e18;
}

// yVault:
function getPricePerFullShare() public view returns (uint) {
    return balance() * 1e18 / totalSupply;
    // ❌ balance() includes tokens TRANSFERRED in.
    //    A plain ERC-20 transfer moves the price. No mint. No swap. No fee.
}

// The attack, in one transaction:
  flash-loan → burn shares until totalSupply ≈ $8M
  transfer $8M of yUSD directly to the vault      → price per share ×2
  collateral repriced $1.5B → $3B                → borrow everything
What an audit looks for: any price derived from a live balance is manipulable inside one transaction — this is the donation attack, and it is still landing today. Collateral must be priced from a manipulation-resistant oracle (TWAP or an independent feed), never from balanceOf(address(this)).
14 Arithmetic Balancer v23 Nov 2025 · 8 chains at once · Composable Stable Pools $128Mdrained in under 30 minutes

Balancer scales every token balance up to 18 decimals before doing pool math, and that scaling rounds down. Normally the lost wei is noise. The attacker drove pool balances down to the 8–9 wei range, where a single wei is a huge fraction of the value, and then chained dozens of swaps inside one batchSwap so the error compounded in his favour on every hop — suppressing the pool token's price, then cycling the arbitrage. Ethereum, Base, Arbitrum, Optimism, Polygon, Avalanche, Gnosis, Berachain and Sonic, all inside half an hour. Balancer's TVL fell 58% in two days.

How the attack ran
  1. Push balances to 8–9 weiWhere a single wei is ~11% of the value
  2. Chain dozens of swapsAll inside one batchSwap call
  3. _upscale always rounds downEvery hop hands the discarded wei to the caller
  4. BPT price suppressed$128M across 8 chains in under 30 minutes
ScalingHelpers.sol — a rounding direction, weaponised
function _upscale(uint256 amount, uint256 scalingFactor) internal pure returns (uint256) {
    return FixedPoint.mulDown(amount, scalingFactor);   // ❌ always rounds DOWN
}

// At normal balances the discarded wei is invisible.
// At a balance of 9 wei it is 11% of the value.

  batchSwap([ swap, swap, swap, ... ])   // one tx, many hops
      hop 1  balance 9 → rounds to 8     // 1 wei to the attacker
      hop 2  ...                          // and again
      hop n  ...                          // and again
  → BPT price suppressed → buy cheap, redeem at true value, repeat

// ✅ the invariant: rounding must ALWAYS favour the pool, never the caller.
//    Direction has to be chosen per call site, not globally.
What an audit looks for: rounding direction at every single arithmetic site, and behaviour at extreme balances. "It's only a wei" stops being true the moment an attacker controls the balance and the number of iterations. Test your pool math at 1 wei, not just at realistic amounts.
15 Oracle Mango Markets11 Oct 2022 · Solana · perpetuals exchange $115Mno bug — the code worked perfectly

The most uncomfortable entry on the list, because there is no vulnerable line to show you. The attacker funded two accounts, took both sides of a huge MNGO perpetual trade against himself, and pushed the mark price of a token with a few hundred thousand dollars of real depth up more than 1,000% in about twenty minutes. Mango's risk engine did exactly what it was written to do: it valued his position at the oracle price and let him borrow $115M against it. He then walked away and argued publicly that it was a legitimate trading strategy. The jury disagreed; a judge later overturned the conviction.

How the attack ran
  1. Fund two accountsAbout $5M on each side
  2. Trade MNGO-PERP against yourselfMark price +1,000% in roughly 20 minutes
  3. No cap on illiquid collateralThe risk engine values the position at the pumped mark
  4. Borrow against the pump$115M out; the code never malfunctioned
the finding is the absence of a limit
// The risk engine, faithfully implemented:
collateral_value = position_size × oracle_price(MNGO)
                                    └── a token with ~$200k of real depth
                                        on a market the borrower can move himself

// ❌ What was missing was not a check — it was a limit:
    · no cap on how much of one account's collateral may be a single illiquid asset
    · no borrow cap scaled to the asset's real market depth
    · no confidence / deviation band on the oracle price
    · no time-weighting to make a 20-minute pump economically useless

→ the contract never malfunctioned. the economic model did.
What an audit looks for: this entire class is invisible to a review that only reads code. You have to model the economics — for every asset you accept as collateral, ask what it costs an attacker to move its price far enough to break you. If that number is smaller than your TVL, you have a finding, no matter how clean the Solidity is.
16 Reentrancy Fei Protocol / Rari Capital Fuse30 Apr 2022 · Ethereum · isolated lending pools $80Mthe protocol shut down after

Fuse's cEther sent the borrowed ETH to the borrower before writing the loan into storage — the classic violation of checks-effects-interactions. The ETH transfer hands control to the borrower's contract, and from inside that callback the attacker called exitMarket(). Since his borrow had not been recorded yet, the collateral check saw an account with no debt and released 100% of his collateral. He kept the loan and the collateral, and repeated it across pool after pool.

How the attack ran
  1. Deposit collateral, then borrowborrowFresh sends the ETH out first
  2. The ETH hits your receive()Control is yours before the loan is written to storage
  3. exitMarket() sees no debtA different function, sharing state but not a lock
  4. Collateral back, loan keptRepeated pool after pool — $80M
CToken.sol — borrowFresh, effects after interaction
function borrowFresh(address payable borrower, uint borrowAmount) internal returns (uint) {
    ...
    doTransferOut(borrower, borrowAmount);          // ❶ external call FIRST
    accountBorrows[borrower].principal = vars.accountBorrowsNew;   // ❷ state after
    accountBorrows[borrower].interestIndex = borrowIndex;
    totalBorrows = vars.totalBorrowsNew;
    ...
}

// inside the attacker's receive():
comptroller.exitMarket(cToken);
    → "does this account have outstanding borrows?"
    → not written yet → no → collateral released

// ✅ two independent fixes, and you want both:
//    1. move doTransferOut() to the LAST line (checks-effects-interactions)
//    2. a shared reentrancy guard that also covers exitMarket()
What an audit looks for: reentrancy is not only "the same function called twice." Cross-function reentrancy — leaving through one function and coming back through another that reads the state you have not written yet — is the version that still gets people. Map which functions share state, and make sure they share a lock.
17 Missing check Qubit Finance28 Jan 2022 · BNB Chain ↔ Ethereum · QBridge $80Mdeposited exactly zero

QBridge shipped a hand-rolled copy of safeTransferFrom built on a raw call, dropping the one line OpenZeppelin includes: the check that the target address actually contains code. Call it with tokenAddress = 0x0 and the EVM does what it always does with a call to an empty account — it succeeds and returns nothing. Zero tokens moved. The Deposit event fired anyway, the Ethereum side saw it, and minted the attacker 77,162 qXETH against a deposit that never happened.

How the attack ran
  1. Call deposit() with token 0x0And with msg.value = 0
  2. Hand-rolled safeTransferFrom runsA raw call, with no isContract check
  3. A call to an empty account succeedsIt returns (true, "") — both requires pass
  4. The Deposit event fires anyway77,162 qXETH minted against nothing — $80M
the one line that was removed
// ❌ QBridge's own version
function _callOptionalReturn(IERC20 token, bytes memory data) private {
    (bool success, bytes memory returndata) = address(token).call(data);
    require(success, "SafeERC20: low-level call failed");
    if (returndata.length > 0) {
        require(abi.decode(returndata, (bool)), "SafeERC20: operation did not succeed");
    }
    // call() to an address with NO CODE returns (true, "").
    // success == true. returndata.length == 0. Both requires pass.
}

// ✅ OpenZeppelin's version
function _callOptionalReturn(IERC20 token, bytes memory data) private {
    bytes memory returndata = address(token).functionCall(data, "...");
    // functionCall() → require(isContract(target), "Address: call to non-contract")
    ...
}

  deposit(tokenAddress = 0x0000…0000, amount = 77162e18, msg.value = 0)
→ result: 77,162 qXETH minted on BSC against nothing at all
What an audit looks for: every re-implementation of a standard library, and every address parameter that reaches a low-level call. Zero-address checks and isContract checks are the cheapest lines in Solidity, and they are the ones people delete to save gas.
18 Toolchain Curve Finance · Vyper compiler30 Jul 2023 · Ethereum · alETH, msETH, pETH, CRV/ETH $69.3Mnobody's source code was wrong

Every audited line was correct. Vyper versions 0.2.15, 0.2.16 and 0.3.0 contained a bug in set_storage_slots that gave each @nonreentrant('lock') its own storage slot instead of one shared slot per lock name. Two functions declaring the same named lock therefore did not share it. Source code that read as fully protected compiled to bytecode that had no cross-function protection at all. The attacker re-entered through the raw-ETH callback in remove_liquidity and drained four pools.

How the attack ran
  1. Both functions declare the same lock@nonreentrant('lock') — correct source
  2. Vyper 0.2.15–0.3.0 compiles itGiving each function its own storage slot
  3. The shared lock is not sharedAudited source, unprotected bytecode
  4. Re-enter via the ETH callbackFour pools drained — $69.3M
the source passed every review — the bytecode did not
# What every auditor read, and correctly approved:
@external
@payable
@nonreentrant('lock')
def add_liquidity(...): ...

@external
@nonreentrant('lock')
def remove_liquidity(_amount: uint256, _min_amounts: uint256[N]):
    ...
    raw_call(msg.sender, b"", value=eth_amount)   # attacker's fallback runs here
    self.balances = ...                        # state written after

# In Vyper 0.2.15 – 0.3.0 those two 'lock's compiled to
#   add_liquidity    → storage slot A
#   remove_liquidity → storage slot B
# One lock per function is the same as no lock at all.

→ fixed in Vyper 0.3.1+ · "audited source" ≠ "audited bytecode"
What an audit looks for: the compiler version is part of the threat model. Pin it, check it against known-bad releases, and verify that the deployed bytecode matches the source that was reviewed. This is also why we insist on on-chain verification: the source on the explorer is a claim until the bytecode agrees with it.
19 Reentrancy The DAO17 Jun 2016 · Ethereum · the one that split the chain ~$60M3.6M ETH · 5% of all ETH then

The original. splitDAO paid the caller out and called withdrawRewardFor — both of which hand control to the caller's contract — and only then zeroed the caller's balance. The attacker's fallback simply called splitDAO again, and again, each pass paid against a balance that was still on the books. 3.6 million ETH left in a few hours. The response was the hard fork that split the network into Ethereum and Ethereum Classic — the single most consequential bug in the industry's history. Ten years later, reentrancy is still in the top three causes of loss.

How the attack ran
  1. Call splitDAO()The ether payout leaves the contract first
  2. withdrawRewardFor calls out againYour fallback runs while the books are unchanged
  3. The balance is zeroed lastEvery recursive pass is paid against the full balance
  4. Loop until empty3.6M ETH — and the Ethereum / Classic hard fork
DAO.sol — splitDAO
function splitDAO(uint _proposalID, address _newCurator) noEther onlyTokenholders
    returns (bool _success) {
    ...
    // ❶ move the ether out — control leaves the contract here
    uint fundsToBeMoved = (balances[msg.sender] * p.splitData[0].splitBalance)
                          / p.splitData[0].totalSupply;
    if (p.splitData[0].newDAO.createTokenProxy.value(fundsToBeMoved)(msg.sender) == false)
        throw;
    ...
    // ❷ burn the tokens — this is where the balance finally goes to zero
    Transfer(msg.sender, 0, balances[msg.sender]);
    withdrawRewardFor(msg.sender);          // ← another external call, still before ❸
    totalSupply       -= balances[msg.sender];
    balances[msg.sender] = 0;                // ❸ too late
    paidOut[msg.sender]  = 0;
    return true;
}

// attacker's fallback: call splitDAO() again.
// balances[msg.sender] is still the original number, every single pass.
→ result: 3,600,000 ETH · the Ethereum / Ethereum Classic hard fork
What an audit looks for: checks-effects-interactions, on every function, without exception — write state before you call out, and guard the ones that cannot. The DAO's reviewers missed it because they read functions one at a time; the bug lived in the interaction between splitDAO and withdrawRewardFor. Auditing functions in isolation is how this gets missed.
20 Arithmetic Uranium Finance28 Apr 2021 · BNB Chain · AMM · one digit ~$50M$31M later seized by US authorities

A migration bumped the pair contract's precision constant from 1,000 to 10,000 — but the change did not reach the swap() function's constant-product sanity check, which still multiplied the reserves by 1000**2 while the balances on the other side of the comparison were scaled by 10,000. The invariant was 100× too loose. Anyone could swap in dust and take nearly the whole reserve. The project had even been told about a related issue in an audit, patched it, and the patch was not enough.

How the attack ran
  1. Swap in a tiny amountOf either side of the pair
  2. swap() checks the K invariantBalances are scaled by 10,000
  3. Reserves scaled by 1000²Left and right side disagree — the check is 100× too loose
  4. Take the reservesDust in, the pool out — ~$50M
UraniumPair.sol — swap()
uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(16));
uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(16));

require(
    balance0Adjusted.mul(balance1Adjusted) >=
    uint(_reserve0).mul(_reserve1).mul(1000**2),      // ❌ should be 10000**2
    'UraniumSwap: K'
);

// left side scaled by 10000² = 100,000,000
// right side scaled by  1000² =   1,000,000
// → the K invariant is satisfied by 1/100th of the value it should require.

→ result: swap in dust, walk out with the pool
What an audit looks for: magic numbers that appear in more than one place. A constant repeated across functions is a bug waiting for the next refactor — hoist it into a single named constant so the compiler makes the mistake impossible. And every core invariant deserves a property test that would fail loudly at 100× drift.
🛈

On sourcing. Every figure above is taken from the incident's public post-mortem or from Rekt's leaderboard, valued at the prices on the day it happened. Code is quoted from the published post-mortems and public repositories; where a snippet is condensed or annotated for readability, the comments say so, and the sources on each card take you to the unabridged original. The "unaudited" count is Rekt's own tag on those entries, not our assessment.

What the record actually says

Six things twenty disasters agree on

01 The bug is almost never clever

A wrong comparison operator. A default zero. A permission nobody revoked. A constant that got updated in one file out of two. Sophisticated cryptography is not what empties contracts — forgotten basics are.

02 New code is where it breaks

Euler's donation function, Compound's reward split, Uranium's migration, Nomad's upgrade. Four of these were introduced by a change made after the original review. An audit is not a certificate — it covers a commit.

03 Scope is where auditors go blind

Rekt tags eight of these twenty as shipped with no audit at all, and a ninth as out of scope. When something is reviewed and still falls, it is usually because the broken part was never in the brief.

04 The code is only half the surface

KelpDAO's verifier count, Ronin's stale allowlist, Bybit's signing screen, Curve's compiler version. Perfect Solidity, catastrophic loss. Configuration, keys and toolchain have to be in scope too.

05 The fixes are one line and they are cheap

>= instead of >. 1 << 192 instead of a mask. A modifier on an initializer. A snapshot instead of a live balance. Every fix on this page costs less than an hour. Not finding them cost $5.5 billion.

06 And it happens to small contracts too

FastBNB, at the top of this page, is the same story at a scale nobody wrote a post-mortem about. Most losses never make a leaderboard — they just quietly take one community's money.

This is what an audit is for.

A single disguised function can cost a community everything. Before you deposit — or before you launch — have the contract read by people whose only job is to find the trap.