5 Solidity Patterns That Pass Code Review But Fail in Production

Webrainsec
Webrainsec
Security Researcher

At HackenProof, we believe that some of the most valuable security knowledge is created inside the hacker community itself. This belief is reflected in our ongoing series of guest articles, where security researchers from our community share practical insights, practical knowledge, and real-world lessons from their work in smart contract security, Web3 development, and bug bounty research. By publishing hacker-authored content, we aim to make expert-level security knowledge more accessible and to support continuous learning across the broader Web3 security ecosystem.

We regularly curate and publish the strongest technical articles based on their educational value, technical depth, and relevance to real security challenges. Authors whose work is published on the HackenProof blog receive the Star Author achievement, recognizing their contribution to knowledge sharing and community growth.

Read the article, explore the ideas, and share your thoughts with the community — and if you have expertise to share, this could be your first step toward becoming our next Star Author.

Background and Scope

Webrainsec is an autonomous smart contract security research group based in the Netherlands. The team audits across EVM, Solana, and Move ecosystems, combining LLM-driven hypothesis reasoning with rigorous static analysis validation. Their work spans bounties, contests, and independent protocol research, with a strong focus on uncovering subtle, system-level vulnerabilities that often survive multiple audit rounds. Webrainsec joined HackenProof on August 12, 2025, and is known for approaching audits not just as code review, but as adversarial system modeling.

In this article, the team breaks down five real-world bug patterns that don’t look like bugs at all. These are issues that pass audits, survive code review, and sit quietly in production until a specific sequence of events triggers failure. Rather than revisiting textbook exploits, the piece explores deeper structural flaws — independent accumulators, multi-path state mismatches, mutable governance parameters, ratio manipulation surfaces, and arithmetic-driven denial of service. If you’re hunting on HackenProof or reviewing complex protocols, this article will sharpen your mental checklist and help you spot vulnerabilities most reviewers overlook.

🖊️ Editor’s note: The following section contains the original article written by the author and is published as submitted, with minimal editorial changes.

Foreword

Some of the most dangerous smart contract bugs don’t look like bugs at all. They pass code review, survive multiple audit rounds, and sit quietly in production until the right sequence of events triggers them. These aren’t the classic reentrancy or access control issues that every auditor checks for on day one. They’re subtler patterns where the code is locally correct but globally broken.

This article covers five patterns I’ve encountered during real protocol audits that consistently slip through review. Each one includes a minimal code example, an explanation of why it passes inspection, and the conditions that make it blow up. If you’re hunting bugs on HackenProof or any other platform, these patterns are worth adding to your mental checklist.

Pattern 1: Independent Accumulator Underflow

Protocols that track deposits, withdrawals, rewards, and penalties often use separate accumulator variables. Each accumulator increases independently through different operations. The exchange rate or total balance is then computed by combining them in a single formula.

What it looks like:

5 Solidity Patterns That Pass Code Review But Fail in Production

Why it passes review: Each accumulator is updated correctly in its own context. totalDeposited increases on deposit, totalWithdrawn increases on withdrawal, and so on. The formula looks like straightforward accounting. Reviewers verify each update site and move on.

How it breaks: The accumulators are independent. Nothing enforces that totalDeposited + totalRewards >= totalWithdrawn + totalPenalties at all times. Consider a liquid staking protocol where users deposit 100 ETH, one user withdraws 40 ETH, and then a validator slashing event adds 70 ETH to penalties. Now totalWithdrawn + totalPenalties = 110, but totalDeposited + totalRewards = 100. Under Solidity 0.8 checked arithmetic, the subtraction reverts with panic(0x11).

This isn’t a temporary glitch. The accumulators only increase, never decrease. Once the underflow condition is reached, every function that calls _getExchangeRate() permanently reverts. If that includes deposit(), withdraw(), and balance queries, the entire protocol is bricked with no recovery path.

How to catch it: Whenever you see a subtraction combining multiple independent accumulators, ask: “Is there any state where the right side exceeds the left?” Map out which operations increase each accumulator and check if any realistic sequence causes the subtraction to underflow. Pay special attention to penalty or slashing accumulators that can spike in a single transaction.

Detection shortcut: Search for a + b – c – d patterns in view functions. If any of those variables can increase without a corresponding increase on the other side, you probably have a bug.

Pattern 2: Multi-Path State Assumptions

Many contracts route the same logical operation through different execution paths depending on context. A deposit from a user might follow Path A, while a deposit triggered by internal rebalancing follows Path B. Both paths end at the same destination, but they start from different states. Bugs emerge when the code picks the wrong path for the current state.

What it looks like:

5 Solidity Patterns That Pass Code Review But Fail in Production

Now imagine a function that handles cancelled withdrawals. Cancelled funds sit on L2 (they were returned to the contract), so they need Path A. But the developer reasons, “this isn’t a user deposit, it’s an internal operation”, and routes through Path B:

5 Solidity Patterns That Pass Code Review But Fail in Production

Why it passes review: The function checks that funds exist on L2 (address(this).balance >= cancelledAmount). It zeroes the accounting variable. It calls a legitimate internal function. Each line is correct. The bug is in the choice of operation type, which requires understanding the preconditions of both paths.

How it breaks: Path B assumes funds are already on L1 and calls _stakeOnL1() directly. But the funds are on L2. The L1 staking call references an empty balance. The L2 funds stay locked in the contract forever, and the cancelledAmount is already zeroed, so there’s no way to retry. The contract has no rescueNativeToken() function because the developers never expected native tokens to get stuck.

How to catch it: For every function that selects between execution paths, verify that the preconditions of the chosen path match the actual state at the call site. Draw a table: “Path A assumes X, Path B assumes Y. At this call site, which is true?” The bug is always a mismatch between the assumed and actual states.

Pattern 3: Mutable Parameters in Invariant Checks

DeFi protocols use parameters like collateral factors, liquidation thresholds, and fee rates that admins can update through governance. These parameters often appear in formulas that determine whether a user’s position is healthy. The problem: when the formula uses the current parameter value, an admin update retroactively changes the health of every existing position.

What it looks like:

5 Solidity Patterns That Pass Code Review But Fail in Production

Why it passes review: The liquidation check is textbook. The admin setter has proper access control. Both functions are individually correct. Reviewers verify the math, confirm the onlyOwner modifier, and check that the parameter is within bounds.

How it breaks: A user opens a position at 80% LTV when liquidationThreshold is 85%. They’re safely within bounds. Governance passes a proposal to lower the threshold to 75%. The moment the transaction executes, that user’s position becomes liquidatable without any market movement or user action. Every position between 75% and 85% LTV is instantly underwater.

This isn’t hypothetical. The user entered the position based on the rules at the time. The rules changed underneath them. In leveraged protocols, the mass liquidation cascade amplifies the damage because forced selling pushes prices down, which makes more positions liquidatable.

How to catch it: Find every admin-settable parameter. Trace it to every formula where it’s used. Ask: “If this parameter changes after users have committed capital, who gets hurt?” If the answer is “existing position holders,” the protocol needs either a timelock, a snapshot mechanism, or a gradual migration path.

Pattern 4: Ratio Inflation via Permissionless Donation

Vault-style contracts (ERC4626, staking pools, yield aggregators) compute share prices as a ratio: totalAssets / totalShares. When the contract uses its own token balance as totalAssets, anyone can inflate the ratio by sending tokens directly to the contract.

What it looks like:

5 Solidity Patterns That Pass Code Review But Fail in Production

Why it passes review: The math is correct. convertToAssets properly handles the zero-supply case. totalAssets reads the actual balance. Looks clean.

How it breaks (the classic attack): An attacker is the first depositor. They deposit 1 wei of tokens and receive 1 share. They then donate 100 ETH directly to the vault (not through deposit(), just a plain transfer). Now totalAssets = 100 ETH + 1 wei but totalSupply = 1 share. When the next user deposits 99 ETH, they get 99e18 * 1 / (100e18 + 1) = 0 shares due to rounding. The attacker redeems their 1 share for the entire vault balance.

But the real pattern is broader. Even without the first-depositor setup, any protocol where totalAssets can be externally inflated without a proportional share increase has a manipulation surface. Oracle-free lending pools, rebasing token wrappers, and fee distribution contracts all share this pattern.

How to catch it: Check whether totalAssets() (or its equivalent) uses balanceOf(address(this)). If yes, check whether anyone can increase that balance without going through the deposit function. Common vectors: direct token transfer, selfdestruct (for ETH), rebasing token mechanics, and fee-on-transfer token surpluses. The fix is usually virtual accounting (track deposits in a state variable) instead of reading live balance.

Important nuance for bounty hunters: Not every balance-based ratio is exploitable. If the deposit function handles direct transfers gracefully (e.g., receive() routes to deposit()), or if the protocol uses a dead shares mechanism (minting initial shares to address(0)), the classic attack doesn’t work. Verify the actual attack path before submitting.

Pattern 5: Checked Arithmetic as a DoS Vector

Solidity 0.8 introduced checked arithmetic by default. Overflow and underflow now revert rather than wrap. This eliminated an entire class of exploitation bugs. But it created a new class: denial-of-service attacks through unexpected reverts.

What it looks like:

5 Solidity Patterns That Pass Code Review But Fail in Production

Why it passes review: This is the standard MasterChef reward pattern, used across hundreds of protocols. The math tracks how much each user has already been paid and computes the difference. Under Solidity 0.5/0.6, reviewers would check for overflow. Under 0.8, they assume the compiler handles it and focus on other things.

How it breaks: If rewardDebt[user] is ever greater than (userStake[user] * rewardPerToken) / PRECISION, the subtraction reverts. This can happen through rounding: if a user’s debt was set using a slightly different rewardPerToken than the current value (due to a reward update between their last action and now), the debt can exceed the current calculation by 1 wei. The function permanently reverts for that user.

A single wei of rounding error doesn’t sound like much. But when claimRewards is called inside withdraw() (which is common), the user can never withdraw their stake. Their funds are locked because a view-level rounding discrepancy causes a state-changing function to revert.

How to catch it: Search for subtraction in reward, fee, or interest calculations. Ask: “Can the subtracted value ever be larger than the value it’s subtracted from, even by 1 wei?” Common triggers include rounding differences between calculations at deposit time and at claim time, accumulator updates between user actions, and precision loss during intermediate multiplications.

The broader lesson: Checked arithmetic means every subtraction is a potential revert. In Solidity 0.5, a – b where b > a would silently produce a wrong number. In 0.8, it bricks the function. Neither outcome is good, but the DoS is often worse because the wrong number might still allow the user to withdraw with a small loss, while the revert locks their funds entirely.

Putting It Together

These five patterns share a common trait: they’re invisible at the function level. Each line of code is correct. The bug only appears when you consider the interaction between independent systems, the sequence of operations over time, or the gap between assumed and actual state.

To find them consistently:

  1. Map the accumulators. List every state variable that only increases. Check every formula that subtracts one from another.
  2. Trace the paths. When a function selects between execution paths, verify the preconditions match the actual state at every call site.
  3. Follow the parameters. For every admin-settable value, trace it to every formula and ask what happens to existing positions when it changes.
  4. Check the ratio. If a share price uses live balance, check if that balance can be inflated externally.
  5. Respect the revert. Every subtraction under Solidity 0.8 is a potential DoS. Check for rounding differences between write time and read time.

These checks take minutes per function and catch bugs that survive multiple audit rounds. Add them to your review process, and you’ll find issues that most reviewers miss.

Share article:
More topics:

Read more on HackenProof Blog