2026-02-17·9 min read

Access control bugs in Solidity: real hacks and property-based defense

Access control bugs in Solidity: real hacks and property-based defense

Access control is the first line of defense in any smart contract. Get it wrong and everything downstream — funds, governance, protocol state — is exposed. It's also one of the most frequently exploited categories on-chain, responsible for billions in losses. In this post, we'll look at common patterns, real exploits, and how property-based testing catches the bugs that code review often misses.

Common access control patterns

Most Solidity projects use one of three approaches:

Ownable — A single owner address with a modifier. Simple but brittle. If the owner key is compromised or the transfer function has a bug, there's no fallback.

OpenZeppelin AccessControl — Role-based with bytes32 role identifiers, admin roles that can grant/revoke, and per-function onlyRole checks. More flexible, but the role graph can get complex fast.

Custom role systems — Protocols that outgrow AccessControl often build their own. Timelock controllers, multi-sig gates, role hierarchies with delegation. These are where the subtle bugs live, because they don't inherit battle-tested code.

The pattern you pick matters less than whether you've actually verified the transitions between states. Who can grant a role? Who can revoke it? Can an admin remove themselves and leave the protocol bricked? These aren't hypothetical — they've happened.

Real exploits that started with access control

Parity multisig wallet (2017) — $150M frozen forever. The Parity library contract had an unprotected initWallet function. An attacker called it, became the owner, then called kill(). Every wallet that delegated to that library lost access to its funds permanently. The root cause: a public function that should've been internal or guarded by an initialization check.

Ronin bridge (2022) — $625M stolen. The Ronin bridge required 5 of 9 validator signatures. But Axie Infinity's rapid growth led to a temporary arrangement where Sky Mavis controlled 4 validators plus a third-party one that hadn't been revoked. An attacker compromised Sky Mavis's keys and had enough signatures. The access control logic was fine — the role distribution wasn't. This is exactly the kind of state that's hard to catch in code review but trivial to express as a property.

A vulnerable pattern: the missing modifier

Here's a pattern we see regularly in audits. A protocol adds a new admin function and forgets to apply the access modifier:

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

contract VulnerableVault {
    address public admin;
    mapping(address => bool) public whitelisted;

    modifier onlyAdmin() {
        require(msg.sender == admin, "Not admin");
        _;
    }

    constructor() {
        admin = msg.sender;
    }

    // This function is missing the onlyAdmin modifier
    function setAdmin(address newAdmin) external {
        admin = newAdmin;
    }

    function addToWhitelist(address user) external onlyAdmin {
        whitelisted[user] = true;
    }
}

Anyone can call setAdmin and take over the contract. It's obvious in isolation, but in a 3,000-line contract with dozens of functions, these slip through. Especially during late-stage refactors when modifiers get accidentally removed.

<a href="/request-audit" class="cta-button">Get your access control reviewed</a>

Catching role transition bugs with properties

Static analysis and manual review can catch missing modifiers, but they struggle with stateful bugs — scenarios where a sequence of valid calls leads to an invalid state. That's where property-based testing shines.

Here's a Chimera property that verifies admin continuity: the protocol should never end up without an admin.

// Property: admin role can't be removed without a replacement
function property_admin_always_exists() public view returns (bool) {
    // After any sequence of calls, there must always be a valid admin
    return vault.admin() != address(0);
}

This looks simple, but a fuzzer will try thousands of call sequences — setAdmin(address(0)), renouncing ownership, self-destructing linked contracts — and flag any path that violates it. We've caught real bugs with exactly this kind of property, where a two-step transfer pattern let the admin be set to address(0) if the pending admin never accepted.

Detecting privilege escalation

The second class of bugs is privilege escalation: a non-privileged user gaining a role they shouldn't have. Here's a property for that:

// Property: only current admin can change roles
function property_no_privilege_escalation() public returns (bool) {
    address attacker = address(0xBAD);

    // Record admin before attacker acts
    address adminBefore = vault.admin();

    // Simulate attacker calling setAdmin
    vm.prank(attacker);
    try vault.setAdmin(attacker) {
        // If the call succeeded and attacker wasn't admin, that's a violation
        return attacker == adminBefore;
    } catch {
        // Call reverted — access control held
        return true;
    }
}

When the fuzzer runs this against the vulnerable contract above, it immediately flags the violation: attacker isn't the admin, but setAdmin didn't revert. The property turns an implicit assumption ("only admin can change admin") into an explicit, testable invariant.

Why properties catch what reviews miss

Code review is great at spotting known patterns. But access control bugs often emerge from interactions between components — a delegatecall to a user-supplied address, a callback that re-enters with elevated permissions, a migration script that temporarily grants a role and doesn't revoke it.

Properties don't care about the path. They define the end state that must hold and let the fuzzer find any sequence that breaks it. We've seen this catch:

  • Unrevoked temporary roles after governance proposals
  • Delegatecall to untrusted contracts that overwrite storage slots holding role mappings
  • Re-entrancy during role transfer that lets an attacker hold two roles simultaneously
  • Timelock bypass through specific call ordering

Practical recommendations

  1. Start with the role graph. Before writing code, map every role, who can grant it, who can revoke it, and what happens if it's empty. Then write properties for each transition.

  2. Don't rely on modifiers alone. Modifiers are necessary but not sufficient. Write properties that verify the effect of access control, not just its presence.

  3. Test role removal paths. The most dangerous bugs aren't unauthorized access — they're authorized actions that brick the protocol. Renouncing the last admin, revoking your own timelock access, removing all guardians from a multisig.

  4. Use two-step transfers for critical roles. transferOwnership + acceptOwnership prevents transferring admin to a dead address. Then write a property that the pending owner can't be address(0) after a transfer is initiated.

  5. Fuzz the upgrade path. If your contract is upgradeable, the proxy admin is the most critical role. Write properties that verify the upgrade admin can't be changed by non-admins and can't be set to zero.

If you're new to writing these kinds of properties, start with our tutorial on how to write your first invariant test. It covers the setup, tooling, and mental model you need to go from zero to running properties against your own contracts.

Access control isn't glamorous, but it's where the money is — both for attackers and for the teams that get it right.

Related Posts

Related Glossary Terms

Get your access control reviewed by experts