Library
Mastering Ethereum: Building Smart Contracts and DApps · 10 of 15
Mastering Ethereum: Building Smart Contracts and DApps
blockchain CRITICAL

Smart Contract Vulnerability Catalog

security vulnerabilities exploits solidity evm

Key Principle

Deployed contract code is immutable and runs in an adversarial public environment, so a single bug equals permanent, unrecoverable monetary loss. Every vulnerability below is a gap between what the developer assumes the EVM does and what it deterministically does. A contract "will execute exactly what is written, which is not always what the programmer intended." Defensive programming means coding to mechanism, not intent.

Why This Matters

All contracts are public, so any vulnerability is exploitable by anyone via a transaction, and "losses are almost always impossible to recover." Irreversibility + a public attack surface is the motivation for the entire antipattern catalog.

Good Examples

Each entry: mechanism → real exploit → fix.

1. Reentrancy (the DAO)

  • Mechanism: The EVM can call arbitrary external contracts, handing control flow to an attacker mid-execution. When a contract sends ether before updating its own state, the recipient's fallback can re-invoke ("reenter") the withdrawal function while the stale balance check still passes — looping to drain the balance in one transaction. The bug is purely statement ordering. In EtherStore.sol (Example 9-1) line 17 sends (msg.sender.call.value(_weiToWithdraw)()) before lines 18-19 decrement the balance. Attacker fallback re-calls withdrawFunds while etherStore.balance > 1 ether.
  • Exploit: The DAO drained >$150M; fallout forced the contentious hard fork that split Ethereum and created Ethereum Classic (ETC).
  • Fix (three, structural first): (1) Checks-Effects-Interactions — make external calls the last operation; decrement before sending so the reentrant call fails require(balances >= _weiToWithdraw). (2) transfer 2300-gas stipend blocks reentry [DATED 2018: avoid relying on the 2300-gas stipend; EIP-1884/later gas repricings made stipend-gated patterns fragile — use call{value:}() plus checks-effects-interactions and/or a reentrancy guard]. (3) Mutex — boolean set before the call, require(!mutex) at entry.

2. Arithmetic Over/Underflow (SafeMath)

  • Mechanism: EVM integers are fixed-size and wrap silently (no revert). A uint8 holds [0,255]; storing 256 → 0; subtracting 1 from 0 → 255. Any unchecked arithmetic on user input is an exploit vector. TimeLock (Example 9-3): a public lockTime lets an attacker call increaseLockTime(2^256 - userLockTime) to overflow it to 0 and withdraw() immediately. Token underflow (Example 9-4): require(balances[msg.sender] - _value >= 0) is always true for uint256 — it underflows to a huge positive, so a zero-balance user mints free tokens.
  • Exploit: BeautyChain (BEC) ERC20 (April 2018) — overflow minted over 57 undecillion tokens; PoWHC Ponzi lost 866 ether via underflow; batchTransfer() overflow = CVE-2018-10299.
  • Fix: Route every op through SafeMath (mul: assert(c/a == b); sub: assert(b <= a); add: assert(c >= a); div is exempt — can't over/underflow, EVM reverts on /0). Partial adoption leaves gaps; only routed ops are protected. [DATED 2018: superseded by Solidity ≥0.8.0 — over/underflow checks built in and revert by default; manual SafeMath unnecessary except inside explicit unchecked {} blocks or older pragmas.]

3. Unexpected / Forced Ether (selfdestruct, this.balance invariant)

  • Mechanism: Ether can land in a contract without executing any code — not even the fallback, so "all incoming ether passed through my payable function" is false. Vector 1: selfdestruct(target) removes bytecode and forces all stored ether onto any address with no defensive hook. Vector 2: pre-sent ether — contract addresses are deterministic (address = sha3(rlp.encode([account_address,transaction_nonce]))), so anyone can pre-fund a future address before its constructor runs. "The smoking gun for this vulnerability is the (incorrect) use of this.balance."
  • Exploit: EtherGame (Example 9-5) reads this.balance against milestones (3/5/10 ether). Force 0.1 ether via selfdestruct → balance never a multiple of 0.5 → milestones unreachable (DoS). Force ≥10 ether → claimReward() always reverts → rewards locked forever.
  • Fix: Track deposits in a depositedWei variable incremented only inside payable functions; never key logic on this.balance. Use invariant checking with real invariants (totalSupply of a fixed-issuance ERC20), not false ones (this.balance). [DATED 2018: selfdestruct largely neutered by EIP-6780 (2024 Dencun), restricting it to same-transaction-created contracts.]

4. DELEGATECALL Storage-Slot Collisions (Parity)

  • Mechanism: DELEGATECALL runs library code in the caller's context (msg.sender/msg.value unchanged), against the caller's storage — but "state-preserving" means storage slots, not variable names. Solidity assigns state vars to slots sequentially in declaration order. If the library's slot model differs from the caller's, library writes hit whatever the caller put there — including the slot holding the library's own address, redirecting all future delegated execution to attacker code.
  • Exploit: FibonacciBalance/FibonacciLib (Examples 9-6/9-7): caller slot[0]=fibonacciLibrary, slot[1]=calculatedFibNumber; library setStart thinks it writes its slot[0] start but overwrites the caller's library address. Attacker calls setStart(int('<attack_addr>',16)) to swap in a draining contract. Second Parity Multisig hack: WalletLibrary was itself stateful; a user called initWallet directly, became owner, called kill/suicide(_to), and every Parity multisig referencing it was bricked — all ether "permanently unrecoverable." Lib: 0x863DF6BFa4469f3ead0bE8f9F2AAE51c91A907b4.
  • Fix: Declare reusable code with the library keyword (not contract) — removes persistent storage and forbids selfdestruct. "whenever possible build stateless libraries."

5. Default Function Visibility → public (First Parity Multisig, ~$31M)

  • Mechanism: Solidity functions with no specifier defaulted to public. A forgotten specifier exposes privileged helpers (initializers, internal send helpers) externally.
  • Exploit: First Parity Multisig (~$31M): WalletLibrary initializers initWallet/initMultiowned set the owners but specified no visibility, so anyone could re-run them on funded wallets, become owner, and drain. (Also HashForEther: implicitly-public _sendWinnings() bypasses the guard in withdrawWinnings.)
  • Fix: "always specify the visibility of all functions in a contract, even if they are intentionally public." Modern solc warns on missing visibility.

6. Entropy Illusion (miner-manipulable block vars)

  • Mechanism: Every transaction is a deterministic state transition, so there is no on-chain entropy. Block variables (next block hash/timestamp/number) are chosen by whoever mines that block, and are identical for every tx in a block (fire many max-bet txs to multiply one outcome).
  • Exploit: A roulette paying "black" on even blockhash — a miner bets $1M, and if their solved block hashes odd, they withhold and re-mine until even, profitable whenever forgone block reward + fees < the payout. Arseny Reutov surveyed 3,649 live PRNG contracts, found 43 exploitable.
  • Fix: Entropy must come from outside the chain — commit-reveal among peers, RANDAO, or a centralized randomness oracle. [DATED 2018: on-chain pseudo-randomness now reads block.prevrandao (beacon-chain RANDAO mix, post-Merge 2022); still proposer-biasable and NOT safe for high-value randomness — external VRF oracles still required.]

7. External Contract Referencing (address-cast to malicious code)

  • Mechanism: "In Solidity, any address can be cast to a contract, regardless of whether the code at the address represents the contract type being cast." A contract running a library reference set at deploy time runs whatever code lives at that address — not the audited source. Audit-of-source ≠ audit-of-runtime.
  • Exploit: EncryptionContract (Example 9-8) stores a non-public encryptionLibrary set in the constructor; the deployer can point it at Rot26Encryption (no-op → plaintext leaked), a Print contract, or a contract lacking the selector (its fallback runs arbitrary code). The honey-pot inversion: Private_Bank looks reentrancy-vulnerable but its constructor-supplied TransferLog traps the would-be exploiter (one reddit user lost 1 ether).
  • Fix: (1) Instantiate with new at deploy time. (2) Hardcode addresses. (3) Make external addresses public so users can inspect them (a private address "can be a sign of someone behaving maliciously"). (4) If changeable, add time-lock and/or voting. Trade-off: new/hardcoding kill upgradeability.

8. Short Address Attack (ABI under-length padding)

  • Mechanism: ABI encoding right-pads short parameters with zeros at the end. Submit an address one byte short and the dropped byte shifts the trailing uint256 left, multiplying it ×256 per missing byte. Targets the off-chain application (e.g. an exchange), not the contract.
  • Exploit: transfer(address,uint) of 100 REP to 0x…deadde (1 byte short): EVM appends trailing 00, address reads 0x…deadde00, value reads 56bc75e2d6310000000 = 25600 tokens (100×256). Exchange thinks 100 withdrawn; 25600 leave.
  • Fix: Validate input length in the off-chain app before sending (the real fix). Ordering value before address can blunt some forms. "the EVM will add zeros to the end of the encoded parameters to make up the expected length."

9. Unchecked CALL/send Return Values

  • Mechanism: send and the CALL opcode return a Boolean and do not revert on failure; state after the call proceeds as if it succeeded. A failed payout that still flips a "paid" flag converts a delivery failure into theft.
  • Exploit: Lotto.sendToWinner()winner.send(winAmount) return ignored, then payedOut = true flips even on failure; now withdrawLeftOver's require(payedOut) passes and anyone drains the winner's funds. Etherpot Lotto.sol (Example 9-9) marks isCashed = true regardless of send result; King of the Ether is the same bug.
  • Fix: Check the Boolean (require(winner.send(...))), or use transfer (reverts on failure), or the withdrawal pattern so one failed transfer corrupts only that user's state.

10. Race Conditions / Front-Running

  • Mechanism: A public mempool + miner-controlled, fee-ordered inclusion. An attacker reads a pending profitable tx, copies its data, resubmits with higher gasPrice, lands first. Miners are the worst adversary — "uniquely incentivized to run the attacks themselves (or can be bribed)."
  • Exploit: FindThisHash.sol (Example 9-10) pays 1,000 ether for a SHA-3 preimage; the attacker copies the visible solve("Ethereum!") and outbids gasPrice — the honest user "will get nothing." ERC20 approve race: Alice resets Bob's allowance 100→50; Bob front-runs transferFrom(100), then spends the new 50 = 150 total. Bancor's on-chain price curve is arbitraged the same way.
  • Fix: Cap gasPrice (stops users, not miners); commit-reveal (hides contents, stops both classes; can't conceal value — ENS over-sends and refunds); submarine sends conceal value [DATED 2018: CREATE2 shipped in Constantinople (2019), submarine sends now implementable]. For approve: set allowance to 0 first, or use increaseAllowance/decreaseAllowance. [DATED 2018: PoW miners → PoS proposers (Merge 2022); front-running now formalized as MEV with PBS; gasPrice ordering is now EIP-1559 priority-fee ordering — worse, not gone.]

11. tx.origin Authentication

  • Mechanism: tx.origin is the address at the bottom of the call stack, not the immediate caller. Authenticating with it means any contract the victim calls inherits the victim's authority.
  • Exploit: Phishable.sol (Example 9-13): owner sends a tx to an attacker contract whose fallback calls withdrawAll(attacker); require(tx.origin == owner) still passes (origin is the owner, msg.sender is the attacker) → funds drain. Works because public contract source is not visible by default.
  • Fix: Authorize on msg.sender, never tx.origin. Sole legitimate use: require(tx.origin == msg.sender) restricts to direct EOA calls.

12. Misnamed Constructor (Rubixi)

  • Mechanism: Before Solidity 0.4.22 a constructor was a function whose name matched the contract name (exact string match). Rename the contract without renaming the function and the "constructor" silently becomes an ordinary callable public function running privileged init.
  • Exploit: OwnerWallet with lowercase ownerWallet — "any user can call the ownerWallet function, set themselves as the owner, and then take all the ether." Rubixi: renamed from DynamicPyramid without renaming its constructor, letting any user become "creator" and claim fees.
  • Fix: Use the constructor keyword. [DATED 2018: constructor keyword mandatory as of Solidity 0.5; named-function constructors no longer compile — this class cannot occur in modern code.]

13. Uninitialized Storage Pointers

  • Mechanism: Complex local variables (structs, arrays) default their data location to storage, not memory. An uninitialized local storage reference aliases slot[0] — clobbering whatever state variable is declared first, bypassing guards.
  • Exploit: NameRegistrar (Example): unlocked→slot[0]; NameRecord newRecord; (uninitialized) aliases slot[0], so newRecord.name = _name writes _name straight into unlocked. Input 0x…0001 flips the permanent lock to true. Weaponized as honey pots (OpenAddressLottery, CryptoRoulette).
  • Fix: Always explicitly initialize local struct/array vars or mark them memory; treat the compiler warning as an error (Mist 0.10 refused to compile). [DATED 2018: hard compile error since Solidity 0.5; this class no longer compiles.]

14. Precision / Truncation Errors

  • Mechanism: "As of this writing (v0.4.24), Solidity does not support fixed-point and floating-point numbers." Integer division truncates toward zero; when divisor > dividend the result is 0, and a later multiply yields 0 — silently (no revert). Solidity "guarantees to perform operations in the order in which they are written," so divide-first destroys precision.
  • Exploit: FunWithNumbers: uint tokens = msg.value/weiPerEth*tokensPerEth → 200 wei buys 0 tokens; selling 29 tokens → 2 ether (rounds down), breaking finer ERC20 decimals.
  • Fix: (1) Invert/enlarge the ratio (store weiPerToken, large). (2) Multiply before dividing (msg.value*tokensPerEth/weiPerEth). (3) High-precision uint256 intermediates, scale down only at the boundary (the rationale behind ERC20 decimals). Ref: DS-Math ("wads"/"rays").

Bonus: Block Timestamp Manipulation

  • Mechanism: block.timestamp (now) is set by the sealing miner, bounded only by monotonicity and a near-future limit. Inside that window the miner picks the value.
  • Exploit: Roulette.sol (Example 9-11) wins if now % 15 == 0 — a miner-player seals a block with that timestamp. GovernMental Ponzi: a miner set a future timestamp to falsely qualify as last player.
  • Fix: For elapsed time use block.number × avg block time ("1 week ≈ 60480 blocks" at 10s blocks; BAT ICO used this). For randomness use commit-reveal. [DATED 2018: post-Merge timestamps set by the slot proposer, tightly bounded to ~12s slots — window narrowed not eliminated.]

Counterpoints

  • Several classes are dead in modern code: misnamed constructors (Solidity 0.5 keyword), uninitialized storage pointers (0.5 compile error), arithmetic over/underflow (0.8 built-in checks). The underlying lesson — never rely on conventions the compiler doesn't enforce — stands.
  • The transfer/send 2300-gas stipend, once the recommended reentrancy defense, is now fragile after gas repricings; favor call{value:}() + checks-effects-interactions + reentrancy guard.

Key Quotes

"Smart contract code is unforgiving. Every bug can lead to monetary loss." — Antonopoulos & Wood, Chapter 9

"It is good practice for any code that performs external calls to unknown addresses to be the last operation… known as the checks-effects-interactions pattern." — Antonopoulos & Wood, Chapter 9

"The smoking gun for this vulnerability is the (incorrect) use of this.balance." — Antonopoulos & Wood, Chapter 9

"All transactions on the Ethereum blockchain are deterministic state transition operations... there is no source of entropy or randomness in Ethereum." — Antonopoulos & Wood, Chapter 9

Rules of Thumb

  • External calls last; update state first (checks-effects-interactions).
  • Never key logic on this.balance — track deposits yourself.
  • Always specify function visibility explicitly.
  • Always check send/call return values; prefer the withdrawal pattern.
  • Authorize on msg.sender, never tx.origin.
  • No on-chain randomness — import entropy via commit-reveal/RANDAO/oracle/VRF.
  • Build stateless libraries; never share a self-destructable stateful library.
  • Multiply before dividing; work in smallest units internally.
  • Validate input length off-chain before constructing transactions.
  • Treat compiler warnings as errors.

Related References

Diagram

Diagram