A bug in a web application can be patched on Friday afternoon. A bug in a deployed smart contract is public, permanent unless you planned for upgrades, and often sitting next to a pile of tokens anyone can take. Most large losses do not come from exotic cryptography. They come from a small set of well known mistakes. Learning to spot them is the most valuable thing a Solidity developer can do before writing contracts that hold real value.
Reentrancy
When a contract sends ETH or calls another contract, the receiver gets control and can call back into the original contract before the first call finishes. If the balance is updated after the transfer, the attacker withdraws again and again from the same balance. This is the bug behind the 2016 DAO hack, and variants of it still appear, including read-only reentrancy where another protocol reads a stale value in the middle of the call.
- Follow checks, effects, interactions: validate inputs, update state, and only then make external calls.
- Add OpenZeppelin ReentrancyGuard to functions that move funds.
- Prefer letting users withdraw their own funds over pushing payments to many addresses in one function.
Missing or Broken Access Control
A mint, pause, or upgrade function without an onlyOwner or role check can be called by anyone. Upgradeable contracts add a quieter version of the same bug: an initializer that was never called or never locked, which lets an attacker initialize the implementation and make themselves owner. Use OpenZeppelin Ownable or AccessControl, call _disableInitializers in the implementation constructor, and write a test for every privileged function that calls it from a random address and expects a revert.
Never authorize with tx.origin. It is the address that started the transaction, so a malicious contract the owner interacts with can pass the check. Use msg.sender.
Price Oracle Manipulation
A lending protocol that reads an asset price from the current reserves of a single DEX pool can be fooled within one transaction. The attacker borrows a large amount with a flash loan, moves the pool price, borrows against the inflated collateral, and repays the flash loan, all in the same block. Use a decentralized oracle such as Chainlink, or a time-weighted average price over a sensible window, and check that the price is fresh and within expected bounds.
Other Bugs Worth Knowing
| Vulnerability | What goes wrong | Prevention |
|---|---|---|
| Signature replay | A signed message is reused on another chain, another contract, or a second time | Use EIP-712 typed data with a nonce, chain ID, contract address, and deadline |
| Unchecked low-level call | call returns false on failure, and the code continues as if the transfer worked | Check the return value, and use SafeERC20 for tokens that do not return a boolean |
| Front-running | Others see the transaction in the mempool and act first, for example on a swap | Slippage limits, deadlines, and commit-reveal schemes where order matters |
| Unbounded loops | A loop over a growing array eventually needs more gas than a block allows, locking the function | Paginate the work or let each user process their own entry |
| Storage collision in upgrades | A new implementation reorders state variables and corrupts existing data | Only append variables, and run the OpenZeppelin upgrades plugin to validate layouts |
| Arithmetic in unchecked blocks | Overflow checks are skipped where a developer assumed the numbers could not overflow | Use unchecked only where the bound is proven, and keep precision by multiplying before dividing |
Solidity 0.8 and later revert on integer overflow by default, which removed a whole class of older bugs. Rounding is still a problem, especially in vaults and share calculations, where rounding in the user's favour can be exploited many times over.
Tools That Catch Bugs Early
- Slither, a static analyzer that flags reentrancy, unprotected functions, shadowed variables, and many other patterns in seconds. Run it in CI.
- Foundry fuzz tests, which call your functions with thousands of random inputs and find edge cases you would not write by hand.
- Invariant tests in Foundry or Echidna, which check properties that must always hold, such as total deposits equalling the sum of all balances.
- Mainnet fork tests, which run your contract against real deployed protocols and real prices.
- Battle-tested libraries such as OpenZeppelin Contracts for tokens, access control, and proxies, instead of writing your own.
A Release Process for Contracts That Hold Value
- 1Write a short specification of what each function may and may not do, and the invariants of the system.
- 2Reach high test coverage, including fuzz and invariant tests, before anyone outside the team reads the code.
- 3Run Slither and fix or document every finding.
- 4Freeze the code and get an external audit. Two independent audits are common for contracts holding significant funds.
- 5Deploy to a testnet such as Sepolia and run the full user flow, including admin actions.
- 6Put admin keys in a multisig wallet and add a timelock to upgrades, so users can see changes before they take effect.
- 7Launch with a bug bounty and monitoring on large transfers and privileged calls.
An audit is a second pair of eyes on code that should already be well tested. It does not replace the tests.
Our smart contract training covers these vulnerabilities by having participants exploit deliberately weak contracts first, then fix and test them with Foundry. Seeing an attack succeed makes the defensive habits stick much better than reading a checklist.
Key takeaways
- Update state before external calls, and add ReentrancyGuard to functions that move funds.
- Protect every privileged function with roles, lock initializers, and never authorize with tx.origin.
- Do not read prices from a single pool. Use Chainlink or a time-weighted price and check freshness.
- Use EIP-712 signatures with nonce, chain ID, and deadline to stop replay.
- Combine Slither, fuzz and invariant tests, an external audit, a multisig, and a bug bounty before holding real value.


