BeginnerHigh Severity

Access Control Vulnerabilities

7 min readUpdated Dec 10, 2024By Hexific Team

What is Access Control?

Access control determines who can call specific functions in your smart contract. Without proper access control, anyone can execute sensitive operations like withdrawing funds, minting tokens, or changing critical parameters.

🚨

Impact

Access control vulnerabilities are among the most exploited bugs in smart contracts. They often lead to complete loss of funds or total protocol takeover.

Common Vulnerabilities

🔓 Missing Access Modifiers

Forgetting to add onlyOwner or similar modifiers to sensitive functions.

🔓 Unprotected Initializers

In upgradeable contracts, anyone can call initialize() if not protected.

🔓 Incorrect msg.sender Checks

Using tx.origin instead of msg.sender, or wrong comparison logic.

🔓 Default Visibility

Functions without explicit visibility default to public in older Solidity versions.

Missing Access Modifiers

The most common mistake is simply forgetting to add access control to admin functions:

Vulnerable - No Access Control
contract VulnerableToken {
    address public owner;
    mapping(address => uint256) public balances;
    
    // ❌ Anyone can call this!
    function mint(address to, uint256 amount) external {
        balances[to] += amount;
    }
    
    // ❌ Anyone can withdraw all funds!
    function withdrawAll() external {
        payable(msg.sender).transfer(address(this).balance);
    }
    
    // ❌ Anyone can become owner!
    function setOwner(address newOwner) external {
        owner = newOwner;
    }
}
Secure - With Access Control
contract SecureToken {
    address public owner;
    mapping(address => uint256) public balances;
    
    modifier onlyOwner() {
        require(msg.sender == owner, "Not owner");
        _;
    }
    
    // ✅ Only owner can mint
    function mint(address to, uint256 amount) external onlyOwner {
        balances[to] += amount;
    }
    
    // ✅ Only owner can withdraw
    function withdrawAll() external onlyOwner {
        payable(owner).transfer(address(this).balance);
    }
    
    // ✅ Only owner can transfer ownership
    function setOwner(address newOwner) external onlyOwner {
        require(newOwner != address(0), "Zero address");
        owner = newOwner;
    }
}

Improper Role Validation

Even when access control exists, it can be implemented incorrectly:

ValidationExamples.sol
1// ❌ VULNERABLE: Using == instead of require
2contract BadValidation {
3    address public admin;
4    
5    function sensitiveAction() external {
6        // This doesn't revert! It just does nothing if not admin
7        if (msg.sender == admin) {
8            // do something
9        }
10        // Execution continues even for non-admins!
11    }
12}
13
14// ❌ VULNERABLE: Using tx.origin
15contract TxOriginVulnerable {
16    address public owner;
17    
18    function withdraw() external {
19        // Vulnerable to phishing! See tx.origin article
20        require(tx.origin == owner, "Not owner");
21        payable(tx.origin).transfer(address(this).balance);
22    }
23}
24
25// ❌ VULNERABLE: Uninitialized owner
26contract UninitializedOwner {
27    address public owner; // Defaults to address(0)
28    
29    modifier onlyOwner() {
30        require(msg.sender == owner, "Not owner");
31        _;
32    }
33    
34    // If owner is never set, no one can call this
35    // Or worse, owner might match a burnt address
36    function mint(address to, uint256 amount) external onlyOwner {
37        // ...
38    }
39}
40
41// ✅ SECURE: Proper initialization
42contract ProperInit {
43    address public owner;
44    
45    constructor() {
46        owner = msg.sender; // Set in constructor
47    }
48    
49    // Or use initializer pattern for proxies
50}

Best Practices

✅ Use OpenZeppelin

Battle-tested access control contracts with Ownable, AccessControl, and more.

✅ Two-Step Ownership

Require new owner to accept transfer, preventing accidental lockouts.

✅ Role-Based Access

Use granular roles instead of a single owner for better security.

✅ Explicit Visibility

Always declare function visibility explicitly (public, external, internal, private).

TwoStepOwnable.sol
1// Two-step ownership transfer pattern
2contract TwoStepOwnable {
3    address public owner;
4    address public pendingOwner;
5    
6    event OwnershipTransferStarted(address indexed previousOwner, address indexed newOwner);
7    event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
8    
9    modifier onlyOwner() {
10        require(msg.sender == owner, "Not owner");
11        _;
12    }
13    
14    constructor() {
15        owner = msg.sender;
16    }
17    
18    // Step 1: Current owner initiates transfer
19    function transferOwnership(address newOwner) external onlyOwner {
20        require(newOwner != address(0), "Zero address");
21        pendingOwner = newOwner;
22        emit OwnershipTransferStarted(owner, newOwner);
23    }
24    
25    // Step 2: New owner must accept
26    function acceptOwnership() external {
27        require(msg.sender == pendingOwner, "Not pending owner");
28        emit OwnershipTransferred(owner, pendingOwner);
29        owner = pendingOwner;
30        pendingOwner = address(0);
31    }
32    
33    // Emergency: Cancel pending transfer
34    function cancelTransfer() external onlyOwner {
35        pendingOwner = address(0);
36    }
37}

Using OpenZeppelin AccessControl

For complex protocols, use role-based access control instead of simple ownership:

AccessControlExample.sol
1import "@openzeppelin/contracts/access/AccessControl.sol";
2
3contract SecureProtocol is AccessControl {
4    // Define roles as constants
5    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
6    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
7    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
8    
9    constructor() {
10        // Grant deployer the default admin role
11        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
12        _grantRole(ADMIN_ROLE, msg.sender);
13    }
14    
15    // Only addresses with MINTER_ROLE can call
16    function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
17        _mint(to, amount);
18    }
19    
20    // Only addresses with PAUSER_ROLE can call
21    function pause() external onlyRole(PAUSER_ROLE) {
22        _pause();
23    }
24    
25    // Only addresses with ADMIN_ROLE can call
26    function setFee(uint256 newFee) external onlyRole(ADMIN_ROLE) {
27        fee = newFee;
28    }
29    
30    // Role hierarchy example:
31    // - DEFAULT_ADMIN_ROLE can grant/revoke all roles
32    // - ADMIN_ROLE can manage protocol settings
33    // - MINTER_ROLE can only mint tokens
34    // - PAUSER_ROLE can only pause/unpause
35}
💡

Pro Tip

Use AccessControlEnumerable if you need to enumerate role members, or AccessControlDefaultAdminRules for additional safety on admin role transfers.