In August 2021, Poly Network lost $611 million—the largest DeFi hack at the time. The cause? A function that should have been restricted to privileged callers was accessible to anyone. Access control failures are responsible for roughly 15% of all DeFi exploits, and unlike complex vulnerabilities, they’re often embarrassingly simple.
The Cost of a Missing Modifier
Access control bugs are the “unlocked door” of smart contract security. They don’t require sophisticated attacks or flash loans. An attacker simply calls a function they shouldn’t be able to call.
Consider this vulnerable code:
contract VulnerableVault {
mapping(address => uint256) public balances;
function adminWithdraw(address to, uint256 amount) external {
// No access control!
payable(to).transfer(amount);
}
} Anyone can call adminWithdraw and drain the contract. The fix is trivial—add onlyOwner—but the exploit is devastating.
Common Access Control Patterns (and Failures)
Pattern 1: Missing Access Control Entirely
The most basic failure. A privileged function simply lacks any authorization check.
// VULNERABLE
function setFeeRecipient(address newRecipient) external {
feeRecipient = newRecipient;
}
// FIXED
function setFeeRecipient(address newRecipient) external onlyOwner {
feeRecipient = newRecipient;
} How This Happens:
- Developer forgets to add modifier
- Function was internal, got changed to external
- Copy-paste error from a public function
- “We’ll add access control later” (never happens)
Pattern 2: Incorrect Access Control Logic
The check exists but doesn’t work as intended.
// VULNERABLE: Wrong comparison
function adminAction() external {
require(msg.sender != owner, "Not authorized"); // Should be ==
// ...
}
// VULNERABLE: Missing return in modifier
modifier onlyOwner() {
if (msg.sender != owner) {
// Missing revert - function continues!
}
_;
}
// FIXED
modifier onlyOwner() {
require(msg.sender == owner, "Not authorized");
_;
} Pattern 3: Broken Role Management
Complex role systems create more opportunities for mistakes.
// VULNERABLE: Anyone can grant themselves admin
contract BrokenRoles {
mapping(address => bool) public admins;
function grantAdmin(address account) external {
admins[account] = true; // No check who's calling!
}
}
// FIXED
contract SecureRoles {
mapping(address => bool) public admins;
address public superAdmin;
constructor() {
superAdmin = msg.sender;
admins[msg.sender] = true;
}
function grantAdmin(address account) external {
require(msg.sender == superAdmin, "Only super admin");
admins[account] = true;
}
} Pattern 4: Initialization Vulnerabilities
Initializers that can be called multiple times or by anyone.
// VULNERABLE: Initializer can be called by anyone
contract VulnerableProxy {
address public owner;
bool public initialized;
function initialize(address _owner) external {
require(!initialized, "Already initialized");
owner = _owner;
initialized = true;
}
}
// Attacker calls initialize() first, becomes owner
// FIXED: Use OpenZeppelin's Initializable
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract SecureProxy is Initializable {
address public owner;
function initialize(address _owner) external initializer {
owner = _owner;
}
} Pattern 5: tx.origin Authentication
Using tx.origin instead of msg.sender enables phishing attacks.
// VULNERABLE: tx.origin can be spoofed via phishing
function withdraw() external {
require(tx.origin == owner, "Not owner");
payable(owner).transfer(address(this).balance);
}
// Attack: Trick owner into calling attacker's contract
contract Attacker {
VulnerableContract target;
function attack() external {
// When owner calls this, tx.origin == owner
target.withdraw(); // Succeeds!
}
}
// FIXED: Always use msg.sender
function withdraw() external {
require(msg.sender == owner, "Not owner");
payable(owner).transfer(address(this).balance);
} Building Robust Access Control
Use OpenZeppelin’s Access Control
Don’t reinvent the wheel. OpenZeppelin’s AccessControl is battle-tested:
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureProtocol is AccessControl {
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(ADMIN_ROLE, msg.sender);
}
function adminFunction() external onlyRole(ADMIN_ROLE) {
// Only admins can call
}
function operatorFunction() external onlyRole(OPERATOR_ROLE) {
// Only operators can call
}
} Implement Two-Step Ownership Transfers
Prevent accidental transfer to wrong addresses:
import "@openzeppelin/contracts/access/Ownable2Step.sol";
contract SecureOwnership is Ownable2Step {
// transferOwnership() now requires the new owner to call acceptOwnership()
function criticalFunction() external onlyOwner {
// Only confirmed owner can call
}
} Add Timelocks for Critical Operations
Give users time to react to parameter changes:
contract TimelockAdmin {
uint256 public constant TIMELOCK_DELAY = 2 days;
struct PendingChange {
bytes32 actionHash;
uint256 executeAfter;
}
mapping(bytes32 => PendingChange) public pendingChanges;
function scheduleFeeChange(uint256 newFee) external onlyOwner {
bytes32 actionHash = keccak256(abi.encode("setFee", newFee));
pendingChanges[actionHash] = PendingChange({
actionHash: actionHash,
executeAfter: block.timestamp + TIMELOCK_DELAY
});
emit FeeChangeScheduled(newFee, block.timestamp + TIMELOCK_DELAY);
}
function executeFeeChange(uint256 newFee) external onlyOwner {
bytes32 actionHash = keccak256(abi.encode("setFee", newFee));
PendingChange memory pending = pendingChanges[actionHash];
require(pending.executeAfter != 0, "Not scheduled");
require(block.timestamp >= pending.executeAfter, "Timelock active");
delete pendingChanges[actionHash];
fee = newFee;
emit FeeChanged(newFee);
}
} Implement Emergency Pause Functionality
Allow stopping operations if something goes wrong:
import "@openzeppelin/contracts/security/Pausable.sol";
contract EmergencyControls is Pausable, AccessControl {
bytes32 public constant GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE");
function pause() external onlyRole(GUARDIAN_ROLE) {
_pause();
}
function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
_unpause();
}
function deposit() external whenNotPaused {
// Can be paused in emergency
}
function withdraw() external {
// Withdrawals always work - don't trap user funds
}
} Access Control Audit Checklist
When reviewing access control, systematically verify:
For Every External/Public Function
- Is this function meant to be restricted?
- If yes, is there an access control check?
- Is the check correct (== not !=, right role, etc.)?
- Can the check be bypassed through other functions?
For Role Management
- Who can grant/revoke each role?
- Is there a super-admin who can grant all roles?
- Can roles be renounced?
- What happens if all admins renounce?
For Ownership
- Is ownership transfer two-step?
- Can ownership be renounced?
- What functions become unusable without an owner?
For Initializers
- Can initialize be called multiple times?
- Is there a race condition for who initializes?
- Are all critical variables set in initializer?
For Upgrades
- Who can trigger upgrades?
- Is there a timelock on upgrades?
- Can the upgrade mechanism itself be upgraded away?
Real-World Case Studies
Wormhole ($326M) - February 2022
A guardian signature verification function could be bypassed because the implementation contract wasn’t properly initialized. The attacker:
- Called
initialize()on the implementation contract (not the proxy) - Became the guardian set
- Signed fraudulent messages to mint tokens
Lesson: Always initialize implementation contracts, or use OpenZeppelin’s _disableInitializers().
Ronin Bridge ($625M) - March 2022
The bridge required 5 of 9 validator signatures. Sky Mavis (the operator) controlled 4 validators, and a third party controlled 1 more but had been given emergency access to Sky Mavis’s systems. When Sky Mavis was compromised, the attacker had 5 keys.
Lesson: Access control isn’t just code—it’s operational. Key management and custody matter.
Poly Network ($611M) - August 2021
A cross-chain function allowed arbitrary contract calls. The attacker:
- Called the cross-chain function with a payload targeting the role management contract
- Made themselves a “keeper” (privileged role)
- Called keeper-only functions to drain funds
Lesson: Functions that execute arbitrary calls need extreme caution. Every parameter is a potential attack vector.
Testing Access Control
Unit Tests for Every Restricted Function
function test_onlyOwnerCanSetFee() public {
// Should succeed for owner
vm.prank(owner);
protocol.setFee(100);
// Should fail for non-owner
vm.prank(attacker);
vm.expectRevert("Not authorized");
protocol.setFee(200);
} Fuzz Test Role Assignments
function testFuzz_onlyAdminCanGrantRoles(address caller, address target) public {
vm.assume(caller != admin);
vm.prank(caller);
vm.expectRevert();
protocol.grantRole(OPERATOR_ROLE, target);
} Test Initialization
function test_cannotReinitialize() public {
// First init succeeds
protocol.initialize(owner);
// Second init fails
vm.expectRevert("Already initialized");
protocol.initialize(attacker);
}
function test_implementationCannotBeInitialized() public {
// Implementation should be locked
vm.expectRevert("Already initialized");
implementation.initialize(attacker);
} Key Takeaways
- Every external function needs explicit access control consideration—even if the decision is “public”
- Use battle-tested libraries—OpenZeppelin’s AccessControl and Ownable2Step
- Two-step ownership prevents catastrophic transfers
- Timelocks give users time to exit—critical for trust
- Initialize implementations—or disable initialization entirely
- Test every restricted function—from both authorized and unauthorized callers
- Access control is operational too—key management matters
A missing onlyOwner is the difference between a secure protocol and a $611 million loss. Access control isn’t glamorous, but getting it right is non-negotiable.
Want a comprehensive access control review of your smart contracts? Contact us to discuss your security needs.
Ready to Secure Your Project?
Whether you're preparing for launch or strengthening an existing protocol, we're here to help. Get in touch for a confidential conversation about your security needs.