Command Palette

Search for a command to run...

Access Control — Missing Authorization

A Treasury contract holds DAO funds with privileged withdraw and admin functions. But the functions are missing their access gate: anyone can call them. The Primer teaches the three canonical access-control patterns — Ownable, AccessControl, Initializable — and how auditors spot this class.

~7 min read

Available

Solidity has no built-in identity or role system. Every time your contract executes, the EVM fills in msg.sender: whoever triggered this transaction frame. But knowing who called you is up to you. No other language does this: it is on you to check.

1. Visibility vs authorization

A function's visibility — public, external, internal, private — controls whether other accounts can call it. public and external are callable from outside; internal and private are callable only within the contract (or, for internal, by inheritors). But visibility does not authenticate. A public function is accessible to anyone, and the contract author must decide whether to enforce a privilege check inside it.

There is one authentication primitive: msg.sender. It is the direct caller of this frame. Never use tx.origin for authentication — that is the wallet that initiated the transaction, but if your code is running inside a call chain (e.g., you are being called by a proxy or router), tx.origin is many frames away. An attacker can trick you into thinking tx.origin is trustworthy when it is not. msg.sender is always the immediate caller.

The simplest authorization check is require()(msg.sender == owner). It is a one-liner, gas-cheap, and bulletproof. If the contract has only one owner, this is sufficient.

2. Three canonical patterns

Ownable is the simplest: the contract tracks a single owner address in storage. A modifier onlyOwner (from OpenZeppelin) adds a require()(msg.sender == owner) check to any function that uses it. On deployment, the owner is set to the deployer. Ownership can be transferred via transferOwnership()(newOwner). This pattern is appropriate for single-admin systems.

AccessControl is role-based: the contract manages a mapping of address → set of roles. A function decorated with onlyRole(ROLE) checks whether msg.sender has that role. Roles are identified by bytes32 constants (e.g., DEFAULT_ADMIN_ROLE, MINTER_ROLE, BURNER_ROLE). Multiple admins can grantRole() or revokeRole(). This pattern is appropriate for multi-tenant systems or when different functions require different permissions.

Initializable is a pattern for proxy-based contracts: the logic contract is deployed once, but users interact with a proxy that delegates all calls to it. To prevent frontrunning the initialization, the logic contract uses an initializer modifier on its initialize() function and calls _disableInitializers()() in the constructor. The proxy then calls initialize() atomically, after which it cannot be re-initialized (and any direct calls to the logic contract are blocked).

3. The attack — no special tooling required

The Treasury contract from the Hunt has three external functions: withdraw()(to, amount), setFee()(newFeeBps), and transferOwnership()(newOwner). None of them checks whether the caller is the owner. They are unprotected.

// Attacker calls these directly
treasury.withdraw(attacker, address(treasury).balance)
treasury.transferOwnership(attacker)

An attacker simply calls withdraw() or transferOwnership() from their own address. The function executes; there is no revert. Within one transaction, the attacker drains the funds and takes ownership. There is no recovery: the state change is atomic.

Attack timeline

  1. 1

    Reconnaissance

    Attacker identifies the Treasury contract address on-chain (from logs, governance announcements, or direct inspection of relevant DAO contracts). Notes the functions: withdraw, setFee, transferOwnership.

  2. 2

    Function identification

    Attacker reads the contract source (verified on Etherscan). Sees no owner check in withdraw() or transferOwnership(). Realizes these are callable by anyone.

  3. 3

    Direct call

    Attacker sends a transaction calling treasury.withdraw(attacker, treasury.balance). The function executes without revert; msg.sender is attacker, and there is no require() to block it.

  4. 4

    Balance transfer

    Treasury sends its entire balance to attacker's address. Attacker's account balance increases. Treasury balance is now zero.

  5. 5

    Unwind

    Attacker optionally calls transferOwnership(attacker) to claim admin rights (already drained the funds, but ownership is a trophy). DAO treasury is empty; DAO is defunct.

4. The fix — add access control

Add OpenZeppelin's Ownable to the Treasury. Inherit from it, and decorate the three privileged functions with the onlyOwner modifier. On deployment, Ownable automatically sets owner to msg.sender (the deployer). All three functions are now gated.

pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Treasury is Ownable {
    IERC20 public immutable token;
    uint256 public feeBps;

    // ... constructor, deposit, balance ...

    function withdraw(address to, uint256 amount) external onlyOwner {
        (bool ok, ) = to.call{value: amount}("");
        require(ok, "transfer failed");
    }

    function setFee(uint256 newFeeBps) external onlyOwner {
        require(newFeeBps <= 10000, "bps out of range");
        feeBps = newFeeBps;
    }

    function transferOwnership(address newOwner) external onlyOwner {
        _transferOwnership(newOwner);
    }
}

The onlyOwner modifier is a one-line change per function. For contracts using proxy patterns, use the initializer modifier instead of a constructor to protect initialize() from being called twice by an attacker.

Do not roll your own access control. Use OpenZeppelin's Ownable, AccessControl, or Initializable depending on your architecture. These patterns are battle-tested and widely-trusted by auditors.

5. What to look for as an auditor

  • Grep every external and public function. For each one, ask: is there an authorization modifier or inline check? If not, assume it is a vulnerability.
  • Functions that manipulate state (especially those that transfer value) without a check are immediate red flags. Priority: setOwner, setAdmin, withdraw, transfer, mint, burn, pause.
  • Search for tx.origin in auth checks. It is almost always wrong. Replace with msg.sender.
  • Proxy patterns: check that initialize() has the initializer modifier and that the logic contract calls _disableInitializers()() in its constructor. Unprotected initialize() = takeover.
  • If using AccessControl, audit the role setup: who has DEFAULT_ADMIN_ROLE? Can an attacker manipulate role grants if they can call a public function that does not check permissions before grantRole()?
  • Follow the call graph: a private function that calls delegatecall or does risky state changes is still a vulnerability if a public function without checks calls it.

With the three patterns in hand, open the Hunt tab. Read the Treasury, identify privileged functions missing their gate, then check your hypothesis against Reveal.

← Back to skill tree