SolutionsProductsAuditsBlogContactRequest an Audit
BlogWe Audited 40 DeFi Protocols — Here's the Same Bug We Found in 28 of Them
We Audited 40 DeFi Protocols — Here's the Same Bug We Found in 28 of Them
security-analysis10 min readJune 2, 2026
0xTeam Author
Share

We Audited 40 DeFi Protocols — Here's the Same Bug We Found in 28 of Them

Across 40 DeFi audits spanning DEXes, lending markets, bridges, and yield aggregators, the most common critical finding wasn't reentrancy or overflow. It was broken access control — found in 28 of 40 protocols. Here's what it looks like in production code and how to fix it.

Numbers don't lie, but they do surprise you.

After 40 DeFi protocol audits — spanning DEXes, lending markets, yield aggregators, bridge contracts, and NFT financialization platforms — we sat down and ran the data. We wanted to know: is there a pattern? Is there one class of vulnerability that keeps showing up regardless of protocol type, team size, or technical sophistication?

There is.

Access control misconfiguration. Found in 28 of the 40 protocols we audited. That's 70%.

Not reentrancy. Not integer overflow. Not flash loan logic errors. The most common critical or high-severity finding across our audit portfolio is broken, missing, or misconfigured access control — the mechanism that determines who is allowed to do what.

This post breaks down what we found, why it keeps happening, and what it actually looks like in production code. We're not naming the protocols — audit confidentiality is part of how trust works in this industry. But the patterns are real, and they're in codebases right now that haven't been audited yet.

What Access Control Misconfiguration Actually Means

"Access control bug" sounds abstract. It isn't. Here's what it looks like in practice.

Scenario A: The unprotected admin function

function setFeeRecipient(address newRecipient) external {
    feeRecipient = newRecipient;
}

No onlyOwner. No role check. No timelock. Any address can call this function and redirect all protocol fees to themselves. We found variants of this in 11 protocols. In three of those cases, the function also controlled treasury withdrawal addresses.

Scenario B: The misconfigured role hierarchy

bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

constructor() {
    _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
    _setupRole(ADMIN_ROLE, msg.sender);
    _setupRole(PAUSER_ROLE, msg.sender);
    _setRoleAdmin(PAUSER_ROLE, ADMIN_ROLE);
    // ADMIN_ROLE admin is DEFAULT_ADMIN_ROLE — which is also held by the deployer
    // But DEFAULT_ADMIN_ROLE admin is... DEFAULT_ADMIN_ROLE itself
    // Anyone with DEFAULT_ADMIN_ROLE can grant themselves ADMIN_ROLE
    // If the deployer key is ever compromised, the entire role hierarchy collapses
}

OpenZeppelin's AccessControl is used correctly here at the code level. The misconfiguration is architectural: the DEFAULT_ADMIN_ROLE is a single point of failure, and its admin is itself. One compromised key = full protocol takeover. We found this pattern — or close variants — in 8 protocols.

Scenario C: The initialization exploit

contract VaultV2 is Initializable {
    address public owner;

    function initialize(address _owner) external {
        owner = _owner;
    }
}

No initializer modifier. No check that initialize() hasn't already been called. On an upgradeable proxy, if this implementation contract is deployed and initialize() isn't called immediately, anyone can call it first and set themselves as owner. This is not a hypothetical — Parity Multisig was destroyed by this exact pattern in 2017, and we still find it in production code in 2024. Found in 6 of our 40 audits.

Scenario D: Function selector collision in proxies

This one is rare but devastating. When a proxy contract and its implementation share a function with the same 4-byte selector (not the same name — just the same first 4 bytes of the keccak256 hash), the proxy might intercept calls intended for the implementation, or vice versa.

In transparent proxy patterns, this is handled by OpenZeppelin's TransparentUpgradeableProxy, which routes admin calls differently from user calls. But custom proxy implementations — or proxies that extend the standard in ways that weren't fully audited — can have selector collisions that let attackers call admin functions as regular users.

We found this in 3 protocols, all with custom proxy implementations.

The 28: Breaking Down What We Found

Across the 28 protocols with access control issues, here's how the findings distributed:

Finding TypeCountSeverity
Unprotected admin/privileged functions11Critical / High
Unsafe DEFAULT_ADMIN_ROLE architecture8High / Medium
Uninitialized or re-initializable upgradeable contracts6Critical
Missing timelocks on sensitive operations7High / Medium
Function selector collisions in custom proxies3Critical
Role granted to zero address or dead address4High
Incorrect modifier on inherited functions5High

Note: several protocols had multiple findings, which is why the column sums exceed 28.

The critical-severity findings — unprotected functions, uninitialized contracts, selector collisions — represent immediate exploitability. An attacker with basic knowledge of the protocol could have drained funds or taken control within minutes of identifying the vulnerability.

The high-severity findings — unsafe role architecture, missing timelocks — represent systemic risk. They may not be immediately exploitable but create conditions where a single incident (key compromise, insider threat, governance attack) leads to total loss of protocol control.

The Protocols That Got Hit: Real-World Validation

Our internal data is confidential. But the public record validates the pattern.

Poly Network — $611M (August 2021)

The largest DeFi hack in history at the time of occurrence. The root cause was an access control failure in the EthCrossChainManager contract. The attacker called a function — verifyHeaderAndExecuteTx() — that was supposed to be restricted to a specific keeper role. Due to a logic flaw in how the role was verified, the attacker passed a crafted header that pointed to a malicious contract. That contract's function was then called with full bridge authority.

The function was there. The restriction was there. The implementation of the restriction was broken. $611M followed.

Compound — $80M (September 2021)

Not a hack — a governance bug. But access control nonetheless. A proposal passed that changed the distribution formula for COMP rewards, unintentionally allowing users to claim far more COMP than intended. The governance mechanism — which is itself an access control system — executed a change that nobody fully modeled.

$80M in COMP was claimable before the protocol could respond. Because governance is access control at the protocol level, and governance bugs are access control bugs.

Ronin Bridge — $625M (March 2022)

The largest crypto hack on record at time of writing. Ronin used a 5-of-9 multisig to authorize bridge transactions. An attacker — later attributed to the Lazarus Group — compromised 5 validator keys, including 4 from Sky Mavis and 1 from Axie DAO (which had been granted signing authority in a temporary arrangement that was never revoked).

The access control system worked exactly as designed. The design was the vulnerability. A temporary permission became permanent. The validator set was never rotated. The threshold was achievable by a single threat actor through phishing and social engineering. $625M. Access control misconfiguration.

Euler Finance — $197M (March 2023)

Euler's exploit is often classified as a flash loan attack. It was also an access control failure. The donateToReserves() function — introduced to allow users to donate funds to the protocol's reserve — had no restrictions on which tokens could be donated or what state was valid at donation time. Combined with flash loan mechanics, this allowed the attacker to create an artificial bad debt position.

The function didn't need to be fully public. It needed constraints that weren't there. $197M.

Why Does This Keep Happening?

After 40 audits and extensive post-mortems on public exploits, we have a clear picture of the failure modes.

1. Access control is added reactively, not architecturally

In most codebases we audit, the core logic is written first. Functions are built to do what they need to do. Then, access control is layered on — onlyOwner here, a role check there, a modifier added after the fact.

This reactive approach means access control is never part of the threat model during design. It's a constraint bolted on at the end, applied to the functions developers think are sensitive — not necessarily the functions that are sensitive.

The functions developers think are sensitive: withdraw(), mint(), upgrade(). The functions that are actually sensitive and often missed: setOracle(), setFeeRecipient(), updateRewardRate(), pauseWithdrawals(), grantRole(), and every other administrative function that can change protocol economics or control flow.

2. Inherited functions don't inherit intent

When a contract inherits from a base class, the inherited functions bring their own access control logic — or lack of it. A common pattern: developer inherits from a base contract, overrides a function, adds their own modifier, but forgets that the base version of that function is still callable unless it's virtual and the override is the only path.

Or the reverse: the base contract has an onlyOwner function. The developer overrides it without the modifier, assuming the parent's restriction carries through. It doesn't. The override is a new function with no inherited access control.

We found this in 5 protocols. In two cases, the unprotected function was a direct consequence of an inheritance pattern the developer clearly didn't intend to create.

3. Upgrades reset assumptions

Every protocol upgrade is an opportunity to introduce access control gaps. New functions are added. Old functions are deprecated but not removed. Role assignments from the constructor don't carry through to the new implementation. The initialize() pattern is updated incorrectly.

We specifically audit upgrade diffs — the delta between V1 and V2 — and we find access control issues in these diffs more frequently than in greenfield code. The reason is simple: developers review new code more carefully than they review what changed in existing code.

4. Timelocks are treated as optional

A timelock forces a mandatory delay between a privileged action being proposed and executed. It gives the community and security researchers time to detect malicious or erroneous changes before they take effect.

7 of our 28 protocols had privileged functions — oracle updates, fee changes, collateral parameter changes — with no timelock. In some cases, these functions could be called unilaterally by an EOA (externally owned account) with no multisig, no delay, no community input.

This isn't exploitable in the traditional sense — the key needs to be compromised first. But it means the blast radius of a key compromise is total and immediate. A timelock doesn't prevent a compromise; it limits the damage and creates a window for response. Treating timelocks as optional on critical functions is a risk posture decision most protocols haven't consciously made. They just... didn't add one.

5. The DEFAULT_ADMIN_ROLE antipattern

OpenZeppelin's AccessControl gives every role a role admin — an admin role that can grant or revoke that role. By default, the admin of all roles is DEFAULT_ADMIN_ROLE. And the admin of DEFAULT_ADMIN_ROLE is itself.

This means: whoever holds DEFAULT_ADMIN_ROLE can grant themselves any role, including roles that were explicitly meant to be separate. The separation of concerns in your role design is entirely dependent on the security of the one key that holds DEFAULT_ADMIN_ROLE.

Protocols that use AccessControl often do so believing they've distributed permissions. They haven't. They've created a hierarchy where the apex — a single key — controls everything. If that key is compromised via phishing, SIM swap, or insider threat, the entire permission system collapses in a single transaction.

The fix is architecture: use a multisig for DEFAULT_ADMIN_ROLE. Use a timelock. Renounce DEFAULT_ADMIN_ROLE after setup if no further admin operations are needed. None of these are hard. All of them are missed.

The Fix Stack: What Secure Access Control Actually Looks Like

Design first, implement second

Before writing a line of code, map every function in your protocol to a permission level:

  • Public: anyone can call
  • User-restricted: caller must meet a condition (e.g., has sufficient balance)
  • Role-restricted: only specific addresses with a named role
  • Admin-only: only multisig with timelock
  • Governance-controlled: only via on-chain proposal

This map should exist as a document before the implementation exists. Every function that doesn't fit its intended permission level is a bug by design.

Use OpenZeppelin AccessControl correctly

  • Never leave DEFAULT_ADMIN_ROLE on an EOA in production
  • Set role admins explicitly — don't rely on defaults
  • Use a 3/5 or 4/7 multisig as DEFAULT_ADMIN_ROLE holder
  • Wrap DEFAULT_ADMIN_ROLE operations in a timelock

Protect initialization in upgradeable contracts

contract VaultV2 is Initializable {
    address public owner;

    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers(); // Prevents initialization of implementation contract
    }

    function initialize(address _owner) external initializer {
        owner = _owner;
    }
}

_disableInitializers() in the constructor + initializer modifier on initialize() = the implementation contract can never be initialized by an attacker.

Add timelocks to all critical parameter changes

Any function that changes: oracles, fee rates, collateral parameters, reward distributions, access control roles, or upgrade paths — needs a timelock. The minimum delay depends on the protocol's TVL and governance model, but 24 hours is a floor; 48–72 hours is reasonable for high-TVL protocols.

Audit your inheritance chain

For every contract in your system, trace the full inheritance tree. For every function that's public or external, verify that the access control is at the right level and that no parent class exposes an unprotected version of the same function.

The 0xTeam Audit Approach to Access Control

When we audit a protocol, access control is not one item on a checklist. It's a parallel analysis that runs the entire audit.

We map every function to its intended permission level and verify the implementation matches. We trace inheritance chains. We test initialization paths. We model the scenario where every privileged key is compromised and calculate the blast radius. We check every upgrade diff against the prior version's permission model.

Access control is not a feature. It is the security boundary between your protocol and everyone who wants to take it from you. 70% of the protocols we've audited had a hole in that boundary. Most of them didn't know it.
++
Worried? Get your security audit done today.

Don't launch vulnerable code. Our team will review your smart contracts and deliver a full audit report within 48 hours.

Request Audit
© 0xTeam space 2026. All rights reserved.