Back to Cheat Sheets

🧮 Logic Errors

OWASP Smart Contract Top 10 (2025) - SC03

HIGH RISK

📋 What Is It?

Logic Errors (business-logic flaws) occur when a contract compiles cleanly, passes its happy-path tests, and appears to "work" - yet its implemented behaviour diverges from its intended behaviour. There is no reentrancy and no missing modifier; the code simply computes the wrong thing. The bug lives in the specification-to-implementation gap, so compilers, linters, and single-bug scanners all stay silent. On a public chain those wrong rules are permanent and tied directly to money: every accounting slip, mis-rounded division, and unhandled edge case is a standing invitation.

SC03 OWASP Rank
Invisible To Tools
Invariants Key Defense

⚠️ Common Exploits

  • First-Depositor Inflation: ERC-4626 donation makes a victim mint zero shares
  • Rounding Leakage: Integer division truncates value to the user
  • Reward Double-Count: Checkpoint never advances; same period paid twice
  • Accounting Mismatch: Ledger credits requested, not received, amount
  • Off-by-One: Wrong comparison operator bypasses a cap or window
  • Order of Operations: Effects and interactions applied out of sequence
  • Token Assumptions: Fee-on-transfer / rebasing tokens corrupt accounting

🔴 Attack Flow

1. Read the on-chain math and accounting logic
↓
2. Find an unhandled edge case or wrong rounding
↓
3. Craft inputs (1 wei deposit + donation, repeat claim)
↓
4. Drive the contract into an unintended state
↓
5. IMPACT: Withdraw more than deposited - pool drained!

❌ Vulnerable Code

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; contract NaiveVault { IERC20 public asset; uint256 public totalShares; mapping(address => uint256) public shares; function deposit(uint256 amount) external returns (uint256 minted) { uint256 totalAssets = asset.balanceOf(address(this)); // BUG: empty-pool 1:1 rule + rate from raw balanceOf. Attacker // deposits 1 wei, then DONATES tokens directly to inflate // totalAssets so a victim's deposit rounds DOWN to 0 shares. minted = (totalShares == 0) ? amount : (amount * totalShares) / totalAssets; ← truncates → 0 totalShares += minted; shares[msg.sender] += minted; asset.transferFrom(msg.sender, address(this), amount); } function claim() external { // BUG: fixed 'start', no per-user checkpoint → same period // paid on EVERY call. Loop claim() to mint 'owed' repeatedly. uint256 owed = stake[msg.sender] * rate * (block.timestamp - start); rewardToken.mint(msg.sender, owed); } }

✅ Secure Code

// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; import {ERC4626} from "@openzeppelin/.../ERC4626.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; import {SafeERC20, IERC20} from "@openzeppelin/.../SafeERC20.sol"; // Extend audited ERC-4626 + virtual-shares offset: the empty-pool rate // is too expensive to manipulate by donation. OZ rounds mints DOWN and // redemption share-burns UP - always in the vault's favour. contract SafeVault is ERC4626 { using SafeERC20 for IERC20; mapping(address => uint256) public lastAccrued; function _decimalsOffset() internal pure override returns (uint8) { return 6; // virtual shares blunt the inflation attack } // Balance-delta accounting: credit only what actually arrived, // so fee-on-transfer / rebasing tokens cannot lie to the ledger. function depositRaw(uint256 amount) external { require(amount > 0, "zero amount"); uint256 before = asset.balanceOf(address(this)); IERC20(asset).safeTransferFrom(msg.sender, address(this), amount); uint256 received = asset.balanceOf(address(this)) - before; require(received > 0, "nothing received"); // ... credit `received`, not the requested `amount` ... } function _accrue(address u) internal { uint256 elapsed = block.timestamp - lastAccrued[u]; if (elapsed > 0 && stake[u] > 0) rewards[u] += (stake[u] * rate * elapsed) / 1e18; lastAccrued[u] = block.timestamp; // checkpoint ALWAYS advances } // mint: Math.mulDiv(a, b, c, Math.Rounding.Floor) - protocol's favour. }

✓ Prevention Checklist

  • Write every core invariant down, then assert it
  • Handle first-depositor/empty-pool (virtual shares, seed)
  • Reject zero and degenerate inputs explicitly
  • State rounding direction; always favour the protocol
  • Use Math.mulDiv with explicit Floor/Ceil
  • Credit balance-delta (received), not requested amount
  • Advance reward checkpoints every accrual
  • Support or reject fee-on-transfer / rebasing tokens
  • Test exact-boundary inputs; review < vs <=
  • Follow checks-effects-interactions ordering
  • Reuse audited standards (ERC-4626, SafeERC20)
  • Fuzz/invariant test; formally verify critical math

🔍 Detection & Tools

Analysis Tools:

Foundry Echidna Slither Mythril Manticore SMTChecker

Libraries & Verification:

OpenZeppelin ERC-4626 Certora Halmos Tenderly

How to Test:

  • Foundry invariant: assert totalAssets >= liabilities
  • Fuzz: deposit(x) then withdraw() never returns MORE than x
  • Assert reward invariant: total paid <= total emitted
  • Boundary tests for exactly-at-cap and one-past-cap inputs
  • Formally verify share/mint and interest-accrual math

🌍 Real-World

  • ERC-4626 vault inflation / donation class: A tokenised vault used empty-pool 1:1 math and read raw balances; an attacker deposited 1 wei, donated tokens directly to skew assets-per-share, and a later honest deposit rounded down to zero shares - absorbed by the attacker's single share.
  • Accounting-error drains: A protocol's internal ledger drifted from real holdings (crediting before verifying a transfer, or ignoring a return value); attackers withdrew the difference until the pool was empty.
  • Reward / emission miscalculation: A staking contract failed to advance its checkpoint or mis-scaled a fixed-point rate; the same accrual was claimed repeatedly, minting far beyond the emission schedule.
  • Common thread: The code was reentrancy-safe and overflow-safe, but the math was wrong - a divergence tools cannot see and only specified invariants catch.

📌 Quick Tips

  • DO NOT assume "compiles + tests pass" means correct
  • DO NOT round in the user's favour
  • DO NOT credit the requested amount blindly
  • DO write and assert invariants first
  • DO handle first-depositor and boundary cases
  • DO reuse audited ERC-4626 / SafeERC20
  • DO fuzz and formally verify critical math