Smart contract access control: owners, roles and privilege escalation
Access control leads OWASP’s Smart Contract Top 10. How ownership transfers, role-admin graphs, timelocks and multisigs fail, six common bugs with their fixes, and how to test who can call what.
- Written by
- Quantix Research, Smart Contract Security
- Published
- Reading time
- 11 minutes
- Category
- Smart Contract Vulnerabilities
Primer. Explains a known class of issue. Code is simplified and illustrative, not for production use.

Contents
Every privileged function answers one question before it does anything else: may this caller do this, now, with these arguments? When the answer is wrong or missing, an attacker needs no price to move and no rounding error to accumulate. They call the function and receive the full authority of the role it was meant for.
Initialisation and upgrade authorisation are covered in our primer on initializers and proxies, and vote-based attacks in our analysis of governance attacks. This primer covers ownership, role hierarchies, six common bugs, privilege design and testing.
Why access control leads the rankings
The OWASP Smart Contract Top 10 ranks Access Control Vulnerabilities first in its 2026 edition (SC01:2026), as in 2025 (SC01:2025). OWASP derives the 2026 order mainly from a practitioner survey, ranked by mean position and checked against 2025 incident data: 122 deduplicated incidents and about $905.4M in losses across all categories.
There are structural reasons. A missing check hands over the whole role, not a slice of value; control is spread across the contract, the deployment script and key custody, which are often reviewed separately; and the defect is usually an absence. The threat model has four callers: any address; a lesser role reaching for a greater one; a compromised or colluding key holder; and a privileged contract made to act for someone else, the confused deputy.
Ownership: one key and its pitfalls
OpenZeppelin’s Ownable has one owner and an onlyOwner modifier. In Contracts 5.x the constructor and transferOwnership reject the zero address, but a transfer otherwise takes effect at once: a mistyped address, or a multisig not yet deployed on this chain, receives the contract permanently. Ownable2Step only records a pending owner, who must call acceptOwnership, so a typo costs a transaction and can be corrected or cancelled.
// Simplified, illustrative. Not production code.contract FeeController is Ownable2Step { constructor(address initialOwner) Ownable(initialOwner) {} // reverts for address(0) // Inherited: transferOwnership(next) records pendingOwner = next, and next must call // acceptOwnership(). transferOwnership(address(0)) cancels a pending transfer. // An ownerless FeeController could never change its fee again: forbid renouncing. function renounceOwnership() public pure override { revert("renounce disabled"); }}renounceOwnership sets the owner to zero, disables every onlyOwner function for good and, in Ownable2Step, clears any pending owner. Renouncing while paused freezes the contract; renouncing while a role, proxy admin or minter survives makes “ownership renounced” misleading. Disable it, as above, or inventory every other privileged path first.
Roles, role admins and escalation
AccessControl replaces the owner with bytes32 roles checked by onlyRole. Each role has an admin role (getRoleAdmin) whose holders alone can grant or revoke it; by default that is DEFAULT_ADMIN_ROLE (0x00), its own admin. The internal _grantRole and _setRoleAdmin perform no checks. The admin relation is a graph: whoever holds A, the admin of B, can grant B to itself, so a role is only as secure as the weakest holder on any path to it.
// Simplified, illustrative. Not production code.contract Market is AccessControl { constructor(address governanceTimelock, address riskTimelock, address opsKey) { _grantRole(DEFAULT_ADMIN_ROLE, governanceTimelock); // admin of every role by default _grantRole(TREASURER_ROLE, governanceTimelock); _grantRole(RISK_ADMIN_ROLE, riskTimelock); _grantRole(OPS_ADMIN_ROLE, opsKey); // one hot key, run by the ops team _setRoleAdmin(KEEPER_ROLE, OPS_ADMIN_ROLE); // intended: ops manages keeper bots _setRoleAdmin(TREASURER_ROLE, OPS_ADMIN_ROLE); // copied line: ops now manages the treasury } function withdrawReserves(address to, uint256 amount) external onlyRole(TREASURER_ROLE) { /* ... */ }}// From opsKey, two transactions and no delay:// grantRole(TREASURER_ROLE, opsKey); withdrawReserves(opsKey, reserves);One copied line makes the ops key, a single hot key for keeper bots, the admin of TREASURER_ROLE. Two transactions take the reserves, and the key can also revoke the role from the timelock.
| Role | Held by | Is admin of | Effective authority |
|---|---|---|---|
| DEFAULT_ADMIN | Governance timelock | DEFAULT_ADMIN, RISK_ADMIN, OPS_ADMIN | Everything, TREASURER included via OPS_ADMIN |
| OPS_ADMIN | Ops key (1-of-1) | KEEPER, TREASURER | Keeper actions and the reserves, no delay |
| TREASURER | Governance timelock | Nothing | The reserves, until the ops key revokes it |
| RISK_ADMIN | Risk timelock | Nothing | Collateral factors only |
AccessControlDefaultAdminRules hardens the top: one holder of DEFAULT_ADMIN_ROLE, which grantRole cannot assign and no other role can administer, transferred in two steps (beginDefaultAdminTransfer, then acceptDefaultAdminTransfer) separated by a cancellable delay. With a 3-day defaultAdminDelay, a transfer begun at T can be accepted only after T + 259,200 s (3 × 86,400). Changing the delay is delayed too: an increase waits the new delay, capped at 5 days by default (1 → 10 days takes effect after 5), and a decrease waits the difference (10 → 3 days, after 7).
AccessManager, added in Contracts 5.0, holds the whole matrix in one contract: uint64 roles (ADMIN_ROLE = 0; PUBLIC_ROLE = 264 − 1, held by everyone) mapped to target contracts and selectors and enforced by the restricted modifier, so a function without it is unrestricted. Grants can carry execution delays, and roles a grant delay and a cancelling guardian; changes to grant and target-admin delays wait at least minSetback (5 days by default), and scheduled operations expire after a week. Its ADMIN_ROLE is every permission in the system.
Six bugs and their fixes
Missing checks, tx.origin and stray visibility. Three bugs fit in a line each: a state-changing function with no modifier, often a late “config helper”; a check on tx.origin, the account that signed the transaction, which any contract the owner calls also satisfies; and a helper left public, or a fallback that forwards every call with delegatecall and so exposes a whole library.
// Simplified, illustrative. Not production code.// 1. No check at all: a "config helper" added after the review.function setOracle(address asset, IOracle feed) external { oracles[asset] = feed; }// Fixed: ... external onlyRole(GOVERNANCE_ROLE) { ...; emit OracleSet(asset, address(feed)); } // 2. tx.origin: any contract the owner can be lured into calling passes this check.function sweep(address payable to) external { require(tx.origin == owner); to.transfer(address(this).balance); }// Fixed: ... external onlyOwner { ... } // authorise msg.sender, the immediate caller // 3. Bookkeeping left public, and a fallback that exposes a whole library.function creditRewards(address user, uint256 amount) public { rewards[user] += amount; }fallback() external payable { (bool ok, ) = walletLibrary.delegatecall(msg.data); require(ok); }// Fixed: _creditRewards(...) internal; forward only an explicit list of selectors.Slither’s protected-vars detector enforces the first fix: annotate a variable with @custom:security write-protection="onlyGovernance()" and any function writing it without that modifier is reported. EIP-7702 notes that it breaks sandwich protections relying on tx.origin and reentrancy guards of the form require(tx.origin == msg.sender). Parity’s multisig wallets, which forwarded calls to a shared library, failed through the third bug twice in 2017. According to OpenZeppelin’s analysis, a July attacker called initWallet on wallets through that forwarding and reset their ownership; in November someone called it on the never-initialised library itself, became its owner and destroyed it, freezing every dependent wallet, with losses OpenZeppelin estimated at over 500,000 ETH.
Unchecked callers in callbacks. Swap, flash-loan and hook callbacks should run only inside an operation the contract started. One that does not check its caller accepts attacker-chosen parameters from anyone, and a caller check alone fails if users can supply the pool.
// Simplified, illustrative. Not production code.// Vulnerable form: swapCallback callable by anyone, with payer = abi.decode(data, (address)).address transient expectedPool; // transient storage, Solidity >= 0.8.28address transient payer; function swap(IPool pool, uint256 amountIn) external { if (!isListedPool[address(pool)]) revert UnknownPool(); // a fake pool would pass a sender check (expectedPool, payer) = (address(pool), msg.sender); pool.swap(address(this), amountIn); (expectedPool, payer) = (address(0), address(0));} function swapCallback(uint256 amountOwed, bytes calldata) external { if (msg.sender != expectedPool) revert UnexpectedCaller(); // only the pool being called, only now tokenIn.safeTransferFrom(payer, msg.sender, amountOwed); // payer recorded here, not taken from calldata}A role held by a contract that makes arbitrary calls. The token sees a caller with MINTER_ROLE; the executor never asks whose request it carries.
// Simplified, illustrative. Not production code.contract BatchExecutor { // holds MINTER_ROLE on the token function execute(address target, bytes calldata data) external returns (bytes memory) { return Address.functionCall(target, data); }}// Anyone: batchExecutor.execute(address(token), abi.encodeCall(token.mint, (attacker, 1e27)));Poly Network’s August 2021 exploit, analysed by Kudelski Security, had this shape: EthCrossChainManager owned EthCrossChainData, which held the keepers’ public keys, and executed messages with a user-defined method name. The attacker chose f1121318093, whose selector with the manager’s appended parameter types equals that of putCurEpochConPubKeyBytes(bytes), 0x41973cd9; calling it replaced the keepers’ key, and about $610 million was moved. A contract that forwards calls should hold no roles; a privileged executor must check the original caller per target and selector, as AccessManager.execute does.
Signatures without nonce or domain. A signature authorising an admin action is a credential whose signer appears in no role table. Without a nonce it can be replayed, for instance to restore a fee governance has since changed; without an EIP-712 domain it works on every chain and deployment trusting that signer. Our signature replay primer covers the binding fields; here, the rule is to give the signer the same bounds and delays as an on-chain role.
A privilege matrix for a model market
Consider a model lending market. Its privilege matrix lists every privileged function with its role, holder, delay and worst case: the first document a reviewer should ask for, and the specification the tests encode.
| Function | Role | Held by | Delay | Worst case if misused |
|---|---|---|---|---|
upgradeToAndCall | UPGRADER | Governance timelock | 48 h | Arbitrary code; every deposit |
grantRole, revokeRole | DEFAULT_ADMIN | Governance timelock | 48 h | Any role below |
setOracle | GOVERNANCE | Governance timelock | 48 h | Mispriced collateral, bad debt |
withdrawReserves | TREASURER | Governance timelock | 48 h | Reserves, not user deposits |
unpause | GOVERNANCE | Governance timelock | 48 h | Reopens a market before a fix |
setCollateralFactor | RISK_ADMIN | Risk timelock | 24 h | Bounded drift, one step a day |
pause | GUARDIAN | Guardian Safe | None | Halted deposits and borrows |
accrue, harvest | KEEPER | Bots, managed by OPS_ADMIN | None | Timing only |
Three rules shape it. Operational roles, whose keys must stay online, reach nothing that moves user funds or changes code, directly or through the role-admin graph. Risk roles are bounded in code, including their rate: ten 5-point changes queued together through the 24-hour timelock could all execute in one block a day later, taking a collateral factor from 30% to 80%; with a one-day cooldown the move needs (80 − 30) / 5 = 10 changes, so at least ten days. Emergency roles only reduce risk: a guardian that can unpause, upgrade or withdraw is a treasury key without a delay.
// Simplified, illustrative. Not production code.function setCollateralFactor(address asset, uint16 newBps) external onlyRole(RISK_ADMIN_ROLE) { uint16 old = collateralFactorBps[asset]; if (newBps > 8_000) revert AboveMaximum(); // 80% ceiling if ((newBps > old ? newBps - old : old - newBps) > 500) revert StepTooLarge(); // 5 points per change if (block.timestamp < lastUpdate[asset] + 1 days) revert TooSoon(); // one change a day (collateralFactorBps[asset], lastUpdate[asset]) = (newBps, uint48(block.timestamp)); emit CollateralFactorSet(asset, old, newBps);} function pause() external onlyRole(GUARDIAN_ROLE) { _pause(); } // immediatefunction unpause() external onlyRole(GOVERNANCE_ROLE) { _unpause(); } // through the 48 h timelockTimelocks and multisig thresholds
TimelockController enforces minDelay between scheduling and execution. PROPOSER_ROLE schedules, EXECUTOR_ROLE executes (granted to address(0), anyone can) and CANCELLER_ROLE cancels; the 5.x constructor makes every proposer a canceller. The optional constructor admin should be renounced, and only the timelock itself can call updateDelay, so shortening the delay first waits out the current one. With minDelay = 172,800 s (48 × 3,600), an operation scheduled at 12:00 UTC on a Monday is executable at 12:00 UTC on Wednesday, a window that helps only if on-chain monitoring watches CallScheduled, RoleGranted and RoleAdminChanged events and a canceller can act.
A t-of-n multisig tolerates n − t lost keys and needs t compromised or colluding signers. The table assumes each key is independently lost with p = 5% and compromised with q = 5% over a period, an illustrative assumption.
| Configuration | Lost keys tolerated | Keys an attacker needs | P(cannot sign) | P(attacker signs) |
|---|---|---|---|---|
| 1-of-1 | 0 | 1 | 5% | 5% |
| 2-of-3 | 1 | 2 | 0.725% | 0.725% |
| 3-of-3 | 0 | 3 | 14.26% | 0.0125% |
| 4-of-7 | 3 | 4 | 0.0194% | 0.0194% |
| 6-of-7 | 1 | 6 | 4.44% | 0.00001% |
| 2-of-3, two keys on one host | The third key only | The host alone | 5% | 5% |
For 2-of-3, either failure needs two of three keys: 3 × 0.05² × 0.95 + 0.05³ = 0.007125 + 0.000125 = 0.725%. 4-of-7 brings both to about 0.019%; a higher threshold without more signers trades liveness for safety. The model uses 4-of-7 for governance, where theft would be total, and 2-of-3 for the guardian, which must be fast.
Testing who can call what
The matrix is a specification, so it can be a test: every gated function must revert, with the exact error, for any caller without the role, and a completeness check fails when an unclassified external function appears.
// Simplified, illustrative. Not production code.contract MarketAccessTest is Test { struct Gate { bytes data; bytes32 role; } Market market; Gate[] gates; mapping(bytes4 => bool) classified; function setUp() public { market = new Market(makeAddr("gov"), makeAddr("risk"), makeAddr("guardian"), makeAddr("ops")); _gate(abi.encodeCall(market.setCollateralFactor, (address(1), 100)), market.RISK_ADMIN_ROLE()); _gate(abi.encodeCall(market.withdrawReserves, (address(2), 1)), market.TREASURER_ROLE()); _gate(abi.encodeCall(market.pause, ()), market.GUARDIAN_ROLE()); // ...one line per gated function; public-by-design selectors are only marked classified. } function _gate(bytes memory data, bytes32 role) internal { gates.push(Gate(data, role)); classified[bytes4(data)] = true; } function testFuzz_unauthorisedCallersRevert(address caller) public { for (uint256 i; i < gates.length; i++) { if (market.hasRole(gates[i].role, caller)) continue; vm.prank(caller); (bool ok, bytes memory err) = address(market).call(gates[i].data); assertFalse(ok); assertEq(err, abi.encodeWithSelector( IAccessControl.AccessControlUnauthorizedAccount.selector, caller, gates[i].role)); } } function test_everyExternalFunctionIsClassified() public view { string memory json = vm.readFile("out/Market.sol/Market.json"); // needs fs_permissions on ./out string[] memory sigs = vm.parseJsonKeys(json, ".methodIdentifiers"); for (uint256 i; i < sigs.length; i++) assertTrue(classified[bytes4(keccak256(bytes(sigs[i])))], sigs[i]); }}An invariant test checks the property across call sequences, including grants and revocations: the handler flags any successful call by an account without RISK_ADMIN_ROLE that changed a collateral factor.
// Simplified, illustrative. Not production code. Handler: one wrapper per external function.function setCollateralFactor(uint256 seed, uint16 bps) external { _call(seed, abi.encodeCall(market.setCollateralFactor, (ASSET, bps)));} function _call(uint256 seed, bytes memory data) internal { address actor = actors[seed % actors.length]; // role holders and strangers bool authorised = market.hasRole(market.RISK_ADMIN_ROLE(), actor); uint16 before = market.collateralFactorBps(ASSET); vm.prank(actor); (bool ok, ) = address(market).call(data); if (ok && !authorised && market.collateralFactorBps(ASSET) != before) unauthorisedChange = true;} // In the test contract, with targetContract(address(handler)):function invariant_onlyRiskAdminChangesCollateralFactor() public view { assertFalse(handler.unauthorisedChange());}Slither is a complementary first pass. The detectors below cover the mechanical cases (there is no plain arbitrary-send; it is split into -eth and -erc20 variants), and the vars-and-auth printer drafts a matrix from the code. Detectors count any msg.sender check as protection, whichever role it names.
slither . --detect arbitrary-send-eth,arbitrary-send-erc20,suicidal,unprotected-upgrade,controlled-delegatecall,tx-origin,protected-vars,events-accessslither . --print vars-and-auth # state written by each function, and the msg.sender checks guarding itThe Quantix Contract Analyzer runs Slither on verified source, pasted code or a repository. tx-origin, protected-vars and the arbitrary-send detectors appear under Access Control, and findings that let any caller move funds, take control or destroy the contract are rated critical at medium or high confidence. It cannot know the intended matrix; the tests and a manual smart contract audit supply that.
Review checklist
- Every state-changing function is in the matrix with a role, holder, delay and worst case.
- No internal helper is public, no fallback forwards arbitrary calls, no check uses
tx.origin. - Ownership transfers take two steps; renouncing is disabled or planned.
getRoleAdminis asserted per role; no operational role reaches upgrade, treasury or admin roles; deployers have renounced.- Callbacks check a validated counterpart; privileged contracts make no arbitrary calls.
- Signed admin actions bind a domain, nonce and deadline; risk parameters have a ceiling, step and cooldown.
- Guardians can only pause and cancel; signers are independent in organisation, device and custody.
Conclusions
Access control fails in three ways: a check is missing, a check names the wrong party, or a legitimate holder is weaker than its authority. Enumerating functions finds the first, drawing the role-admin graph the second, and counting independent failure domains and delays the third.
Written down as a privilege matrix and enforced by tests and monitoring, a protocol’s authority becomes reviewable.
Recommendations
Developers
- Use
Ownable2SteporAccessControlDefaultAdminRules; disable renouncing where it would strand the contract. - Keep operational roles off every path to upgrade, treasury and admin roles; bound risk roles per change and per day.
- Grant no role to an arbitrary-call contract, and validate every callback’s caller.
Protocol operations
- Put upgrade, treasury and admin roles behind a timelock proposed by a multisig of independent signers.
- Limit the guardian to pausing and cancelling; monitor role, ownership and timelock events.
Reviewers
- Ask for the privilege matrix, rebuild it from code and on-chain role holders, and compare.
- Test every gated function against unauthorised callers; fail the build on unclassified functions.
Bibliography
Primary sources: standards, official documentation, incident reports and papers.
- 1OWASP Smart Contract Top 10: 2026(opens in a new tab)OWASP Smart Contract Security project
- 2Access Control API: Ownable2Step, AccessControl, AccessControlDefaultAdminRules, AccessManager(opens in a new tab)OpenZeppelin Contracts 5.x documentation
- 3Governance API: TimelockController(opens in a new tab)OpenZeppelin Contracts 5.x documentation
- 4Slither Detector Documentation(opens in a new tab)Crytic (Trail of Bits), Slither wiki
- 5EIP-7702: Set Code for EOAs(opens in a new tab)Ethereum Improvement Proposals
- 6Parity Wallet Hack Reloaded(opens in a new tab)OpenZeppelin, November 2017
- 7The Poly Network Hack Explained(opens in a new tab)Kudelski Security, August 2021
- 8Community Alert: Ronin Validators Compromised(opens in a new tab)Ronin Network (Sky Mavis), March 2022
Applied on this site
Assessments, case studies and tools that apply this publication.







