ERC-20 is the most deployed token standard — and one of the most misunderstood from a security perspective. Dozens of production DeFi protocols have been exploited because they assumed standard ERC-20 behavior that their tokens didn't actually implement. This post covers the security vulnerabilities in ERC-20 contracts themselves, and the protocol-level risks that arise from non-standard token behavior.
The approve() Race Condition
The original ERC-20 approve() function has a well-known race condition:
// Attack scenario:
// 1. Alice approves Bob for 100 tokens: approve(Bob, 100)
// 2. Alice later wants to change it to 50: approve(Bob, 50)
// 3. Bob sees the pending tx in the mempool
// 4. Bob frontruns: calls transferFrom(Alice, Bob, 100) — spends the original allowance
// 5. Alice's approve(Bob, 50) confirms
// 6. Bob calls transferFrom(Alice, Bob, 50) again — spends the NEW allowance
// Result: Bob spent 150 tokens, Alice intended max 50
Fix: Use increaseAllowance/decreaseAllowance
// Instead of approve(spender, newAmount):
token.increaseAllowance(spender, additionalAmount);
token.decreaseAllowance(spender, reducedAmount);
// Or use EIP-2612 permit() for gasless approvals that don't have this race:
token.permit(owner, spender, value, deadline, v, r, s);
OpenZeppelin's ERC-20 implementation includes increaseAllowance and decreaseAllowance. The race condition is mostly theoretical in practice (requires precise mempool manipulation), but it's the reason EIP-2612 permit() exists — the permit signature is single-use and includes an expiry.
Fee-on-Transfer Tokens
"Deflationary" tokens deduct a fee during every transfer. A call to transfer(to, 100) results in the recipient receiving, say, 97 tokens — 3% burned or redirected to a fee wallet.
// Example deflationary token
contract DeflationaryToken is ERC20 {
uint256 public constant FEE_BPS = 300; // 3%
address public feeRecipient;
function _transfer(address from, address to, uint256 amount) internal override {
uint256 fee = (amount * FEE_BPS) / 10000;
super._transfer(from, feeRecipient, fee);
super._transfer(from, to, amount - fee);
}
}
The protocol-level exploit: Any DeFi protocol that assumes transfer(to, amount) results in the recipient holding exactly amount will have accounting errors.
// VULNERABLE: AMM pool assuming no-fee transfer
function addLiquidity(address token, uint256 amount) external {
uint256 before = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amount);
uint256 received = IERC20(token).balanceOf(address(this)) - before;
// If token has transfer fees, received < amount
// But the pool records 'amount', not 'received'
_mintLpShares(msg.sender, amount); // WRONG: should use 'received'
}
Fix: Balance-check pattern
function addLiquidity(address token, uint256 amount) external {
uint256 before = IERC20(token).balanceOf(address(this));
IERC20(token).transferFrom(msg.sender, address(this), amount);
uint256 received = IERC20(token).balanceOf(address(this)) - before;
// Use 'received', not 'amount'
_mintLpShares(msg.sender, received); // CORRECT
}
Real-world impact: Dozens of yield aggregators and AMMs have been exploited by depositing fee-on-transfer tokens. The attacker withdraws more than they deposited by exploiting the accounting discrepancy.
Rebase Tokens
Rebase tokens (e.g., AMPL, stETH before wstETH) change all holder balances simultaneously by modifying a global multiplier rather than individual balances:
// Simplified rebase token
contract RebaseToken is ERC20 {
uint256 public rebaseIndex = 1e18;
mapping(address => uint256) private _shares;
function balanceOf(address account) public view override returns (uint256) {
return (_shares[account] * rebaseIndex) / 1e18;
}
function rebase(uint256 newIndex) external onlyOracle {
rebaseIndex = newIndex; // Everyone's balance changes simultaneously
}
}
Protocol risk: A smart contract that holds rebase tokens will see its balance change without any transfer event firing. Contracts that:
- Cache token balances in storage
- Compare stored balance to current balance
- Use balance as an accounting invariant
...will all break with rebase tokens. Vault contracts are especially vulnerable — the totalAssets() function might return a stale cached value while the actual balance has rebased.
Fix: Either explicitly exclude rebase tokens (document that the protocol doesn't support them), or use share-based accounting that naturally handles rebases (like Compound's cToken model or stETH's wstETH wrapper).
Missing Return Value on transfer()
Early ERC-20 tokens (including USDT and BNB) don't return a bool from transfer() and transferFrom(). This violates the ERC-20 standard — the standard requires these functions to return bool.
// USDT's non-standard transfer (simplified)
function transfer(address to, uint256 value) public {
// Does NOT return bool
require(balances[msg.sender] >= value);
balances[msg.sender] -= value;
balances[to] += value;
emit Transfer(msg.sender, to, value);
}
// VULNERABLE: caller checks return value
bool success = IERC20(usdt).transfer(to, amount);
require(success, "Transfer failed");
// This reverts on non-standard tokens because the ABI decoder
// tries to decode the missing bool return value
// VULNERABLE: caller ignores return value
IERC20(token).transfer(to, amount);
// No revert, but transfer failure is silently ignored
Fix: Use SafeERC20
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract SafeVault {
using SafeERC20 for IERC20;
function withdraw(address token, address to, uint256 amount) external {
IERC20(token).safeTransfer(to, amount); // handles both standard and non-standard tokens
}
}
SafeERC20.safeTransfer uses a low-level call and handles both the case where transfer returns nothing (non-standard) and where it returns false.
Unlimited Mint / Hidden Mint Function
// VULNERABLE: owner can mint unlimited tokens
contract Token is ERC20, Ownable {
constructor() ERC20("MyToken", "MTK") {
_mint(msg.sender, 1_000_000e18);
}
// Hidden rug: owner can mint at will
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
}
This is a rug pull vector. The contract deploys with a fixed supply, but the owner can silently dilute all holders by minting more.
Variants:
- Public mint with no cap (anyone can print money)
- Mint cap that's artificially high (1 quadrillion tokens with 1M circulating)
- Mint function hidden in a low-visibility part of the contract (base contract, proxy implementation)
- Upgradeable proxy where V2 adds a hidden mint
Fix: Either remove mint entirely after initial distribution, or gate it with a hard-coded supply cap:
uint256 public constant MAX_SUPPLY = 1_000_000e18;
function mint(address to, uint256 amount) external onlyOwner {
require(totalSupply() + amount <= MAX_SUPPLY, "Max supply exceeded");
_mint(to, amount);
}
Better: emit an event and lock the mint function permanently after initial distribution.
Blacklist / Freeze Functions
// Common in USDC, USDT, BUSD — but risky in new tokens
contract BlacklistToken is ERC20, Ownable {
mapping(address => bool) public blacklisted;
function blacklist(address account) external onlyOwner {
blacklisted[account] = true;
}
function _beforeTokenTransfer(address from, address to, uint256 amount) internal override {
require(!blacklisted[from] && !blacklisted[to], "Blacklisted");
super._beforeTokenTransfer(from, to, amount);
}
}
This is expected for regulated stablecoins (USDC can freeze accounts for legal compliance). But in a new DeFi token, a blacklist function means the owner can freeze any wallet's tokens — including LP positions, which can drain a liquidity pool by preventing LP token transfers.
Audit red flag: Any token used as collateral in a lending protocol or as an LP position should not have owner-controlled freeze functions, because:
1. Protocol integrators can't accurately price collateral risk
2. A freeze + price dump enables theft of collateral value from lenders
Uncapped Transfer Fee
// VULNERABLE: fee rate can be set to 100% by owner
contract DynamicFeeToken is ERC20, Ownable {
uint256 public transferFeeBps; // set by owner
function setFee(uint256 newFeeBps) external onlyOwner {
// No cap — owner can set to 10000 (100%)
transferFeeBps = newFeeBps;
}
}
If an attacker (or malicious owner) sets the fee to 100%, transfer() sends 0 tokens to the recipient. This can be used to:
- Drain a DEX pair that uses this token as one side
- Front-run large sells by setting fee to 100% before the victim's transaction
Fix: Hard-code a fee cap in the contract (e.g., 10% maximum):
function setFee(uint256 newFeeBps) external onlyOwner {
require(newFeeBps <= 1000, "Fee cannot exceed 10%");
transferFeeBps = newFeeBps;
}
Token Security Checklist
For token contract authors:
- [ ] No uncapped mint function (or mint is permanently disabled after launch)
- [ ] No owner-controlled blacklist unless required by legal/compliance (document if so)
- [ ] Transfer fee is hardcoded (not owner-adjustable) or has a hard max cap (≤10%)
- [ ] transfer() and transferFrom() return bool (standard compliance)
- [ ] No emergency drain function (withdraw, emergencyWithdraw on token contract itself)
- [ ] Supply cap is enforced at contract level, not just administratively
For protocol developers integrating arbitrary ERC-20s:
- [ ] Use SafeERC20 for all transfer operations
- [ ] Use balance-delta checks for deposits (handle fee-on-transfer tokens)
- [ ] Document whether the protocol supports fee-on-transfer tokens
- [ ] Document whether the protocol supports rebase tokens
- [ ] Test with non-standard tokens (USDT, WBTC) in your test suite
Detection Coverage
| Vulnerability | Slither | Mythril | Semgrep | AI |
|---|---|---|---|---|
| Missing SafeERC20 (unchecked return) | ✅ | ⚠️ | ✅ | ✅ |
| Uncapped mint function | ✅ | ❌ | ✅ | ✅ |
| Fee-on-transfer accounting error | ❌ | ❌ | ❌ | ✅ |
| Uncapped transfer fee | ❌ | ❌ | ❌ | ✅ |
| Blacklist rug vector | ❌ | ❌ | ❌ | ✅ |
| approve() race condition | ⚠️ | ❌ | ⚠️ | ✅ |
| Rebase token integration risk | ❌ | ❌ | ❌ | ✅ |
Fee-on-transfer and rebase token integration issues require reasoning about how a token will behave in context — you need to understand that the calling protocol assumes no-fee transfer behavior. This is a semantic issue that static analysis can't detect, but AI analysis can flag by checking the protocol's accounting assumptions against the token's implementation.
Scan your ERC-20 token with ContractScan — the AI engine detects mint caps, fee functions, blacklist risks, and SafeERC20 compliance in a single pass.
Related: Rug Pull Detection Patterns — deeper coverage of mint, blacklist, and drain rug pull vectors.
Related: Token Approval Security: The Infinite Allowance Problem — EIP-2612 permit() and the approve/transferFrom attack surface.
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.