security/reentrancy-access-control

Reentrancy & Access Control Vulnerability Patterns

ethereumsecurity-guide👥 Communityconfidence highhealth 100%
v1.0.0·Updated 3/26/2026

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

ApproachWhen to use
Checks-Effects-InteractionsDefault for all functions with external calls
nonReentrant modifierWhen CEI is not sufficient (callbacks, hooks)
Pull paymentsHigh-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/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?