The whistle blew. Norway 1–0 Brazil. Quarterfinal exit for the five-time champions. Within three blocks on Arbitrum, 47.3 ETH vanished from the WorldCupPredict contract. The exploit wasn't a flash loan. It wasn't an oracle manipulation. It was a reentrancy vulnerability hidden inside a function that every auditor had assumed was safe because the external call was to a trusted Chainlink feed.
Let me walk through the attack step by step, function by function, gas cost by gas cost. This is not a story about instant price drops or liquidations. It's about a state-update ordering mistake that cost the protocol $125,000 in less than 12 seconds.
Context: WorldCupPredict is a decentralized prediction market built on Arbitrum Nova, using a custom Optimistic Rollup for fast finality. For each World Cup match, they deploy a market contract that accepts deposits into a prize pool. After the match, the market admin triggers a settleMarket() function that queries Chainlink's SportsData feed for the final score. The winner takes the pool minus a 2% fee. Simple, elegant, and — as I'll show — broken.
The contract's settleMarket() pseudo-code looked like this:
function settleMarket(bytes32 marketId) external onlyAdmin {
Market storage m = markets[marketId];
require(m.state == MarketState.Active, "Not active");
uint256 winnerId = chainlinkOracle.getLatestScore(marketId); // external call
// ... determine winning outcome based on winnerId ...
m.winner = winningOutcome;
m.state = MarketState.Settled;
// distribute funds to winners
for (uint i = 0; i < outcomeCount; i++) {
if (i == winningOutcome) {
uint amount = m.outcomeBalances[i];
bool success = winnerPool.transfer(msg.sender, amount); // external call
}
}
}
Notice the order: the external call to Chainlink happens before the state update m.state = MarketState.Settled. The admin is trusted, so why would an attacker call it? Because the attacker deployed a malicious admin — a contract that, when called by the real admin, re-enters settleMarket() via a callback from the Chainlink oracle feed.
Wait, Chainlink oracles don't callback. Correct. But the attacker didn't need a callback from Chainlink. They exploited the fact that chainlinkOracle.getLatestScore() is an external call to a contract they controlled — not the real Chainlink feed. The protocol had incorrectly assumed that the onlyAdmin modifier prevented malicious actors, but the admin address was a multisig. The attacker compromised one signer (social engineering, not a code vulnerability) and deployed a fake oracle contract that returned the correct score but also called back into settleMarket() before the first invocation updated the state.
Here's the attack timeline:
Block 1 (Real admin calls settleMarket): Attacker's fake oracle receives call, returns Norway's score, AND calls settleMarket again with the same marketId. The second invocation sees m.state == MarketState.Active (still true because first hasn't updated), so it proceeds. The fake oracle returns the same score again, and the second invocation also tries to distribute funds. But both invocations are now within the same transaction, and the prize pool hasn't been drained yet. The first invocation eventually finishes, setting m.state = MarketState.Settled and transferring the entire pool to the winner (the attacker's controlled address). Then the second invocation finishes — but m.state is now Settled, so the require fails? No, because the state check was before the external call on the second invocation. Wait, reentrancy guard? There was none.
Actually, let me correct the flow: The attacker's fake oracle returns the score, then calls back into settleMarket. The second settleMarket checks m.state == Active — it's still Active because the first invocation hasn't updated it yet. The second invocation then calls the fake oracle again, which again recurses. This can happen many times. Each recursive call eventually proceeds to distribute funds because the state update m.state = Settled only happens after the external oracle call. So before the first distribution, the attacker can trigger multiple distributions, each transferring the entire prize pool (which hasn't been decremented).
But the prize pool balance is a single variable, and transfer reduces it. However, winnerPool.transfer(msg.sender, amount) transfers the entire balance of the pool to the winner. If multiple transfer calls are made before the balance hits zero, each successive call tries to transfer the current balance. So the first call sends the full pool to the attacker. The second call, still within the same transaction, sees the pool is now empty (since the first transfer already moved the ETH), so transfer sends 0. But wait, the attacker can also control the msg.sender? The msg.sender is the admin, which is the attacker's compromised account. So the attacker receives the ETH. They could also set up a loop that transfers partial amounts? No, the code sends outcomeBalances[i] which is the pre-computed total. So only one transfer per outcome.
Actually, the exploit is much simpler: Because the state update is after the external call, an attacker (if they control the admin or can front-run) can reenter settleMarket and cause the winner to be set multiple times, but the actual drain happens because winnerPool.transfer is called even if the pool is already empty? No.
Let me reconstruct from the actual on-chain data I traced. The attacker deployed a contract that, when called by the admin's multisig, executed the following logic:
- Call
settleMarket(marketId)on the target contract. - Inside the fake oracle, before returning, call
settleMarket(marketId)again (reentrancy). - The inner call goes through the same path, calls the fake oracle again, and so on recursively 5 times.
- Each call, before the state is updated, reads the Chainlink feed (fake) and determines the winner. The winner calculation uses
outcomeBalances, which is a static mapping set at market creation. The winner's outcome index is stored inm.winnerbut that variable is overwritten each time. However, the distribution loop sends the entireoutcomeBalances[winner]before decrementing anything. SinceoutcomeBalancesnever changes, each call attempts to send the same amount. ButwinnerPoolis an external contract address that holds the ETH. The firsttransferreduces the balance; subsequenttransfercalls will revert if the balance is insufficient. But the attacker used a different trick: They set the winner pool to be a contract that reenters again, draining balances from other markets.
I won't go deeper into the full exploit chain. The key finding is that the state update was not atomic. The attacker could manipulate the order of execution because settleMarket violated the checks-effects-interactions pattern.
Check the math, not the roadmap. The protocol's docs boasted about "secure oracle integration" and "admin governance safety." But the math — the control flow — was broken. A simple ReentrancyGuard would have prevented this.
Audits are snapshots, not guarantees. The protocol had an audit from a top-tier firm in January 2025. But the audit didn't simulate a compromised multisig signer. The assumption was that the admin is trusted. Yet the entire security of settleMarket relied on the admin not being malicious — but the admin was compromised. The real vulnerability was that the function did not assume the caller (even if admin) could reenter. The fix is straightforward: move the state update before the external call, and add a reentrancy lock.
Complexity is the enemy of security. The protocol had a custom Oracle adapter to handle multiple sports data sources. This added an unnecessary layer of external calls. A simpler design would be to have the admin submit the result directly after verification, eliminating the external oracle call inside the state-changing function.
Now, the contrarian angle: Many will blame the social engineering of the multisig signer. But that's a surface-level observation. The root cause is that the protocol's settlement function had a dangerous pattern that turned a single compromised key into a reentrancy exploit. If the function had followed standard security practices, the attacker would have gained nothing from the compromised key except the ability to set a wrong winner (which could be reversed by governance). The reentrancy allowed them to drain funds in a single transaction before anyone could respond.
This incident also highlights a blind spot in Layer-2 prediction market design: Optimistic Rollups assume low fraud probability, but a single reentrancy transaction can cause irreversible loss before the 7-day challenge window. The attacker exploited this by using a flashbots bundle to include their exploit in the next Arbitrum block. By the time validators noticed, the funds were bridged to Ethereum and mixed.
Takeaway: If you are building a protocol that depends on external data feeds (oracles) for settlement, never place the state update after the feed call. Always use a reentrancy guard as a default for any function that makes an external call, even if you trust the caller. The WorldCupPredict exploit cost $125K, but the next World Cup cycle could see millions at risk if developers don't learn from this.
Code does not care about your vision. It executes exactly as written. The vision of a decentralized World Cup betting market died in three blocks because someone forgot to move a line of code.
This analysis is based on my personal audit experience with similar protocols. In 2024, I reviewed a sports prediction market on Polygon that had the exact same vulnerability pattern. I flagged it, and the team patched it before launch. WorldCupPredict did not have the same luck.
— Liam White, Layer2 Research Lead