5 smart contract bugs i still see in 2026 (and how to catch them)
5 smart contract bugs i still see in 2026 (and how to catch them) the boring bugs that keep draining real money, and the checks that stop them you'd think we'd be done with these by now. we're not. i read a lot of sol
5 smart contract bugs i still see in 2026 (and how to catch them)
the boring bugs that keep draining real money, and the checks that stop them
you'd think we'd be done with these by now. we're not. i read a lot of solidity: client code, contest code, forks of forks. the same five bugs keep showing up, usually in code that looks clean at first glance.
here they are, with the smallest snippets i could write, and how i actually catch them.
1. reentrancy, even when you "followed CEI"
checks-effects-interactions is good advice. it's just not the whole story. CEI protects the function you wrote it in. it doesn't protect other functions that read the same state while you're mid-call.
function withdraw(uint256 shares) external {
uint256 amount = previewRedeem(shares);
_burn(msg.sender, shares); // effect
(bool ok,) = msg.sender.call{value: amount}(""); // interaction
require(ok);
totalAssetsCached -= amount; // effect AFTER the call
}
function price() external view returns (uint256) {
return totalAssetsCached * 1e18 / totalSupply();
}
shares are burned but totalAssetsCached isn't updated yet. during the callback, price() is inflated. any protocol that uses this as an oracle gets read-only reentrancy'd. also watch ERC777 / ERC721 / ERC1155 hooks (onERC721Received is an external call too).
catch it: finish every state update before any external call, not just "most". use a nonReentrant guard and expose a reentrancy check on view functions other protocols consume. in review, list every external call and ask "what state is half-updated right now?"
2. rounding in share math (hi, ERC4626 inflation)
classic first-depositor attack:
- attacker deposits 1 wei, gets 1 share
- attacker donates 10,000 tokens directly to the vault
- victim deposits 9,999 tokens:
9999 * 1 / 10001rounds to 0 shares - attacker redeems and walks off with the victim's deposit
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 supply = totalSupply();
return supply == 0 ? assets : assets * supply / totalAssets(); // rounds down
}
the deeper lesson: every division picks a winner. it should always be the vault, never the user.
catch it: use virtual shares/assets with a decimals offset (OpenZeppelin's ERC4626 does this), or seed the vault with dead shares at deploy. round down on deposit/redeem, round up on mint/withdraw. and fuzz it: "a deposit > 0 must never mint 0 shares" is a one-line invariant.
3. stale oracle prices
(, int256 answer,,,) = feed.latestRoundData();
return uint256(answer);
i see this weekly. no staleness check, no sign check, no L2 sequencer check. if the feed stops updating (or the sequencer goes down and comes back), you're lending against a price from hours ago.
(, int256 answer,, uint256 updatedAt,) = feed.latestRoundData();
require(answer > 0, "bad price");
require(block.timestamp - updatedAt <= HEARTBEAT, "stale");
catch it: set HEARTBEAT per feed (they differ, don't hardcode one value everywhere). on L2s, check the sequencer uptime feed plus a grace period. and never use spot AMM reserves as a price, flash loans make that free to move.
4. unchecked return values and fee-on-transfer tokens
two bugs that look like one: you trust that a transfer did what you asked.
token.transferFrom(msg.sender, address(this), amount); // USDT returns nothing, some tokens return false
balances[msg.sender] += amount; // credits the full amount anyway
some tokens return false instead of reverting. some (USDT) return nothing at all, which breaks the IERC20 interface call. and fee-on-transfer tokens deliver less than amount, so your accounting drifts until the last user can't withdraw.
uint256 before = token.balanceOf(address(this));
token.safeTransferFrom(msg.sender, address(this), amount);
uint256 received = token.balanceOf(address(this)) - before;
balances[msg.sender] += received;
catch it: SafeERC20 everywhere, no exceptions. measure balance deltas if you accept arbitrary tokens, or explicitly whitelist tokens and document that you don't support weird ones. rebasing tokens are their own headache, decide up front.
5. unprotected initializers
upgradeable contracts don't have constructors doing setup, they have initialize(). if it's callable by anyone, it's callable by the first person to notice.
function initialize(address _owner) external {
owner = _owner; // no initializer modifier, anyone can call, again and again
}
two flavors:
- the proxy is deployed and initialized in separate txs, someone frontruns the init
- the implementation contract is never initialized, so anyone can take it over (and in older UUPS setups,
selfdestructit via an upgrade call)
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() { _disableInitializers(); }
function initialize(address _owner) external initializer {
__Ownable_init(_owner);
}
catch it: initializer / reinitializer(n) modifiers, _disableInitializers() in the implementation constructor, and deploy+init in a single tx (factory or proxy constructor data). in review, grep for every initialize and every onlyOwner-less setter.
how i actually review for these
nothing fancy:
-
map the external calls. every
.call, transfer, hook, and oracle read. that's where 1, 3 and 4 live. - write the invariants down. "total shares backed by total assets", "no deposit mints 0", "price is never older than X". then fuzz them with foundry.
- run slither, then ignore 80% of it and read the other 20% carefully.
- assume weird tokens and impatient attackers. if a step can be frontrun or a token can misbehave, one will.
none of these are new. that's the point. the bugs that drain protocols are rarely clever, they're just the ones nobody checked twice.
building or auditing something? hxxe.dev
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.