Access Control Exploits

Difficulty: Advanced

Access control vulnerabilities occur when smart contract functions lack proper authorization checks, allowing unauthorized users to execute privileged operations. These are among the most common and devastating vulnerabilities because they often provide direct access to fund management, ownership, or protocol configuration. Unlike reentrancy which requires a specific execution flow, access control bugs are often simple missing checks that anyone can exploit by calling the unprotected function.

The most fundamental mistake is the tx.origin vs msg.sender confusion. tx.origin is the EOA that originally initiated the transaction, while msg.sender is the immediate caller. When a user interacts with a malicious contract that then calls your contract, tx.origin is the user but msg.sender is the malicious contract. If your contract uses tx.origin for authentication, an attacker can trick the owner into interacting with a malicious contract, which then calls your admin functions - passing the tx.origin check because the owner initiated the transaction. Always use msg.sender.

Default visibility has been a significant source of vulnerabilities. In Solidity pre-0.5, functions without explicit visibility defaulted to public, meaning anyone could call them. The Parity multisig wallet hack exploited this: the initWallet function lacked a visibility modifier and had no ownership check, allowing anyone to claim ownership. Modern Solidity requires explicit visibility, but developers must still be careful about making sensitive functions public or external when they should be internal or private.

Delegatecall is powerful but dangerous. When contract A delegatecalls contract B, B's code executes in A's storage context - B can read/write A's storage, use A's msg.sender and msg.value, and even self-destruct A. The Parity wallet library destruction ($280M frozen permanently) occurred because the library contract had an unprotected initWallet function. An attacker called initWallet directly on the library contract itself, became the owner, and self-destructed it - permanently breaking all wallets that delegatecalled to it.

Modern access control uses OpenZeppelin's Ownable (simple) or AccessControl (role-based). AccessControl provides flexible RBAC where roles are bytes32 hashes, each role has an admin role, and DEFAULT_ADMIN_ROLE governs all others. For high-value DeFi protocols, access control should be paired with timelocks and multisig wallets. The principle of progressive decentralization suggests starting with a multisig, then transitioning to governance as the protocol matures.

Code examples

tx.origin Phishing Attack

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

// ======== VULNERABLE ========
contract VulnerableWallet {
    address public owner;

    constructor() { owner = msg.sender; }

    function transferTo(address payable to, uint256 amount) external {
        require(tx.origin == owner, "Not owner"); // WRONG!
        to.transfer(amount);
    }

    receive() external payable {}
}

// ======== ATTACKER ========
contract TxOriginAttacker {
    VulnerableWallet public target;
    address public attacker;

    constructor(address _target) {
        target = VulnerableWallet(payable(_target));
        attacker = msg.sender;
    }

    // Disguised as airdrop claim, NFT mint, etc.
    function claimFreeTokens() external {
        // When owner calls this: tx.origin = owner -> check passes!
        target.transferTo(payable(attacker), address(target).balance);
    }
}

// ======== SECURE ========
contract SecureWallet {
    address public owner;
    constructor() { owner = msg.sender; }

    function transferTo(address payable to, uint256 amount) external {
        require(msg.sender == owner, "Not owner"); // CORRECT!
        to.transfer(amount);
    }

    receive() external payable {}
}

The attacker deploys a contract that calls the victim's wallet. When the wallet owner interacts with the attacker's contract, tx.origin is the owner so the check passes. The fix is using msg.sender.

Unprotected Initializer (Proxy Risk)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

// ======== VULNERABLE ========
contract VulnerableImpl {
    address public owner;
    bool public initialized;

    // BUG: Can be called on the implementation contract itself
    function initialize(address _owner) external {
        require(!initialized, "Already initialized");
        initialized = true;
        owner = _owner;
    }

    function withdrawAll() external {
        require(msg.sender == owner, "Not owner");
        (bool success, ) = msg.sender.call{value: address(this).balance}("");
        require(success);
    }

    receive() external payable {}
}

// ======== SECURE ========
contract SecureImpl is Initializable, OwnableUpgradeable {
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers(); // Prevents init on implementation
    }

    function initialize(address _owner) public initializer {
        __Ownable_init(_owner);
    }

    function withdrawAll() external onlyOwner {
        (bool success, ) = msg.sender.call{value: address(this).balance}("");
        require(success);
    }

    receive() external payable {}
}

In proxy patterns, the implementation contract must never be initializable directly. _disableInitializers() in the constructor ensures only the proxy's storage can be initialized. Without this, an attacker could initialize the implementation and exploit it.

Role-Based Access Control

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/Pausable.sol";

contract SecureToken is ERC20, AccessControl, Pausable {
    bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    uint256 public constant MAX_SUPPLY = 1_000_000e18;
    uint256 public constant MAX_MINT_AMOUNT = 10_000e18;
    uint256 public constant MINT_COOLDOWN = 1 hours;

    mapping(address => uint256) public lastMintTime;

    constructor() ERC20("SecureToken", "STK") {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(MINTER_ROLE, msg.sender);
        _grantRole(PAUSER_ROLE, msg.sender);
    }

    function mint(
        address to,
        uint256 amount
    ) external onlyRole(MINTER_ROLE) whenNotPaused {
        require(amount <= MAX_MINT_AMOUNT, "Exceeds max mint");
        require(
            block.timestamp >= lastMintTime[msg.sender] + MINT_COOLDOWN,
            "Cooldown active"
        );
        require(totalSupply() + amount <= MAX_SUPPLY, "Exceeds max supply");

        lastMintTime[msg.sender] = block.timestamp;
        _mint(to, amount);
    }

    function pause() external onlyRole(PAUSER_ROLE) { _pause(); }
    function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); }

    function _update(
        address from,
        address to,
        uint256 value
    ) internal override whenNotPaused {
        super._update(from, to, value);
    }
}

OpenZeppelin's AccessControl provides fine-grained RBAC. Each sensitive operation requires a specific role. Additional safeguards include mint cooldowns, maximum amounts, and supply caps. DEFAULT_ADMIN_ROLE can grant/revoke other roles.

Key points

Concepts covered

Access Control, tx.origin, msg.sender, Default Visibility, Delegatecall, Proxy Patterns