--- name: Reentrancy & Access Control Vulnerability Patterns description: Use this skill when auditing or writing Solidity contracts to identify and prevent reentrancy attacks and access control flaws — the two most common critical vulnerability classes in Code4rena audits. ecosystem: ethereum type: security-guide source: community confidence: high version: 1.0.0 time_sensitivity: evergreen tags: - reentrancy - access-control - smart-contract-security - solidity - audit updated_at: 2026-03-26T00:00:00.000Z --- # 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):** ```solidity 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):** ```solidity 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`/`transferFrom` in non-trivial logic: are hooks considered? - [ ] Are all ETH sends using `call` guarded by `nonReentrant`? - [ ] Is every privileged function guarded by RBAC, not just single-address checks? - [ ] Are `initialize` functions protected against front-running? - [ ] Do time-locks protect parameter changes that affect user funds? - [ ] Is `msg.sender` the correct check, or should `tx.origin` be avoided?