◈ 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.
“Liquidity”
Reads as adding or protecting liquidity for holders — a reassuring, pro-investor action.
↗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:
// 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.
! Impact
⌕ How we caught it
Not the project team. With no incentive to soften findings, we reviewed the contract purely in the depositor's interest.
We read each public/owner function by hand — invest, withdraw, and the innocuously named Liquidity — not just automated tool output.
We followed the owner wallet's outgoing BNB and matched every drain back to Liquidity() calls on the live contract via BscScan.
The result: a Critical finding backed by permanent, public transaction hashes anyone can open and verify — no trust required.
⛓ 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.
☠ 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.
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.
- Poisoned signing UIA tampered JS bundle is served from Safe{Wallet}’s S3 bucket
- Three signers approveTheir screens read “transfer 30,000 ETH to the hot wallet”
- operation = 1DELEGATECALL, not CALL — the target’s code runs as the wallet
- masterCopy overwrittenSlot 0 replaced → 401,000 ETH swept out
// 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
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.
- Sky Mavis is breached4 of the 9 validator keys sit on one company’s servers
- A stale allowlist is reusedAxie DAO’s gas-free RPC grant from November, never revoked
- Quorum reached by one party5-of-9 signatures, all traceable to a single compromise
- Withdrawal signed173,600 ETH + 25.5M USDC — unnoticed for six days
// 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
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.
- A cross-chain message is craftedTarget contract and method name are both attacker-chosen
- A method name is brute-forced
f1121318093, picked for its keccak prefix - Selector collision0x41973cd9 ≡
putCurEpochConPubKeyBytes, and the bridge owns that contract - Keepers replacedThe attacker now signs the bridge’s own withdrawals
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
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.
- Register as a relayerA legitimate, open bridge role
- Submit an IAVL range proofWith one extra leaf node appended to it
- The verifier hashes only what it walksThe forged leaf is never visited, so the root still matches
- Released twice1,000,000 BNB × 2 against a proof of nothing
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
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.
- Build a fake guardian messageNo real signatures behind it
- Pass your own accountIn the slot where the Instructions sysvar belongs
load_instruction_attrusts itIt parses the data without checking the account’s address- Minted from nothing120,000 wETH on Solana, no ETH locked
// ❌ 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 )?;
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.
- Social-engineer a developerLayerZero Labs, six weeks before the theft
- Poison the verifier’s RPC nodesThe DVN now reports attacker-supplied chain state
- requiredDVNs.length == 1One verifier means compromising it IS compromising the bridge
- Released on a ghost message116,500 rsETH for a Unichain tx that never existed
// 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
.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.
- Open a liquidity positionWith an absurdly large liquidity value
- The math calls
checked_shlwThe guard that should reject an overflowing shift - Mask is 0xffff…<<192~2⁶⁴× too high, and Move’s
<<never aborts - Required deposit rounds to 1Near-infinite liquidity for one unit of token A
// ❌ 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
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.
- Flash-loan and loop depositsLayered leverage — every step legitimate
- Donate your own collateral
donateToReserves: equity falls, debt does not - No
checkLiquidity()The one balance-reducing path that skips the solvency check - Self-liquidate at a discountTake your own position at up to 20% off — $197M
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
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.
- An upgrade sets the root to zero
initialize()then marks it confirmed - Send a message that was never provenAn unwritten mapping entry returns 0x00
- acceptableRoot(0x00) == trueSo every unproven message passes the
!provencheck - Copy-paste looting~300 addresses reused the calldata — $190M
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
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.
- Submit BIP-18“Transfer the protocol’s assets to me”
- Wait 24 hoursThe emergency-commit window opens
- Votes = current balanceFlash-loan ~$1B, vote, commit — all in one transaction
- Executed in a single block$181M out, the loan repaid before the block closed
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.
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.
- Call the wallet’s fallbackAnything unmatched is
delegatecalled into the library - Call
initWallet([me], 1)Re-initialise it and become the only owner - No initializer guard — on the library eitherSo the library itself can be claimed, then
kill()ed - Stolen, then bricked153,037 ETH taken · 513,774 ETH frozen forever
// 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
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.
- Proposal 62 shipsCOMP rewards split into supply and borrow speeds
- A first-time supplier claimsTheir stored index is still 0
>where>=was neededThe init branch is skipped, so the delta becomes the full 1e36- Paid out for a week~$147M — a 7-day timelock and no pause switch
// ❌ 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
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.
- Flash-loan, then burn sharesVault supply squeezed down to ~$8M
- Transfer $8M into the vaultA plain ERC-20 transfer — no mint, no swap, no fee
- Price = balance() / totalSupplyA donation doubles the price per share instantly
- $1.5B collateral reads $3BBorrow out everything on the shelf — $130M
// 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
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.
- Push balances to 8–9 weiWhere a single wei is ~11% of the value
- Chain dozens of swapsAll inside one
batchSwapcall _upscalealways rounds downEvery hop hands the discarded wei to the caller- BPT price suppressed$128M across 8 chains in under 30 minutes
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.
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.
- Fund two accountsAbout $5M on each side
- Trade MNGO-PERP against yourselfMark price +1,000% in roughly 20 minutes
- No cap on illiquid collateralThe risk engine values the position at the pumped mark
- Borrow against the pump$115M out; the code never malfunctioned
// 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.
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.
- Deposit collateral, then borrow
borrowFreshsends the ETH out first - The ETH hits your
receive()Control is yours before the loan is written to storage exitMarket()sees no debtA different function, sharing state but not a lock- Collateral back, loan keptRepeated pool after pool — $80M
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()
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.
- Call
deposit()with token 0x0And withmsg.value = 0 - Hand-rolled
safeTransferFromrunsA rawcall, with noisContractcheck - A call to an empty account succeedsIt returns
(true, "")— both requires pass - The Deposit event fires anyway77,162 qXETH minted against nothing — $80M
// ❌ 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
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.
- Both functions declare the same lock
@nonreentrant('lock')— correct source - Vyper 0.2.15–0.3.0 compiles itGiving each function its own storage slot
- The shared lock is not sharedAudited source, unprotected bytecode
- Re-enter via the ETH callbackFour pools drained — $69.3M
# 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"
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.
- Call
splitDAO()The ether payout leaves the contract first withdrawRewardForcalls out againYour fallback runs while the books are unchanged- The balance is zeroed lastEvery recursive pass is paid against the full balance
- Loop until empty3.6M ETH — and the Ethereum / Classic hard fork
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
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.
- Swap in a tiny amountOf either side of the pair
swap()checks the K invariantBalances are scaled by 10,000- Reserves scaled by 1000²Left and right side disagree — the check is 100× too loose
- Take the reservesDust in, the pool out — ~$50M
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
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.
≡ Six things twenty disasters agree on
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.
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.
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.
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.
>= 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.
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.