Reentrancy & Access Control Vulnerability Patterns
Distilled from 300+ Code4rena audit findings. These two vulnerability classes account for a disproportionate share of critical-severity findings and stolen funds. Understanding both attack vectors — and how they interact — is essential for any smart contract developer or auditor.
Reentrancy Patterns
Reentrancy occurs when an external call transfers control to attacker-controlled code before the calling contract's state has been finalized.
1. Flash Loan Callback Reentrancy
Pattern: A flashLoan() function sends funds to a recipient and invokes a callback (e.g., onFlashLoan). If state is not updated before the callback, the attacker can call flashLoan() again recursively, bypassing per-transaction limits or draining the pool.
Root cause: State updates (e.g., totalBorrowed, pool balance) happen after the external callback.
Example (vulnerable):
function flashLoan(address recipient, uint256 amount) external {
token.transfer(recipient, amount);
IFlashLoanReceiver(recipient).onFlashLoan(amount); // ← attacker reenters here
require(token.balanceOf(address(this)) >= poolBalance, "not repaid");
}
Fix: Use the checks-effects-interactions (CEI) pattern: update state, then call out.
2. Hook-Based Double-Entry Reentrancy
Pattern: Two different code paths call the same state-modifying function. For example, updateReward() is triggered both by a beforeTokenTransfer ERC20 hook and by an explicit call in exit(). An attacker who can trigger a transfer during exit() causes updateReward() to execute twice with stale state.
Root cause: Reliance on multiple implicit entry points without a global reentrancy guard.
Fix: Apply nonReentrant at the top-level user-facing function. Do not rely solely on CEI when hooks are involved.
3. ETH Low-Level Call Reentrancy
Pattern: (bool ok,) = recipient.call{value: amount}("") allows recipient's fallback function to execute arbitrary code. If the contract hasn't updated balances yet, the fallback can re-enter withdraw() or claimReward().
Fix: Always update accounting before transferring ETH. Prefer pull-over-push payment patterns (let users withdraw rather than pushing ETH to them).
Key Reentrancy Mitigations
| Approach | When to use |
|---|---|
| Checks-Effects-Interactions | Default for all functions with external calls |
nonReentrant modifier | When CEI is not sufficient (callbacks, hooks) |
| Pull payments | High-value ETH distributions |
ReentrancyGuard (OZ) | Quick hardening of existing code |
Access Control Patterns
Access control vulnerabilities arise when the wrong principal can invoke a privileged function — either because checks are absent, misconfigured, or bypassable.
1. Single-Address onlyX Modifier
Pattern: modifier onlyGateway { require(msg.sender == gateway, "forbidden"); _ }. If gateway is a contract that can be compromised, upgraded, or misconfigured, all functions protected by this modifier are exposed.
Root cause: Binary trust — either full trust or none. No defense-in-depth.
Fix: Use role-based access control (e.g., OpenZeppelin AccessControl). Separate roles for different privilege levels (e.g., OPERATOR_ROLE, ADMIN_ROLE, PAUSER_ROLE).
2. Unprotected Initialization Functions
Pattern: A separate initialize(bytes calldata data) function is guarded only by an initializer flag but has no access control. An attacker can front-run the legitimate deployer's initialization call in the mempool, injecting malicious parameters (fee recipients, admin addresses, malicious logic).
Root cause: Treating an initializer guard (prevents re-initialization) as equivalent to access control (prevents unauthorized initialization). They are orthogonal concerns.
Example (vulnerable):
function initialize(address admin, uint256 fee) external {
require(!initialized, "already initialized"); // ← no msg.sender check
initialized = true;
_admin = admin;
_fee = fee;
}
Fix: Add onlyDeployer or pass the deployer address in the constructor, or use OpenZeppelin's Initializable combined with an access control check.
3. Missing Checks on Privileged Parameter Inputs
Pattern: An admin function accepts addresses or amounts without validating them. setFeeRecipient(address addr) with no zero-address check allows an admin mistake or compromised key to burn all fees. More critically, some protocols allow users to set configuration parameters that should be admin-only (missing onlyOwner).
Fix: Validate all inputs at every access control boundary. Prefer declaring constants over accepting arbitrary values for critical parameters.
4. Insufficient Granularity — Missing Time-Locks
Pattern: A single admin key can immediately change protocol-critical parameters (fee rates, oracle addresses, collateral ratios). If the key is compromised or the admin acts maliciously, users have no time to exit.
Fix: Apply time-locks (TimelockController from OZ) to all parameter changes. Common windows: 24h for low-risk, 48-72h for high-risk parameters.
Interaction: Reentrancy × Access Control
A subtle class of vulnerabilities combines both: an access-controlled function uses a callback or low-level call, and the callback recipient is a contract that can exploit the reentrancy. Always check whether privileged functions also make external calls — if so, apply reentrancy guards regardless of who the caller is.
Audit Checklist
- Every function with an external call: are state updates before the call?
- Any ERC20
transfer/transferFromin non-trivial logic: are hooks considered? - Are all ETH sends using
callguarded bynonReentrant? - Is every privileged function guarded by RBAC, not just single-address checks?
- Are
initializefunctions protected against front-running? - Do time-locks protect parameter changes that affect user funds?
- Is
msg.senderthe correct check, or shouldtx.originbe avoided?