All posts
·17 min·Alex Werner

What to look for when securing Solidity code

There is no shortage of Solidity security checklists. The SWC Registry, the OWASP Smart Contract Top 10, the ConsenSys best practices, the OpenZeppelin docs, the Trail of Bits guides, Secureum, Sigma Prime. They are all good. They are also fragmented, and most of them describe the bug without telling you which of those bugs actually emptied a treasury last quarter.

So we did the merge. We took the 78 incidents in our own Web3 threat feed - each one a real, sourced, post-mortemed loss - and lined them up against the standard guides. This post is the result: a single list of what to look for when securing Solidity code, with the real hacks that prove each point and the concrete fix. Read it once and you have the map.

Read the data first: the money moved from code to keys

Before the checklist, the most important shift to understand. The vulnerabilities that define Solidity security in tutorials - reentrancy, flash-loan oracle games - are no longer where most of the money goes.

  • Access-control and key-compromise failures are now the single largest dollar cause of crypto theft. OWASP ranks Access Control #1 in its 2025 Smart Contract Top 10, attributing roughly $953M of 2024 losses to it. Chainalysis put private-key compromise at ~44% of all funds stolen in 2024. These are rare incidents with enormous average severity.
  • The classic contract bugs are largely solved. Immunefi's six-year dataset shows flash-loan attacks falling from 54% of DeFi losses in 2020 to under 1% in 2025, and reentrancy following the same curve. Better tooling, audited libraries, and Solidity 0.8 did their job.
  • A few mega-incidents carry the totals. Bybit (~$1.5B, Feb 2025) was ~44% of the entire year's stolen value on its own, and it was not a Solidity bug - it was a compromised signing workflow. Ronin ($625M) was spear-phished validator keys. Poly Network ($611M) was a privileged-function flaw.
  • The residual code risk is your own business logic. With generic patterns handled, the long tail that remains is application-specific accounting and logic - the bugs no checklist can enumerate because they only exist in your protocol.

The takeaway for how you spend effort: don't ship the classic bugs (they are cheap to avoid and still fatal the one time you miss), harden the trust boundary around the code (that is where most of the money actually leaves), and invariant-test your own logic (that is the frontier). The checklist below is ordered with that in mind.

1. Access control and privileged functions

The number-one dollar cause. Any function that can move funds, mint, pause, upgrade, or change a parameter is a privileged function, and every one of them needs an explicit, correct authorization check.

What goes wrong, with receipts:

  • A missing or wrong check. Poly Network let the cross-chain manager call a privileged keeper-setter it happened to own; the attacker brute-forced a function name whose 4-byte selector collided with it and replaced every bridge key. $611M.
  • An over-privileged or dormant key. Gala Games had a proper MINTER role - the failure was a compromised, dormant minter account that minted 5B tokens. PlayDapp was the same shape: a phished deployer key calling addMinter.
  • tx.origin for authentication. A contract that checks require(tx.origin == owner) can be phished through a malicious intermediary contract - see the tx.origin class.
  • An unprotected privileged setter. A price or rate setter left reachable is the same bug as an open mint. KiloEx (~$7.5M across four chains, Apr 2025) exposed its oracle setPrices through a publicly callable meta-transaction forwarder, so the attacker wrote an arbitrary price and traded against it. Vow had no timelock on its USD-rate setter, so when an admin briefly flipped the rate to 100x while "testing", an MEV bot minted ~148M vUSD inside two blocks.

What to look for / how to fix (SWC-105, SWC-106, SWC-115; OWASP SC01:2025):

  • Authorize on msg.sender, never tx.origin.
  • Use audited primitives: OpenZeppelin Ownable / AccessControl (role-based), and Ownable2Step so a fat-fingered ownership transfer can't brick the contract.
  • Put the dangerous actions - mint, upgrade, selfdestruct, parameter changes - behind a multisig and a timelock, not a single EOA. Apply least privilege: the fewest roles, held by the fewest keys.
  • Add monitoring and a tested pause path so an abused privilege is caught in minutes, not days.

2. Initialization and proxy upgrades

Upgradeable contracts add two failure modes that flat contracts don't have, and both have drained nine figures.

  • Unprotected / re-callable initializer. In a proxy, the logic contract's constructor never runs in the proxy's context, so initialization moves to an initialize() function - and if that function is callable a second time, an attacker calls it to seize ownership. The original Parity wallet freeze was exactly this: an unprotected initWallet on a shared library, followed by selfdestruct.
  • Storage-layout collision. A delegatecall proxy runs logic against the proxy's storage, so the two must agree on every slot. Audius added a proxy variable that collided with the implementation's initialized flag, re-opening the initializer and letting an attacker pass a malicious governance proposal. Munchables was a rogue insider pre-seeding storage that survived the upgrade.

What to look for / how to fix (SWC-118; OWASP 2026 Proxy/Upgradeability; OpenZeppelin Upgrades):

// Lock the implementation so its initializer can never be called on the logic contract itself.
constructor() {
    _disableInitializers();
}

function initialize(address owner_) external initializer {  // OZ Initializable: runs exactly once
    __Ownable_init(owner_);
}
  • Guard every initializer with OpenZeppelin Initializable (initializer / reinitializer) and call _disableInitializers() in the implementation constructor.
  • Preserve storage layout across upgrades: append-only variables, storage gaps or ERC-7201 namespaced storage, and run an automated storage-layout diff (openzeppelin-upgrades) on every upgrade.
  • Never put selfdestruct or an untrusted delegatecall in an upgradeable contract.

3. Reentrancy (all four kinds)

The oldest bug in the book, and still live because it has grown variants. The rule is one sentence: finish all your state changes before you make an external call.

  • Classic - The DAO sent ether before zeroing the balance; the recipient's fallback re-entered and withdrew repeatedly.
  • ERC-777 / callback-token reentrancy - dForce / Lendf.me and Grim Finance: a token transfer fires a hook into attacker code mid-update.
  • Cross-function / cross-contract - Rari / Fei re-entered the Comptroller's exitMarket from a CEther transfer; a single-function guard wouldn't have helped. Penpie re-entered a different function that shared a lock the guarded one didn't.
  • Read-only reentrancy - Conic: an unguarded view used as a price source returned a corrupted value while a Curve pool was mid-remove_liquidity.

What to look for / how to fix (SWC-107; OWASP SC05:2025; ConsenSys; OZ ReentrancyGuard):

function withdraw(uint256 amount) external nonReentrant {
    require(balances[msg.sender] >= amount, "insufficient");
    balances[msg.sender] -= amount;            // effects first
    (bool ok, ) = msg.sender.call{value: amount}("");  // interaction last
    require(ok, "transfer failed");
}
  • Apply checks-effects-interactions everywhere, and a nonReentrant mutex on state-changing entry points - the guard must cover every function that shares the state, not just one.
  • Treat ERC-777/677/1363 transfer hooks as untrusted reentry points; prefer pull-over-push withdrawals.
  • Before reading an external view as an oracle, trigger the source's own reentrancy lock first.

4. Oracle and price manipulation (and flash loans)

If your contract reads a price, ask one question: can someone move that price inside the same transaction? If the answer is yes, it's not an oracle, it's an attack surface. Flash loans make the answer yes for almost any single-pool spot price.

  • Spot price off a single pool - bZx (the first famous one), Harvest, PancakeBunny, Cream: a flash loan skews a Uniswap/Curve pool, the protocol reads getReserves() / balanceOf(), and over-values the attacker's collateral.
  • A manipulable TWAP window - Inverse Finance: a TWAP whose update gate fell back to spot price on a thin pool.
  • A custom feed reading raw AMM state - UwU Lend: an oracle taking the median of pool spot reads, and Mango: perp PnL valued off a thin spot market.

What to look for / how to fix (OWASP SC02 & SC07:2025; ConsenSys Oracle Manipulation):

  • Price assets with a manipulation-resistant oracle - Chainlink aggregation, a median of independent sources, or a TWAP over a sufficiently long window. Never a single pool's spot getReserves(), balanceOf(), or get_p().
  • Check oracle staleness/heartbeat and bound deviation; reject prices read inside a flash-loan-atomic call.
  • Cap how much unrealized/volatile value can count as collateral, and stress-test every price read against an attacker who controls the pool for one block.

5. Arithmetic: overflow, rounding, and AMM invariants

Three distinct failure modes that all reduce to "the math was wrong."

  • Overflow/underflow. Pre-0.8 this was endemic - BEC's batchTransfer multiplication wrapped to zero and passed the balance check. It still happens in unchecked blocks and non-Solidity VMs: Cetus (a wrong overflow mask in a Move library, $223M) and Velocore (a fee term that underflowed and wrapped).
  • Rounding / precision and the ERC-4626 inflation attack. Integer division truncates. The first-depositor / donation attack weaponizes it: seed an empty vault with 1 wei, donate to inflate price-per-share, and later depositors round down to zero shares. Real losses: Sonne, Onyx, zkLend - all empty-market exchange-rate manipulations. And rounding bugs aren't confined to vaults: Bunni (~$8.4M, Sep 2025) rounded an "idle balance" the wrong way in its Uniswap-v4 withdrawal path, and a flash-loan-funded sequence of swaps across tick boundaries triggered the error until the pool leaked - after which the protocol shut down.
  • Broken AMM/accounting invariants. Uranium shipped a k-invariant with a 10000 where 1000 belonged; Spartan computed LP payout from live balanceOf instead of cached reserves; KyberSwap double-counted liquidity at an exact tick boundary.

What to look for / how to fix (SWC-101; OWASP SC08:2025):

  • Use Solidity ≥ 0.8 checked arithmetic; reach for unchecked{} only where overflow is provably impossible. On non-EVM VMs, verify the math library's overflow behavior yourself.
  • Multiply before divide; never assume 18 decimals; round in the protocol's favour and reject zero-share mints.
  • Defend vaults with virtual shares / a decimals offset (OZ ERC4626) or a protocol-seeded initial deposit; track balances with internal accounting, never raw balanceOf that a donation can inflate.
  • Unit- and invariant-test every fee, share, and reserve formula at boundary inputs.

6. External calls, unchecked returns, and approvals

The EVM does not throw when a low-level call fails - it returns false. Ignore that and you process a failed transfer as a success.

  • Unchecked return value - the canonical King of the Ether bug: a send() returned false, the code ignored it, and state advanced anyway.
  • Arbitrary external call with attacker-controlled target/calldata - Li.Fi and Hedgey let callers pass a target the contract then called, weaponizing the router's own approvals to run transferFrom against victims.
  • Unvalidated trust in a supplied address - Pickle accepted a fake "jar" with the right interface.

What to look for / how to fix (SWC-104, SWC-124; OWASP SC06:2025; OZ SafeERC20/Address):

  • Check every return value. Wrap token calls in SafeERC20; wrap raw calls in Address.functionCall.
  • Allowlist call targets and selectors. Never let a user supply an arbitrary target+calldata that runs with your contract's permissions, and never let from in a transferFrom be attacker-chosen.
  • Interface conformance is not trust - validate addresses against a registry of contracts you deployed.

7. Signatures, replay, and proof verification

Anywhere you verify a signature or a proof off the happy path, the details decide whether it's secure.

  • Forged/under-verified proofs - Wormhole trusted a caller-supplied account instead of checking it against the real sysvar; BNB Chain accepted a malformed IAVL Merkle proof. Both minted unbacked assets.
  • Signature/nonce reuse - AnySwap reused an ECDSA nonce across two transactions, leaking the private key.
  • Cross-chain / missing-context replay - the signature-replay class: a signature valid once, replayed because it carried no chainId/nonce/domain.

What to look for / how to fix (SWC-117, SWC-121, SWC-122; OZ ECDSA):

  • Bind every signed message with EIP-712 typed data + a domain separator (name, version, chainId, verifyingContract) + a per-signer nonce, and mark signatures single-use.
  • Enforce low-s and a valid v, and reject address(0) from ecrecover (use OZ ECDSA).
  • Verify proofs and external accounts by address/structure, not by position or shape; fuzz the verifier against malformed inputs.

8. Governance

If voting power can be acquired and used in the same transaction, governance is a flash loan away from being yours. Beanstalk lost $182M when an attacker flash-borrowed a supermajority of votes and executed a malicious proposal in one transaction.

  • Snapshot voting power at a past block (ERC20Votes / checkpoints), so it can't be flash-borrowed.
  • Put a timelock between proposal, vote, and execution, and forbid emergency paths from calling arbitrary fund-moving contracts.

9. Beyond the code: the trust boundary that actually loses the money

This is the uncomfortable half of the meta-analysis. The largest losses in our feed are not Solidity bugs at all - they are the infrastructure around a perfectly fine contract. If you ship a flawless protocol behind a compromised signer, you have the Bybit pattern.

  • Multisig blind-signing. Bybit (~$1.5B), Radiant, and WazirX all came down to hardware-wallet signers approving a delegatecall whose true effect their device didn't render. Mandate clear-signing, on-device calldata verification, and independent transaction simulation. Never blind-sign.
  • Frontend / supply-chain compromise. BadgerDAO (injected approval prompts) and Ledger Connect Kit (a poisoned npm package that drained dozens of dApps at once) never touched the contracts. This is not a one-off - it is the steady drip the data keeps surfacing. The DNS-hijack wave took Curve's domain twice (2022, then again in May 2025, forcing the move to curve.finance), redirected Compound through the July 2024 Squarespace registrar collapse, hit Ambient, and in April 2026 drained over $1M from CoW Swap users (219 ETH from a single wallet) after forged documents tricked its registrar into handing over cow.fi - every one by repointing a domain, never a contract. And in September 2025 a single npm compromise of chalk/debug (2B+ weekly downloads) shipped a wallet-address-swapping crypto clipper to anything that built against them. Use Subresource Integrity, pinned dependencies, signed releases, registrar/registry lock + DNSSEC + hardware-key 2FA, and consider content-addressed (ENS/IPFS) hosting.
  • The compiler is part of your trust boundary. Curve / Vyper: a specific compiler version mis-generated the reentrancy lock. Pin and verify your compiler version and rebuild deployed bytecode from audited source.
  • Protect your users from approval phishing. Drainer kits, permit-signature phishing, unlimited approvals, and address poisoning are where retail loses (~$494M in 2024, with permit signatures alone 57% of it). Request least-privilege exact-amount approvals, support revocation, and render human-readable transaction intent in your UI.

10. How to find the bugs no list can name

Everything above is what to look for. But the residual risk - the application-specific logic that now dominates losses - has no entry in any registry, because it only exists in your protocol. You cannot pattern-match a bug that has never been seen. You find it by how you read the code, not by which list you hold. The discipline that surfaces these bugs is one move repeated: read the code many times, each pass with a single mindset, and trust your discomfort over your first read.

Three tools to read a function honestly. The senior-auditor edge is not knowing more patterns; it is reading without trusting your first read.

  • Feynman. Explain what the function does in plain English, no Solidity words. The moment you reach for jargon - "it safeTransfers the fee" instead of "it takes the protocol's cut off the user's payment and moves it to the treasury" - you have papered over an assumption. That spot is where the bug is.
  • Socratic. For every line, ask: why is this here, what does it assume, what breaks if the assumption is false? Drill past the restatement. if (token != ETH) transferFrom(...) - why no else? Because ETH is assumed to arrive via msg.value. Where is msg.value == amount enforced? If the answer is "nowhere," you have it.
  • Inversion. Every clean path gets a backward pass. The developer asks "does this work?"; you ask "how do I make it not work?" Read each check and ask what value slips past it; read each state update and ask what state you are in just before it fires.

Then read the same code through different lenses - the bugs live at the seams. A single bug class rarely tells the whole story; the expensive ones sit where two views overlap:

  • Asymmetry. Paired operations must mirror: deposit/withdraw, mint/burn, lock/unlock, the native vs ERC-20 branch. The bug is not a wrong line - it is the update one side does and the other forgot. (This is the Penpie shape from section 3 and the rounding shape from section 5.)
  • Invariants. Write down what must always be true - total shares back total assets, the sum of balances equals the pool, debt never rounds in the user's favour - then hunt the path that violates it and extract value from the broken state.
  • Boundaries. Apply the same corner-case questions to every input and call site: zero, one, empty, max, first-time, the dust amount, the self-referential address.
  • Periphery. The unreviewed 20-line library, helper, or encoder that core contracts trust implicitly. One bug there compromises every caller - Cetus and Velocore (section 5) were both library math.
  • Gaps. The hardest bugs need two or three lenses at once: a rounding error (precision) that only breaks a conservation law (invariant) at an empty pool (boundary). No single-class scan sees it; re-read the seams on purpose.

And calibrate what counts as a finding - this is the difference between an audit and a vibe:

  • A finding is not real until you have traced the attack with concrete values - a number, a state sequence, a quoted line. No proof and it is a lead, not a finding. Leads are honest, not failures; write them down for follow-up instead of dropping them.
  • When you find a bug, deepen it - never argue yourself out of it. Chain it, find more victims, lower the cost of the precondition.
  • Weaponize every bug across the codebase. Native/ERC-20 confusion in one onRevert? Check every contract's onRevert. The same mistake is almost always copy-pasted.
  • Gate honestly. Does a guard actually interrupt the attack before harm? Can an unprivileged actor reach the vulnerable state and trigger it? Is the harm material to a real victim? "Admin can rug" with no unprivileged amplifier is not a finding.

How to actually apply this

A list is only useful if it runs continuously. The practices that move the needle:

  • Review in lenses, not in one pass - the manual counterpart to the section above: sweep the code once per mindset (asymmetry, invariants, boundaries, periphery, the seams), because a single read holding every bug class at once is the surface scan that misses the logic bugs.
  • Build on audited libraries (OpenZeppelin) and the latest stable, pinned compiler - you inherit reentrancy guards, safe math, and access control for free.
  • Static analysis in CI - Slither and Mythril on every PR, gating the high- and medium-confidence detectors.
  • Fuzzing and invariant testing - Foundry / Echidna against your protocol-specific accounting invariants. This is the only thing that catches the long-tail logic bugs that now dominate the residual risk.
  • Defense in depth - pausable circuit breakers, withdrawal rate limits, and timelocks on admin actions, so one mistake is survivable.
  • Independent audits, monitoring, a bug bounty, and an incident-response plan - and a hardened, segmented signing environment, because the data says that is where the money goes.

None of these is novel. What's novel is having them in one place, ranked by what actually causes loss, and tied to the specific incidents that prove each one - which is the whole point of keeping a threat feed in the first place.

This is also why I built Stateward to reason over the whole codebase rather than a single diff. Most of the classes above - a reentrancy guard that doesn't cover a sibling function, an oracle read that's safe until a new borrow path reaches it, an initializer left open after a refactor - are properties of how the pieces fit together, not of any one line. A reviewer that understands the system catches them. A scanner that reads a line does not. And every dependency you pull in gets checked against this same feed of real-world exploits, on every pull request.

Want this kind of review on your own pull requests?

Get started free