Access control failures remain the single largest category of smart contract losses. Access control failures caused an estimated $953 million in smart contract losses in 2024 alone, representing 67% of all losses that year. Yet when most people hear “access control vulnerability,” they picture the trivially missing onlyOwner modifier on a mint function. That class of bug gets caught by automated tools, junior auditors, and even Slither on a first pass.
The vulnerabilities discussed in this article are harder. They require understanding initialization order, storage layout, ABI encoding edge cases, and the semantics of role inheritance. They are the reason a protocol can pass a surface-level audit and still be exploited a week after launch. Each section below pairs a vulnerable pattern with a corrected one and explains the exact mechanism that bridges the two.
1. Missing Modifiers (The Baseline)
Before diving into the subtle cases, let us establish the baseline so the contrast is clear.
Access control is the #1 vulnerability category, with losses from functions that should be restricted but are left publicly callable. A canonical example is an unguarded price setter: function setPrice(uint256 newPrice) external { price = newPrice; } — an attacker calling it can manipulate oracle values, drain lending pools, or distort DEX pricing.
Vulnerable pattern:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableVault {
address public owner;
uint256 public withdrawalLimit;
constructor() {
owner = msg.sender;
withdrawalLimit = 1 ether;
}
// ❌ No access control — anyone can call this
function setWithdrawalLimit(uint256 newLimit) external {
withdrawalLimit = newLimit;
}
// ❌ tx.origin check is bypassable through a malicious intermediary contract
function adminAction() external {
require(tx.origin == owner, "not owner");
// sensitive logic
}
}
Corrected pattern:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
contract SecureVault is Ownable {
uint256 public withdrawalLimit;
constructor() Ownable(msg.sender) {
withdrawalLimit = 1 ether;
}
// ✅ msg.sender check, not tx.origin
function setWithdrawalLimit(uint256 newLimit) external onlyOwner {
withdrawalLimit = newLimit;
}
}
tx.origin is especially dangerous because it always refers to the original EOA that started the call chain. A malicious contract can relay a call from a legitimate user and satisfy a tx.origin == owner check even though msg.sender is the attacker contract. Never use tx.origin for authorization.
2. Initializer Front-Running
The upgrade proxy pattern removes constructors from the equation. Due to a requirement of the proxy-based upgradeability system, no constructors can be used in upgradeable contracts. This means you need to change the constructor into a regular function, typically named initialize, where you run all the setup logic.
The critical difference: constructors run exactly once, atomically, at deployment. While Solidity ensures that a constructor is called only once in the lifetime of a contract, a regular function can be called many times. This creates a window for front-running.
Front-running is a technique where an attacker gets ahead of a particular transaction and exploits the time delay between the submission of a public transaction and its inclusion in a block. In the context of initializers, an attacker can front-run the deployment transaction and interfere with the initialization process. If an initializer contains functionalities like setting variables or contract ownership, a front-running attacker can manipulate these values by submitting their own transaction with updated values before the original deployment transaction is processed. This manipulation can lead to scenarios where an attacker gains unauthorized control over the contract.
There is also an independent but related attack surface: the uninitialized implementation contract. Any proxy using UUPS library versions whose implementation contracts had not been “initialized” were at risk. Anyone could execute the initialization function, become the owner, and execute a delegatecall to a contract with a selfdestruct opcode. This action would erase the implementation contract’s code, preventing the proxy from migrating to a new implementation. Essentially, this vulnerability could potentially lock millions of dollars in assets within the proxy indefinitely.
Vulnerable pattern — proxy-only initialization, no implementation protection:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
// ❌ Implementation contract has no constructor protection.
// Anyone can call initialize() on the bare implementation address
// before or after the proxy is deployed, gaining owner status
// and the ability to delegate-call arbitrary code.
contract VulnerableToken is Initializable {
address public owner;
uint256 public totalSupply;
function initialize(address _owner, uint256 _supply) external initializer {
owner = _owner;
totalSupply = _supply;
}
// onlyOwner gate — but attacker IS the owner on the bare impl
function upgradeTo(address newImpl) external {
require(msg.sender == owner, "not owner");
// delegatecall to newImpl ...
}
}
Attack scenario:
- Deployer broadcasts
deploy(VulnerableToken)— tx enters mempool. - Attacker sees the pending tx, broadcasts
VulnerableToken.initialize(attacker, 0)with higher gas. - Attacker tx mines first. Attacker is now
ownerof the implementation. - Attacker calls
upgradeTo(maliciousContract)wheremaliciousContractcontains aselfdestruct. - Proxy is bricked.
Corrected pattern — _disableInitializers in the constructor:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
/// @custom:oz-upgrades-unsafe-allow constructor
contract SecureToken is Initializable, OwnableUpgradeable {
uint256 public totalSupply;
// ✅ Constructor runs once at impl deployment, permanently
// locks the initializer on the implementation contract itself.
constructor() {
_disableInitializers();
}
// ✅ Only callable on the proxy, not on the implementation
function initialize(address _owner, uint256 _supply) external initializer {
__Ownable_init(_owner);
totalSupply = _supply;
}
}
How _disableInitializers Works
The most recent and recommended approach proposed by OpenZeppelin to “initialize” implementation contracts is using the _disableInitializers() function. It sets $._initialized to type(uint64).max, preventing reinitializations. Setting the version to type(uint64).max in the implementation ensures the implementation contract will never be initialized.
The constructor annotation /// @custom:oz-upgrades-unsafe-allow constructor suppresses the OpenZeppelin upgrades plugin validator warning about constructors in upgradeable contracts; it is a documentation signal, not a security bypass.
Deployment Atomicity
Even with _disableInitializers protecting the implementation, the proxy’s initialize function is still susceptible to front-running if deployment and initialization are non-atomic. Deploy and initialize proxy contracts in a single transaction using the data parameter of ERC1967Proxy: new ERC1967Proxy(address(impl), abi.encodeCall(impl.initialize, (owner, supply))). The ERC1967Proxy contract constructor makes a call to the implementation at deploy time. The initialization call must be made at this moment, encoded in the _data variable.
3. Role Inheritance and DEFAULT_ADMIN_ROLE Pitfalls
OpenZeppelin’s AccessControl is a powerful replacement for flat Ownable patterns, but its role hierarchy has sharp edges that trip up even experienced teams.
The DEFAULT_ADMIN_ROLE Superpower
AccessControl includes a special role called DEFAULT_ADMIN_ROLE, which acts as the default admin role for all roles. An account with this role will be able to manage any other role, unless _setRoleAdmin is used to select a new admin role. Since it is the admin for all roles by default, and in fact it is also its own admin, this role carries significant risk.
By default, the admin role for all roles is DEFAULT_ADMIN_ROLE, which means that only accounts with this role will be able to grant or revoke other roles. This is the silent assumption that breaks systems: a developer creates MINTER_ROLE and PAUSER_ROLE, correctly restricts functions, but never realizes that any DEFAULT_ADMIN_ROLE holder can grant themselves MINTER_ROLE without any additional check.
Vulnerable pattern — DEFAULT_ADMIN_ROLE over-granted:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
contract VulnerableDAO is AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant TREASURY_ROLE = keccak256("TREASURY_ROLE");
constructor(address admin, address minter, address treasury) {
// ❌ Granting DEFAULT_ADMIN_ROLE to a hot wallet or multisig
// with low threshold means that account can silently self-assign
// TREASURY_ROLE and drain funds.
_grantRole(DEFAULT_ADMIN_ROLE, admin);
_grantRole(MINTER_ROLE, minter);
_grantRole(TREASURY_ROLE, treasury);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
// mint tokens
}
function withdrawFunds(address to, uint256 amount) external onlyRole(TREASURY_ROLE) {
// transfer funds
}
}
An attacker who compromises the admin EOA calls grantRole(TREASURY_ROLE, attacker). No timelock, no signal, no veto window.
Vulnerable pattern — self-administered role:
// ❌ MINTER_ROLE is its own admin.
// Any minter can grant the MINTER_ROLE to arbitrary addresses.
_setRoleAdmin(MINTER_ROLE, MINTER_ROLE);
A role’s admin can even be the same role itself, which would cause accounts with that role to be able to also grant and revoke it. This is occasionally useful for peer-nomination schemes, but it means a single compromised minter can horizontally escalate to a full minter cartel.
Corrected pattern — separated admin hierarchy with AccessControlDefaultAdminRules:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/extensions/AccessControlDefaultAdminRules.sol";
contract SecureDAO is AccessControlDefaultAdminRules {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant TREASURY_ROLE = keccak256("TREASURY_ROLE");
// Dedicated admin roles — DEFAULT_ADMIN_ROLE does NOT manage these
bytes32 public constant MINTER_ADMIN_ROLE = keccak256("MINTER_ADMIN_ROLE");
bytes32 public constant TREASURY_ADMIN_ROLE = keccak256("TREASURY_ADMIN_ROLE");
constructor(
address initialAdmin,
address minterAdmin,
address treasuryAdmin,
uint48 adminTransferDelay
)
// ✅ Enforces single DEFAULT_ADMIN_ROLE holder +
// 2-step transfer with a configurable time delay
AccessControlDefaultAdminRules(adminTransferDelay, initialAdmin)
{
// ✅ Roles are governed by their own dedicated admin roles,
// not DEFAULT_ADMIN_ROLE, limiting blast radius.
_setRoleAdmin(MINTER_ROLE, MINTER_ADMIN_ROLE);
_setRoleAdmin(TREASURY_ROLE, TREASURY_ADMIN_ROLE);
_grantRole(MINTER_ADMIN_ROLE, minterAdmin);
_grantRole(TREASURY_ADMIN_ROLE, treasuryAdmin);
}
function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
// mint tokens
}
function withdrawFunds(address to, uint256 amount) external onlyRole(TREASURY_ROLE) {
// transfer funds
}
}
AccessControlDefaultAdminRules implements the following risk mitigations: only one account holds the DEFAULT_ADMIN_ROLE since deployment until it is potentially renounced; it enforces a 2-step process to transfer the DEFAULT_ADMIN_ROLE to another account; and it enforces a configurable delay between the two steps, with the ability to cancel before the transfer is accepted.
The Forgotten _setRoleAdmin Call
A subtler variant: the developer sets up a perfect hierarchy diagram in the specification but forgets to call _setRoleAdmin. Since every role’s admin defaults to DEFAULT_ADMIN_ROLE, the intended separation of MINTER_ADMIN_ROLE → MINTER_ROLE never takes effect. During an audit, always verify that _setRoleAdmin is called for every custom role, and verify on-chain after deployment with getRoleAdmin(MINTER_ROLE).
4. Function Selector Clashes in Proxies
A function selector is the first 4 bytes of the keccak256 of the function signature. The EVM uses it to dispatch calls. In proxy architectures, the proxy contract and the implementation contract coexist in a shared call path, and the dispatcher must decide whether to execute proxy logic or delegate to the implementation.
When function selectors clash — i.e., functions with the same selector exist in both the proxy and implementation contract — when a user calls a function, the proxy does not know whether to call its own function or perform a delegatecall.
This is known as “proxy selector clashing” and is a security vulnerability that can be exploited, or at the very least be a source of annoying bugs. Technically, this clash can also happen between functions even if they have different names.
The probability of two different functions having the same selector is 1 in 4.29 billion; a function selector consists of 4 bytes. That is a small probability, but not negligible. For example, clash550254402() has the same function selector as proxyAdmin().
How Each Proxy Pattern Handles Clashes
Transparent Proxy: The transparent proxy separates upgrade control from usage: only the ProxyAdmin contract can call upgrade functions; all other callers interact with the implementation via delegatecall. The proxy intercepts calls from the admin address and routes them away from the implementation, which prevents selector-shadowing attacks where an implementation function shares a 4-byte selector with an admin function.
UUPS: Universal upgradeable proxies reduce the likelihood of a function selector clash. While the Solidity compiler cannot detect clashing functions across two contracts, it can reject them when they occur within the same contract. In UUPS, the upgrade function lives in the implementation, so the Solidity compiler itself enforces uniqueness within one compilation unit.
Vulnerable pattern — plain proxy with selector ambiguity:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ❌ SimpleProxy has both a public `upgrade` function AND delegates
// to an implementation that also defines upgrade(address).
// The proxy cannot distinguish which is intended.
contract SimpleProxy {
address public implementation;
address public admin;
constructor(address _impl) {
implementation = _impl;
admin = msg.sender;
}
// Selector: bytes4(keccak256("upgrade(address)")) = 0x0900f010
function upgrade(address newImpl) external {
require(msg.sender == admin, "not admin");
implementation = newImpl;
}
fallback() external payable {
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch result
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
// ❌ Implementation also has upgrade(address) — SAME selector 0x0900f010
contract VulnerableImplementation {
address public owner;
// Selector collision with proxy's upgrade()!
// A non-admin user calling this reaches the fallback,
// which delegates to THIS function — bypassing proxy admin check.
function upgrade(address newOwner) external {
owner = newOwner; // silently reassigns owner instead of upgrading
}
}
Corrected pattern — UUPS with OpenZeppelin:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
/// @custom:oz-upgrades-unsafe-allow constructor
contract SecureImplementation is Initializable, OwnableUpgradeable, UUPSUpgradeable {
constructor() { _disableInitializers(); }
function initialize(address owner) external initializer {
__Ownable_init(owner);
__UUPSUpgradeable_init();
}
// ✅ Compiler enforces no selector clashes within this contract.
// Upgrade logic lives here, not in the proxy.
function _authorizeUpgrade(address newImplementation)
internal
override
onlyOwner
{}
}
Auditing for Selector Clashes
One tool is solc, where solc --hashes MyContract.sol will list all function selectors. Slither has a Function ID printer that can do the same thing. Slither also has a slither-check-upgradeability tool that can detect function clashing.
Run both during every proxy audit. Do not rely solely on the proxy pattern’s architectural guarantees — a future implementation upgrade might introduce a colliding function.
5. Single-Step vs Two-Step Ownership Transfer
The standard Ownable contract facilitates immediate ownership transfer with a single function call. However, this can pose risks, such as unintended ownership transfers due to human error or malicious attacks that exploit the single-step transfer to seize control.
A single typo in the newOwner address argument causes permanent, irreversible loss of contract control. In Ownable, ownership is transferred immediately when the transferOwnership function is called. If the new owner address is mistyped or invalid, the contract ownership could be permanently lost.
Vulnerable pattern — single-step:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
// ❌ One call transfers ownership with no confirmation step.
// A typo, a clipboard hijack, or a phishing attack on the admin
// results in an unrecoverable ownership loss.
contract VulnerableProtocol is Ownable {
constructor() Ownable(msg.sender) {}
function setFee(uint256 fee) external onlyOwner {
// ...
}
// Inherited transferOwnership(address) — immediate, irreversible
}
Corrected pattern — Ownable2Step:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable2Step.sol";
// ✅ Two-step process: current owner proposes, new owner must accept.
contract SecureProtocol is Ownable2Step {
constructor() Ownable(msg.sender) {}
function setFee(uint256 fee) external onlyOwner {
// ...
}
// Inherited workflow:
// Step 1: currentOwner calls transferOwnership(newOwnerAddress)
// → stores newOwnerAddress as pendingOwner
// Step 2: newOwnerAddress calls acceptOwnership()
// → completes transfer only if caller == pendingOwner
}
This extension of the Ownable contract includes a two-step mechanism to transfer ownership, where the new owner must call acceptOwnership in order to replace the old one. This can help prevent common mistakes, such as transfers of ownership to incorrect accounts, or to contracts that are unable to interact with the permission system.
Rather than directly transferring to the new owner, the transfer only completes when the new owner accepts ownership. Ownable2Step was released in January 2023 during the OpenZeppelin version 4.8 update.
The DEFAULT_ADMIN_ROLE Transfer Analog
The same single-step risk exists with AccessControl. The default admin in AccessControl is a very sensitive role and it requires special treatment. The AccessControlDefaultAdminRules extension enforces a 2-step process to transfer the DEFAULT_ADMIN_ROLE to another account, and enforces a configurable delay between the two steps, with the ability to cancel before the transfer is accepted.
For upgradeable contracts, use Ownable2StepUpgradeable. For new deployments mixing Ownable and AccessControl, favor AccessControlDefaultAdminRules over raw AccessControl. Always check that renounceOwnership is either removed or protected behind a multi-sig — a careless renounceOwnership() call is functionally equivalent to a misconfigured transferOwnership(address(0)).
6. Cross-Contract Privilege Escalation
This is the most architecturally subtle class. A contract individually passes its access control checks, but the composition of multiple contracts creates an escalation path that no single contract’s audit would reveal.
Pattern A: Trusted Callback Abuse
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// ❌ VaultController trusts any caller with OPERATOR_ROLE
// to invoke sensitiveAction(). But OPERATOR_ROLE is also
// granted to a Staking contract that has a publicly callable
// notifyReward() function — which calls back into VaultController.
interface IVaultController {
function sensitiveAction(address target, bytes calldata data) external;
}
contract StakingPool {
IVaultController public vaultController;
// Anyone can call this ↓
function notifyReward(address target, bytes calldata data) external {
// ❌ StakingPool holds OPERATOR_ROLE on VaultController.
// Any user can use StakingPool as a privilege relay.
vaultController.sensitiveAction(target, data);
}
}
contract VaultController {
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
mapping(bytes32 => mapping(address => bool)) public roles;
function sensitiveAction(address target, bytes calldata data) external {
// ❌ Checks msg.sender == StakingPool, which has OPERATOR_ROLE.
// Does NOT check whether the original initiator was authorized.
require(roles[OPERATOR_ROLE][msg.sender], "not operator");
(bool ok,) = target.call(data);
require(ok);
}
}
The attack: an unprivileged user calls StakingPool.notifyReward(target, data). StakingPool forwards it to VaultController.sensitiveAction. VaultController sees msg.sender == address(StakingPool), which holds OPERATOR_ROLE, and allows execution. Arbitrary target.call(data) runs with no user authorization check.
Corrected pattern — caller context propagation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/AccessControl.sol";
contract SecureVaultController is AccessControl {
bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
bytes32 public constant INITIATOR_ROLE = keccak256("INITIATOR_ROLE");
// ✅ Both the calling contract (OPERATOR_ROLE) AND the
// transaction initiator (INITIATOR_ROLE) must be authorized.
// An unprivileged user routing through StakingPool is blocked
// because msg.sender == StakingPool satisfies OPERATOR_ROLE,
// but tx.origin... wait — we do NOT use tx.origin.
// Instead, we require the initiating address to be passed
// explicitly and verified off-chain, or we use EIP-2771 with
// a trusted forwarder that embeds the original sender.
function sensitiveAction(
address originalSender,
address target,
bytes calldata data
) external onlyRole(OPERATOR_ROLE) {
// ✅ Gate on the propagated sender as well
require(hasRole(INITIATOR_ROLE, originalSender), "initiator not authorized");
(bool ok,) = target.call(data);
require(ok, "call failed");
}
}
contract SecureStakingPool {
SecureVaultController public vaultController;
// Only INITIATOR_ROLE holders can trigger actions through this pool
function notifyReward(address target, bytes calldata data) external {
// ✅ msg.sender is forwarded so VaultController can verify it
vaultController.sensitiveAction(msg.sender, target, data);
}
}
Pattern B: delegatecall Privilege Bypass
The delegatecall function in Solidity allows a contract to execute code from another contract in the context of the calling contract. This powerful feature can inadvertently bypass access control checks if not carefully managed. An attacker might be able to use delegatecall to execute privileged functions through a seemingly innocuous entry point.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract LibraryContract {
address public owner; // slot 0
// ❌ This function is intended to be called directly,
// not via delegatecall from a proxy that stores a
// different variable in slot 0.
function setOwner(address newOwner) external {
require(msg.sender == owner, "not owner");
owner = newOwner;
}
}
contract VulnerableProxy {
address public implementation; // slot 0 ← COLLISION with LibraryContract.owner!
address public admin; // slot 1
fallback() external {
// ❌ delegatecall executes LibraryContract.setOwner in *this* storage.
// `owner` in LibraryContract maps to slot 0, which IS `implementation` here.
// An attacker calls setOwner(attackerAddress) → overwrites implementation.
address impl = implementation;
assembly {
calldatacopy(0, 0, calldatasize())
delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
return(0, returndatasize())
}
}
}
The fix is EIP-1967 storage slots: the implementation address is stored at a deterministic, collision-resistant slot (keccak256("eip1967.proxy.implementation") - 1), not at slot 0. Never use delegatecall to a library that was designed for direct calls without auditing its entire storage model.
Pattern C: Role Escalation via TimelockController
A vulnerability in TimelockController allowed an actor with the executor role to escalate privileges. As a workaround, revoke the executor role from accounts not strictly under the team’s control.
A TimelockController where executor is set to address(0) (meaning anyone can execute) combined with a poorly configured proposer set creates a situation where an attacker can propose and execute operations that grant themselves higher roles — provided no delay makes the window exploitable.
// VULNERABLE TimelockController configuration (conceptual)
// address[] memory executors = new address[](1);
// executors[0] = address(0); // ❌ anyone can execute queued proposals
// CORRECTED configuration
// address[] memory executors = new address[](1);
// executors[0] = address(multisig); // ✅ only multisig can execute
7. Systematic Audit Checklist
The following checklist is structured for a complete access control review. Work through it top to bottom on every new engagement. Items marked [Critical] represent findings that should block deployment.
7.1 Modifier Coverage
- [Critical] Every state-mutating function has an explicit access modifier (
onlyOwner,onlyRole,whenNotPaused, etc.) or is intentionally public with documented justification. - [Critical] No authorization logic uses
tx.originas the identity check. - Verify that
internalfunctions called byexternalentrypoints do not inadvertently skip caller checks. - Check that
receive()andfallback()do not execute privileged logic without authorization.
7
.2 Ownership and Admin Key Management
- [Critical] Ownership transfer uses a two-step pattern (
transferOwnership+acceptOwnership). Single-step transfers are unacceptable for high-value contracts. - [Critical] No deployer EOA retains
DEFAULT_ADMIN_ROLEorownerafter deployment. All privileged roles must be held by a multisig or governance contract. - Verify that
renounceOwnershipis disabled or guarded if the contract cannot function without an owner. - If time-locked admin transfers are used, verify the timelock delay is appropriate for the protocol’s risk level.
7.3 Role-Based Access Control
- [Critical]
DEFAULT_ADMIN_ROLEis explicitly set to a restricted address — not left asaddress(0)ormsg.senderpost-deployment. - Every custom role has its
roleAdminexplicitly set via_setRoleAdmin. Roles without an explicit admin default toDEFAULT_ADMIN_ROLE. - No role can grant itself a higher-privilege role. Trace the full grant chain for each role.
- Emergency roles (pause, circuit breaker) have stricter revocation conditions than standard roles.
7.4 Initializer Security
- [Critical]
_disableInitializers()is called in the constructor of every implementation contract. - Deployment and initialization are atomic (single transaction).
- Re-initialization is impossible:
initializermodifier prevents callinginitialize()more than once.
7.5 Cross-Contract Trust
- For every contract that calls another with elevated permissions, trace the full permission chain back to the root of trust.
- Verify that no contract trusts
msg.senderwithout validating it is a specific, expected contract address. - Identify all
delegatecallsites. Verify the callee cannot manipulate storage unexpectedly. - Check that emergency functions in dependent contracts cannot be triggered by unauthorized addresses.
7.6 Function Selector Checks
- For UUPS proxies: verify no implementation function selector collides with
upgradeTo(address)(0x3659cfe6). - For transparent proxies: verify admin routing is correct and admin-only functions are inaccessible to non-admin callers.
- Verify no two functions share the same 4-byte selector.
Access control failures are almost always preventable. The patterns are known, the fixes are standardized, and the tooling exists to catch most of them automatically. What the tooling does not catch — cross-contract privilege chains, role inheritance gaps, emergency function exceptions — requires methodical application of the checklist above to every privileged function in scope.