10 Faces of Blockchain Security (Part 1) — Smart Contract Code Vulnerabilities


10 Faces of Blockchain Security (Part 1)

Smart Contract Code Vulnerabilities

When a blockchain security incident hits the news, it gets compressed into a single line: “the code got hacked.” But in reality, blockchain failures span completely different layers — bugs in the code itself, flaws in economic design, limits of the consensus algorithm, and failures in off-chain operations. This series walks through ten cases that represent all four layers.

Part 1 covers the most intuitive layer: four vulnerabilities in the smart contract code itself. Each entry follows the same structure: vulnerable code → exploit flow → real-world case → defense pattern.


1. Reentrancy

The vulnerability

When a contract sends ETH to an external address, and that address is itself a contract, the recipient’s receive() or fallback() function executes. If the contract updates its state variables after the external call, an attacker can recursively call back into the same function from within that callback.

Vulnerable code

// Vulnerable: external call happens before the balance update
function withdraw() public {
    uint amount = balances[msg.sender];
    (bool ok, ) = msg.sender.call{value: amount}(""); // ① external call
    require(ok, "transfer failed");
    balances[msg.sender] = 0;                          // ② update comes later
}

Exploit flow

  1. The attacker contract calls withdraw().
  2. call executes, ETH is sent, and the attacker’s receive() is triggered.
  3. Since balances[attacker] is not yet zero, receive() calls withdraw() again.
  4. The balance check passes again, and steps 3–4 repeat until the contract’s funds are drained.
// Attacker contract
receive() external payable {
    if (address(target).balance >= 1 ether) {
        target.withdraw(); // recursive call
    }
}

Real case — The DAO (2016)

The most iconic incident in Ethereum’s history. Roughly 3.6 million ETH (about $50M at the time) was drained this way. The community executed a hard fork to reverse it. Those who opposed the fork — holding to “Code is Law” — stayed on the original chain, splitting it into Ethereum (ETH) and Ethereum Classic (ETC).

Defense patterns

  • Checks-Effects-Interactions: always order operations as checks → state changes → external calls.
  • OpenZeppelin ReentrancyGuard: the nonReentrant modifier locks out reentry entirely.
  • Pull over Push: don’t have the contract push funds; let each user pull their own withdrawal.
// Safe: update state first, external call last
function withdraw() public nonReentrant {
    uint amount = balances[msg.sender];
    balances[msg.sender] = 0;                          // ① update first
    (bool ok, ) = msg.sender.call{value: amount}("");  // ② call last
    require(ok, "transfer failed");
}

2. Integer Overflow / Underflow

The vulnerability

Before Solidity 0.8.0, arithmetic wrapped around silently. In uint256, 0 - 1 isn’t an error — it becomes the maximum value 2^256 - 1, and max + 1 wraps back to 0. Combined with a balance check, this is catastrophic.

Exploit flow — BeautyChain (BEC) token (2018)

BEC’s batchTransfer computed the total as uint256 amount = cnt * _value.

// Vulnerable: cnt * _value can overflow
function batchTransfer(address[] _receivers, uint256 _value) public {
    uint cnt = _receivers.length;
    uint256 amount = uint256(cnt) * _value;   // ← overflow point
    require(_value > 0 && balances[msg.sender] >= amount);
    balances[msg.sender] -= amount;
    for (uint i = 0; i < cnt; i++) {
        balances[_receivers[i]] += _value;    // huge value actually transferred
    }
}

By setting _value extremely high, the attacker makes cnt * _value overflow to nearly zero. The check balances[msg.sender] >= amount passes, yet each recipient still receives the astronomical _value, collapsing the token’s total supply.

Defense patterns

  • Solidity 0.8+: the compiler auto-reverts on overflow. This solves the problem in most cases.
  • Beware unchecked: if you use unchecked blocks for gas optimization, you must be able to prove the operation can never overflow.
  • Legacy code: for versions below 0.8, use OpenZeppelin SafeMath.

3. delegatecall and Storage Collisions

The vulnerability

delegatecall runs another contract’s code in the caller’s storage context. The logic comes from the implementation, but the state being read and written belongs to the proxy. It’s the core tool of upgradeable contracts — but if the proxy and implementation have mismatched storage layouts, variables overwrite the wrong slots.

Exploit flow

// Proxy: slot 0 = owner
contract Proxy {
    address public owner;             // slot 0
    // ... executes implementation logic via delegatecall
}

// Implementation: treats slot 0 as something else
contract Impl {
    uint256 public totalSupply;       // slot 0 ← collides with owner!
    function mint(uint256 v) public { totalSupply = v; }
}

Calling mint() through the proxy: the implementation thinks it’s writing totalSupply to slot 0, but it actually overwrites the proxy’s owner slot. An attacker calling mint(uint256(uint160(attacker))) can seize ownership.

Defense patterns

  • EIP-1967: store admin/implementation addresses in standardized keccak256-derived slots with effectively zero collision probability.
  • OpenZeppelin Upgradeable: use battle-tested proxy frameworks (TransparentUpgradeableProxy, UUPS).
  • Storage gaps: reserve uint256[50] private __gap; for future inheritance expansion.
  • Initialize on deploy: initialize the implementation immediately after deployment so no dangling state is left behind. (This connects directly to the next item.)

4. Logic Bugs — Verification Bypass (Nomad Bridge, 2022)

The vulnerability

Even syntactically perfect code fails when the verification logic itself is wrong — something compilers and even auditors easily miss. The most dangerous variant treats uninitialized or default (zero) values as “valid.”

Exploit flow — Nomad (~$190M loss)

During an upgrade, Nomad registered 0x00 (an empty value) as the trusted merkle root. The verification logic behaved roughly like this:

// Conceptual reconstruction: acceptableRoot(0x00) returns true
function process(bytes memory message) public {
    bytes32 root = computeRoot(message);
    require(acceptableRoot[root], "invalid root");
    // ...transfer funds
}

An unproven message’s root isn’t in the mapping, so it resolves to the default 0x00. Because acceptableRoot[0x00] was set to true, every unproven message passed verification.

What followed was even more dramatic. When one attacker crafted a successful withdrawal, hundreds of people simply copied that transaction and swapped in their own address and amount — a copy-paste looting spree that required no programming knowledge at all.

Defense patterns

  • Reject defaults: explicitly forbid zero values, e.g. require(root != bytes32(0)).
  • Audit initialization and upgrades: incidents cluster in migration/init code more than in core logic. Focus audit attention there.
  • Formal verification: for logic with clear invariants (like verification checks), prove correctness mathematically with tools like Certora or SMT solvers.
  • Multiple independent audits + bug bounties: let one team catch what another missed.

Part 1 Summary

#VulnerabilityRoot causeNotable case
1ReentrancyExternal call before state updateThe DAO
2Integer overflowWrap-around arithmeticBEC token
3delegatecall storage collisionProxy/impl layout mismatchParity (see Part 3)
4Logic bugZero value mishandledNomad

All four are vulnerabilities you can trace within the lines of code. But many of the largest losses happen even when the code is flawless. In Part 2, we move from code to economic design — oracle manipulation, flash loans, and MEV.

This article is part of the ChainLab technical series. Hands-on Solidity implementations and test suites continue in later installments.

Tags: BlockchainSecurity, SmartContracts, Solidity, Reentrancy, DeFiHacks

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *