Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

Ronin Bridge, August 2024: the vote threshold was 0 for 48 minutes before the first withdrawal, and no event said so

On August 6, 2024 the Ronin team upgraded its bridge on Ethereum. 48 minutes later an MEV bot withdrew about 4,000 ETH from it, and half an hour after that another address took 2M USDC. In total about $12M left. The bot

On August 6, 2024 the Ronin team upgraded its bridge on Ethereum. 48 minutes later an MEV bot withdrew about 4,000 ETH from it, and half an hour after that another address took 2M USDC. In total about $12M left. The bot belonged to a whitehat, and the funds came back the same day[1][2][3].

Most bridge hacks start with a stolen key. This one did not. Nobody broke in, and nothing unusual was emitted. The upgrade was the team's own, and the Upgraded event was expected. What broke was a number inside the contract, and only a question to the contract could show it.

How the Ronin bridge approves a withdrawal

Ronin is the blockchain behind Axie Infinity. Its bridge to Ethereum has two parts that matter here[2][3]:

  • The gateway 0x6419…AF08 on Ethereum holds the bridged ETH, USDC and other tokens. It is a proxy: the address stays, the code behind it can be replaced.
  • Bridge operators watch Ronin. When a user withdraws to Ethereum, operators sign the withdrawal. Each operator has a vote weight.

The gateway pays out only when the signers' total weight reaches a threshold. The view function minimumVoteWeight() returns it. Before August 6 it returned 1540, 70% of the operators' total weight[3][7].

The gateway also limits how much one withdrawal may take, about 4,000 ETH or 2M USDC[2]. Keep that in mind. It capped the size of each withdrawal.

Changes to the gateway go through MainchainBridgeManager 0xa714…fab0: a proposal needs operator signatures, then anyone can relay it on-chain. For emergencies there is a separate PauseEnforcer 0xe514…bcd4 that can stop the gateway in one call.

What went wrong in the upgrade

An upgradeable contract keeps its state in the proxy. New code often needs new state, so it ships with an initializer that fills it. The gateway had several, numbered by version. Version 3 set _totalOperatorWeight, the total weight the threshold is computed from. Version 4 set a new helper contract for WETH[2][3].

The upgrade on August 6 jumped straight to version 4. The proxy had never run version 3. So _totalOperatorWeight stayed 0, and 70% of 0 is 0[3].

The upgrade logs show this. They contain Upgraded and Initialized(4), but no Initialized(3)[7].

The attack, step by step

All times UTC, block times from Ethereum. We read every transaction below on-chain ourselves[7].

08:48:47 — the planned upgrade

Address 0xe880…f059 relayed proposal round 5 to MainchainBridgeManager with 16 operator signatures (0x855d…767e, block 20468438). The proposal made six calls to the gateway. Among them: add WBTC, switch the code to 0xfc27…20Ee and run initializeV4, then change the proxy admin. The last two calls failed, the first four succeeded[7].

We read minimumVoteWeight() with eth_call on archive blocks[7]:

Block When minimumVoteWeight()
20468437 the block before the upgrade 1540
20468438 08:48:47, the upgrade 0
20468678 the block before the first withdrawal 0

From this block, a withdrawal from the gateway needed no operator signatures at all. Anyone who noticed could take up to the per-withdrawal limit, as many times as they liked.

🔔 SmartAlert

Rule: "Contract state" on the gateway: call minimumVoteWeight() every minute, fire when the answer is below 1. It is the same rule as the paused() recipe, with a number instead of true/false.

Alert: about 08:51–08:53. The rule reads the block 12 confirmations behind the head (about 144 seconds), so the first call that sees 0 lands 2.5–4 minutes after the upgrade[8].

Time left: about 45 minutes before the first withdrawal (09:37:23) and 79 minutes before the second (10:11:47).

What the team could do: the PauseEnforcer was there. One triggerPause() stops the gateway. The team made exactly that call later, at 10:15:23.

⚠️ What it misses

An Upgraded rule would fire here too, and it would not help. The team was waiting for this event. An alert that says "the gateway was upgraded" reads as "yes, that is ours". Nothing in the event stream says the threshold is now 0. Only the contract knows that, and only if someone asks it.

09:37:23 — 3,996 ETH

Address 0x1a56…3cc1 called its MEV contract 0x4Ab1…3c0B, which called submitWithdrawal on the gateway. The gateway paid out 3,996.09 ETH (0x2619…a6cb, block 20468679)[7]. This was 48 minutes after the upgrade.

🔔 SmartAlert

Rule: "Total address balance in $ below threshold" on the gateway, with a floor a little under the normal total. Native ETH does not emit Transfer, so a balance rule is how you see it leave.

Alert: about 09:40–09:41.

Time left: about 31 minutes before the 2M USDC.

The honest part: this is the alert that confirms the first one. On its own it comes after $10M has left. With the state alert in front of it, it says "it is happening now".

10:11:47 — 2M USDC

Address 0xFDe0…455A withdrew 1,998,046.88 USDC to 0x6980…BDD0 (0xbce5…0ad8, block 20468848)[7].

🔔 SmartAlert

Rule: Transfer above an amount in $ on USDC, from = the gateway, threshold $1,000,000.

Alert: about 10:14–10:15:30, about when the team paused the bridge. This one tells you the size of the loss, not the time left.

10:15:23 — the pause

Address 0x8B35…8f57 called triggerPause() on the PauseEnforcer (0x90a1…c090, block 20468866)[7]. That is 38 minutes after the first withdrawal and 86 minutes after the upgrade. Ronin said it paused the bridge about 40 minutes after the first withdrawal[1].

After — the money came back

The whitehat behind the MEV bot returned the ETH, then the USDC about two hours later. Ronin paid a $500K bounty. According to The Block, the bridge held more than $850M at the time[1][4]. The per-withdrawal limit capped each transaction at about 4,000 ETH or 2M USDC[2]. SEAL 911 helped with the response[3].

What no rule would have changed

⚠️ What it misses

A rule created after the upgrade. A contract-state rule starts recording answers when you create it. It does not read past blocks, so you cannot check it on history the way you can an event rule. To show the moment of the change, it had to exist before 08:48:47 for Ronin. A "below 1" rule created later would still fire, on its first call.

⚠️ What it misses

Knowing which number matters. Here it is minimumVoteWeight(). For a lending market it may be a price from an oracle, for a token paused() or owner(). A monitor checks the numbers you point it at. It does not guess your invariants.

⚠️ What it misses

Attacks in one transaction. If the bad value and the withdrawal land in the same transaction, a state check sees the result, not the warning. At Bybit in February 2025, a signed Safe transaction emptied about $1.46B two and a half minutes after the first sign[5]. At KiloEx in April 2025, pools were drained in the same second[6]. Ronin had 48 minutes. Not every incident does.

How to set up this rule in SmartAlert

  1. Add the contract and open it. Contract-state rules are on Pro and above.
  2. Create a "Contract state" rule. Pick the function, for example minimumVoteWeight(), and press "Check function" to see the current answer.
  3. Choose the condition "Below threshold" with a threshold under the normal value, for example 1. For a bridge, call every minute.
  4. Create it before your next upgrade. Then the first call after the upgrade shows whether the number survived.
  5. Add the event rules too. Upgraded tells you when the code changed, the state rule tells you what it changed. For native ETH, add the gateway in "Addresses" and create a "Total address balance in $ below threshold" rule.
  6. Choose where alerts go. Telegram is free.

See all alert recipes.

Sources

  1. Ronin Network on X, incident statement: pause, about 40 minutes, the whitehat.
  2. Rekt, Ronin Network — Rekt II: the skipped initializer, the per-withdrawal limit, the amounts.
  3. Three Sigma, analysis of the Ronin Network exploit: initializeV3 and _totalOperatorWeight, the 70% threshold, SEAL 911.
  4. The Block, report on the Ronin bridge pause: returned funds, the $500K bounty, more than $850M in the bridge.
  5. CertiK, Bybit incident technical analysis.
  6. KiloEx, Security incident root cause analysis.
  7. Checked by SmartAlert on-chain: minimumVoteWeight() with archive eth_call at blocks 20468437, 20468438 and 20468678; the upgrade transaction, its six calls, their results and its logs (Upgraded, Initialized(4), no Initialized(3)); the two withdrawals and the pause, with block times, senders and amounts, through the Blockscout API.
  8. SmartAlert, alert delay on Ethereum: 12 confirmations (about 144 s), head polling up to 12 s, storage up to 15 s (P95), rule check every 60 s on Pro.

Originally published on the SmartAlert blog.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.