DeFi-Specific Vulnerabilities: Beyond Traditional Smart Contract Security
Reentrancy and integer overflow are well-understood. Auditors know the patterns; documentation covers them thoroughly. The vulnerability classes that still trip up DeFi protocols are more subtle: they arise from assumptions about token behavior that hold in test environments but fail against real token contracts deployed on mainnet.
Token mechanics — decimal precision, transfer fees, rebasing supply, non-standard return values — create a distinct attack surface. These bugs don’t appear in synthetic tests with clean ERC-20 mocks. They appear when a protocol integrates USDT, stETH, or any of the hundreds of tokens with non-standard behavior.
Decimal Mismatch
Different tokens use different decimal precision. ETH, DAI, and most ERC-20s use 18 decimals. USDC and USDT use 6. WBTC uses 8. Gemini Dollar uses 2. A protocol that arithmetically combines two token amounts without normalizing their decimals produces wrong results by factors of $10^{12}$ or more.
The vulnerable pattern:
// USDC (6 decimals) and DAI (18 decimals) treated identically
function swap(
address tokenA, // USDC
address tokenB, // DAI
uint amountA
) external {
// 1,000,000 USDC units = $1 USDC
// But this arithmetic treats it as $1 of 18-decimal token
uint amountB = getPrice(tokenA, tokenB) * amountA;
IERC20(tokenA).transferFrom(msg.sender, address(this), amountA);
IERC20(tokenB).transfer(msg.sender, amountB);
}
The bridge-specific version of this is worse. A cross-chain message encoding a 6-decimal token amount gets interpreted on the destination chain as an 18-decimal amount. An attacker sends 1 USDC (1,000,000 units) and receives 1,000,000 of an 18-decimal token — a million-to-one extraction.
The fix: normalize before arithmetic, denormalize before transfer.
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Metadata.sol";
contract SecureSwap {
uint constant PRECISION = 1e18;
function swap(address tokenA, address tokenB, uint amountA) external {
uint normalizedA = normalize(amountA, tokenA);
uint normalizedB = getPrice(tokenA, tokenB) * normalizedA / PRECISION;
uint amountB = denormalize(normalizedB, tokenB);
IERC20(tokenA).transferFrom(msg.sender, address(this), amountA);
IERC20(tokenB).transfer(msg.sender, amountB);
}
function normalize(uint amount, address token) internal view returns (uint) {
uint decimals = IERC20Metadata(token).decimals();
require(decimals <= 18, "Decimals too high");
return amount * 10**(18 - decimals);
}
function denormalize(uint amount, address token) internal view returns (uint) {
uint decimals = IERC20Metadata(token).decimals();
return amount / 10**(18 - decimals);
}
}
Detection looks for arithmetic combining amounts from two different token addresses without an intervening decimals() call on each.
Fee-on-Transfer Tokens
Some ERC-20 tokens deduct a percentage on each transfer. USDT does this on certain chains. Tokens like Statera (STA) and Reflect Finance (RFI) do it on all transfers. When a protocol records the sent amount rather than the received amount, depositors can be credited more than the contract actually holds.
The pattern that breaks:
mapping(address => uint) public balances;
function deposit(uint amount) external {
// A 10% fee token sends 90% but this credits 100%
balances[msg.sender] += amount;
IERC20(feeToken).transferFrom(msg.sender, address(this), amount);
}
function withdraw(uint amount) external {
balances[msg.sender] -= amount;
// Transfers more than was deposited — other users' funds subsidize the difference
IERC20(feeToken).transfer(msg.sender, amount);
}
The attack is simple: deposit repeatedly, accumulating credits above the protocol’s actual holdings, then withdraw the full credited amount.
The fix is always to measure what arrived:
function deposit(uint amount) external {
uint before = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amount);
uint received = IERC20(token).balanceOf(address(this)) - before;
balances[msg.sender] += received; // credit what was received, not what was sent
}
This pattern — balanceOf before transfer, balanceOf after, credit the delta — handles fee tokens, rebasing, and any other mechanism that might affect the received amount.
Rebasing Tokens
Rebasing tokens change the balance every holder sees without transferring tokens. stETH accrues staking rewards by increasing balances daily. AMPL adjusts its total supply based on a price oracle. Aave’s aTokens accumulate interest through rebasing.
A protocol that caches stETH balances in a mapping will have stale numbers the moment a rebase occurs. Depositors who withdraw first capture the rebased gains; later depositors find the protocol insolvent.
The broken accounting:
mapping(address => uint) public userShares;
uint public totalShares;
function deposit(uint amount) external {
userShares[msg.sender] += amount;
totalShares += amount;
IERC20(stETH).transferFrom(msg.sender, address(this), amount);
}
function withdraw() external {
uint share = userShares[msg.sender];
// After a rebase, the actual stETH balance is higher than totalShares
// First withdrawers extract the excess at others' expense
uint payout = share * IERC20(stETH).balanceOf(address(this)) / totalShares;
userShares[msg.sender] = 0;
IERC20(stETH).transfer(msg.sender, payout);
}
Share-based accounting fixes this. When depositing, calculate shares proportional to the current balance. When withdrawing, convert shares back to tokens at the current ratio. Every rebased token is distributed proportionally.
function deposit(uint amount) external {
uint currentBalance = IERC20(stETH).balanceOf(address(this));
uint shares = totalShares == 0
? amount
: amount * totalShares / currentBalance;
userShares[msg.sender] += shares;
totalShares += shares;
IERC20(stETH).transferFrom(msg.sender, address(this), amount);
}
function withdraw() external {
uint shares = userShares[msg.sender];
uint payout = shares * IERC20(stETH).balanceOf(address(this)) / totalShares;
userShares[msg.sender] = 0;
totalShares -= shares;
IERC20(stETH).transfer(msg.sender, payout);
}
If you need to avoid rebasing complexity entirely, wstETH (wrapped stETH) is non-rebasing — interest accrues by the exchange rate moving instead.
Missing Slippage Protection
AMM swaps without a minimum output amount are unconditionally sandwichable. The minAmountOut = 0 pattern shows up frequently in protocol contracts that wrap DEX calls.
function swap(address tokenIn, address tokenOut, uint amountIn) external {
IUniswapRouter(router).swapExactTokensForTokens(
amountIn,
0, // no minimum — accept any output amount
path,
msg.sender,
deadline
);
}
A sandwich bot front-runs by buying the output token before the victim’s swap (moving the price up), lets the victim swap at the worse price, then back-runs by selling at the elevated price. With minAmountOut = 0, there is no floor on how much price impact the victim absorbs.
The fix requires using a TWAP or external oracle for the expected output, not the current spot price:
function swap(
address tokenIn,
address tokenOut,
uint amountIn,
uint maxSlippageBps // caller sets acceptable tolerance, e.g. 50 = 0.5%
) external {
require(maxSlippageBps <= 1000, "Slippage limit too high");
uint expectedOut = getTWAPExpectedOutput(tokenIn, tokenOut, amountIn);
uint minAmountOut = expectedOut * (10000 - maxSlippageBps) / 10000;
IUniswapRouter(router).swapExactTokensForTokens(
amountIn,
minAmountOut,
getPath(tokenIn, tokenOut),
msg.sender,
block.timestamp + 300
);
}
Using current spot reserves for expectedOut is insufficient — a sandwich attack that moves price before the victim’s transaction changes both the expected output and the floor simultaneously.
Reward Manipulation via Donation
Reward distributions calculated from balanceOf(address(this)) can be gamed by sending tokens directly to the contract (a “donation”). The donation inflates the denominator in the reward calculation, allowing an attacker who then claims rewards to extract more than their legitimate share.
function getReward(address user) public view returns (uint) {
// balanceOf is manipulable — direct transfers change this number
uint totalRewards = IERC20(rewardToken).balanceOf(address(this));
return shares[user] * totalRewards / totalShares;
}
The attack: deposit a small amount for shares, donate a large amount of the reward token to inflate the balance, claim rewards that include the donated amount. The donation loss is smaller than the extracted reward if the attacker holds a disproportionate share.
Track deposits explicitly instead of reading from balance:
uint public accRewardPerShare;
function distributeRewards(uint amount) external {
require(totalShares > 0, "No shares");
accRewardPerShare += amount * 1e18 / totalShares;
IERC20(rewardToken).transferFrom(msg.sender, address(this), amount);
}
function getReward(address user) public view returns (uint) {
return shares[user] * accRewardPerShare / 1e18 - claimed[user];
}
This is the standard MasterChef-style accumulator pattern. Rewards are tracked through an internal accumulator, not through balanceOf, so no external transfer can alter the distribution.
Unchecked ERC-20 Return Values
USDT on Ethereum returns void on transfer() — not bool. The canonical ERC-20 interface specifies a bool return, but several deployed tokens predate the standard or deviate from it. Calling transfer() directly and not checking the return value means a failed transfer goes undetected.
// If token is USDT on mainnet, this never reverts on failure
// and returns nothing checkable
IERC20(token).transfer(to, amount);
// execution continues as if transfer succeeded
balances[to] -= amount;
OpenZeppelin’s SafeERC20 handles this with a low-level call that wraps the return value check correctly for both standard and non-standard tokens:
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
IERC20(token).safeTransfer(to, amount); // handles void returns
IERC20(token).safeTransferFrom(from, to, amount);
Using SafeERC20 for all token interactions is the simplest way to cover this class of issue.
Summary
These six vulnerability classes share a common characteristic: they don’t appear in tests with simple ERC-20 mocks. Standard test tokens return exactly what was sent, don’t rebase, don’t charge fees, and use 18 decimals. The bugs surface in production when the protocol encounters real tokens that don’t follow those assumptions.
The d-xo/weird-erc20 repository documents most of the non-standard behaviors that production code needs to handle.1 Running integration tests with actual mainnet tokens — or token contracts that simulate their behaviors — catches these issues before deployment.
References
- Weird ERC20 Tokens — d-xo
- Lido stETH Integration Guide
- OpenZeppelin SafeERC20
- MEV and Sandwich Attacks — ethereum.org