Web3 Security 101Part 6 of 12SmartAlert7 min read

Why bridges are the biggest target: how the Ronin and Orbit bridges lost money

A bridge keeps everyone's tokens in one contract and trusts off-chain signers. Ronin and Orbit step by step: which alerts would fire and which would not.

*Part 6 of 12 in Web3 Security 101.*

A blockchain cannot read another blockchain. Ethereum has no way to check that a user burned tokens on some other network. A bridge is the workaround, and the workaround is also the weak spot. This part explains how a bridge works, why it concentrates risk, and what two very different bridge incidents looked like on chain.

The mechanism

The common design is lock and release, or lock and mint:

  1. You send tokens to a vault contract on chain A. The vault holds them.
  2. On chain B you get a wrapped token, minted or released against the lock.
  3. When you want to go back, you burn the wrapped token on chain B. The vault on chain A releases the original tokens.

Step 3 is the problem. The vault on chain A cannot see chain B. Someone has to tell it "this burn really happened", and that someone is a set of off-chain signers: validators, operators, witnesses, the name differs per bridge. They watch both chains, sign a withdrawal message, and the vault pays out when enough valid signatures are attached.

That gives a bridge three properties that make it a target:

  • Everything is in one place. All the tokens ever bridged sit in one vault or a handful of contracts. Orbit's vault on Ethereum held about $81.5M in DAI, WBTC, ETH, USDC and USDT at the time of the attack12. At Ronin, according to The Block, the Ethereum bridge held more than $850M3.
  • The vault trusts signatures, not facts. It cannot tell a real burn from an invented one. If the signatures are valid, the money moves.
  • Three things can break it independently. The signers' keys, the key that can change the contract (owner, proxy admin), and the numbers inside the contract that define "enough signatures". An attacker needs only one.

We saw the second one in part 5, with IoTeX. Here are the other two.

Ronin, August 2024: a number that became zero

Ronin is the chain behind Axie Infinity. Its Ethereum gateway pays out when the operators' signatures reach a threshold, and the view function minimumVoteWeight() returns that threshold. Before the upgrade it returned 1540, which is 70% of the operators' total weight45.

On August 6, 2024 at 08:48:47 UTC the team upgraded the gateway. An upgradeable contract fills its new state through numbered initializers. The upgrade ran version 4 and skipped version 3, the one that set _totalOperatorWeight. So the total stayed 0, and 70% of 0 is 0. From that block minimumVoteWeight() returned 0, and a withdrawal needed no signatures at all56.

Nobody broke in. The upgrade was the team's own, and the Upgraded event it emitted was expected. At 09:37:23, 48 minutes later, an MEV bot withdrew about 4,000 ETH. At 10:11:47 another address withdrew 1,998,046.88 USDC. In total about $12M left. The bot belonged to a whitehat, and the funds came back. The team paused the bridge at 10:15:2326. The gateway capped each withdrawal at about 4,000 ETH or 2M USDC, so the bot could not take more per transaction2.

An alert that calls minimumVoteWeight() every minute and fires below 1 would have fired at about 08:51–08:53: about 45 minutes before the first withdrawal. Our Ronin write-up has the transactions and the archive reads that prove the value.

Orbit Bridge, December 2023: valid signatures, wrong hands

Orbit Bridge keeps every bridged token on Ethereum in one vault. The vault is a multisig: 10 owners, 7 signatures required. We read getOwners() and required() one block before the first attacker transaction7.

On December 31, 2023 at 20:40:35 UTC an attacker withdrew 0.012 WBTC, a test. At 20:52:47 they withdrew 30 ETH. Then, between 21:07:59 and 21:25:35, five large withdrawals: $10M DAI, 230.88 WBTC, 9,500 ETH, $10M USDC and $30M USDT. About $81.5M in total. The vault was switched off at 22:21:35, 74 minutes after the first big withdrawal and when it was almost empty127.

Every one of these withdrawals carried seven valid signatures. Orbit's owner, Ozys, says the contracts had no bug; it later sued its former security chief, saying he weakened the firewall policy before he left. How the attacker got seven signatures has not been explained in public, and we do not guess8. For monitoring it does not matter: the signatures looked right, and only the amounts did not.

The first two withdrawals, about $500 and about $69K, are below any threshold you could set without drowning in false alarms. The $10M DAI at 21:07:59 is not. A rule on transfers above $1M from the vault would have fired at about 21:10:30–21:12, with about $20M already gone but about $61.5M still in the vault. The 9,500 ETH left about 5–6 minutes after the alert, the USDC and USDT about 13–14 minutes after. Orbit had a switch for the vault and used it 74 minutes after the first big withdrawal7. The details are in our Orbit write-up.

What the two cases show

Ronin and Orbit are opposites. At Ronin the signers were never involved: the contract's own parameter changed, there was no suspicious transfer until 48 minutes later, and the state check was the only early signal. At Orbit the contract's parameters were fine, the signers were the problem, and the first useful signal was the size of the money leaving.

So a bridge needs two kinds of watching: what the contract says about itself (state), and what leaves it (flows). Neither covers the other.

How to catch it

  1. Watch the safety numbers with a "Contract state" rule. For a Ronin-style bridge: minimumVoteWeight(), call every minute, fire when below 1. It is the same idea as the paused() recipe, with a number instead of true/false. Create it before your next upgrade: the rule records answers from the moment it exists and does not read the past. Pro and above. For a multisig-style vault, required() works the same way.
  2. Watch upgrades and owner changes. Proxy upgraded and ownership transferred on the gateway and on the contract that manages it. These alerts tell you when the code changed. The state rule tells you what it changed.
  3. Watch outflows in dollars. Transfer above an amount in $ on each token the vault holds, filtered by from = the vault. Choose a threshold above your biggest normal user withdrawal and well below the vault total. This rule prices the amount from the moment it is created and cannot be tested on old transfers, so pick the threshold first with a large transfer rule in tokens, which does show how often it would have fired.
  4. Cover native ETH. ETH leaves a vault without a Transfer event. Add the vault in "Addresses" and create a "Total address balance in $ below threshold" rule with a floor a little under the normal total.
  5. Send these alerts to the person who holds the pause key, directly. Ronin had a PauseEnforcer and Orbit had a switch. Both were used after an hour.

Two things we cannot do. We cannot watch the list of signers: getOwners() returns an array, and contract-state rules support only a bool, an address or a number. If your vault emits owner-change events, the event rule covers it; if not, there is no rule for it today. And we cannot tell a valid withdrawal from a forged one: below the threshold, a stolen-signature withdrawal looks exactly like a user's.

Next: part 7, oracle and price manipulation, flash loans and KiloEx.

Sources

  1. 1.Orbit Chain, Official statement regarding Orbit Bridge exploit: the amounts and the stolen tokens. ↩
  2. 2.Rekt, Orbit Bridge — Rekt and Ronin Network — Rekt II: timelines, the Ronin skipped initializer and per-withdrawal limit. ↩
  3. 3.The Block, report on the Ronin bridge pause: returned funds, more than $850M in the bridge. ↩
  4. 4.Three Sigma, analysis of the Ronin Network exploit: initializeV3, _totalOperatorWeight, the 70% threshold. ↩
  5. 5.SmartAlert, Ronin Bridge hack, August 2024: minimumVoteWeight() read at archive blocks, upgrade logs. ↩
  6. 6.Ronin Network on X, incident statement. Transaction times: SmartAlert, on-chain. ↩
  7. 7.SmartAlert, Orbit Bridge hack, December 2023: getOwners() and required() at block 18907899, every transaction and block time checked on-chain. ↩
  8. 8.The Block, report on Ozys and its former security chief: no contract bug, the firewall change, the lawsuit. ↩

Try it free

An address is enough. The first 14 days get Pro limits, and rules are checked on history before they go live.