Back to Cheat Sheets

📞 Unchecked External Calls

OWASP Smart Contract Top 10 (2025) - SC06

HIGH RISK

📋 What Is It?

Unchecked External Calls occur when a contract makes a low-level call to another address-to send ether, invoke a function, or move tokens-and then ignores whether that call actually succeeded. On the EVM, not every failure reverts: send, call, delegatecall, and staticcall signal failure by returning false instead of throwing. If you never read that boolean, a failed call looks identical to a successful one-so the contract marches forward on a false assumption, stranding funds or crediting phantom balances.

SC06 OWASP Rank
Silent Failure Mode
Easy To Prevent

⚠️ Common Exploits

  • Ignored call bool: call{value:} return dropped; failed ETH transfer looks fine
  • Unchecked send: send() returns false, never read, payment marked "paid"
  • Assumed ERC-20 revert: non-standard token returns false → phantom deposit credited
  • USDT-class no-return tokens: strict bool decode reverts and bricks the integration
  • Masked delegatecall: success flag discarded; a reverting impl treated as success
  • Push-loop griefing: one reverting recipient blocks a whole payout loop
  • 2300-gas stipend: transfer/send break valid contract recipients

🔴 Attack Flow

1. Find a send/call/transfer whose return is unchecked
↓
2. Become the recipient, the token, or the depositor
↓
3. Make the call fail (reject ETH / return false)
↓
4. Victim updates state as if it succeeded
↓
5. IMPACT: Stuck funds, phantom credit, corrupted books!

❌ Vulnerable Code

// Bad: return value of call ignored - silent failure function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "insufficient"); balances[msg.sender] -= amount; // effect first msg.sender.call{value: amount}(""); ← bool NOT captured emit Withdrawn(msg.sender, amount); // emitted even on failure } // Bad: assumes transferFrom reverts on failure function stake(uint256 amount) external { token.transferFrom(msg.sender, address(this), amount); ← return ignored staked[msg.sender] += amount; ← phantom stake if token returns false } // Bad: delegatecall success flag discarded function run(address impl, bytes calldata data) external { impl.delegatecall(data); ← reverting impl treated as success executed = true; } // Bad: push loop - one bad recipient bricks everyone for (uint256 i = 0; i < winners.length; i++) { payable(winners[i]).transfer(prize); ← one revert blocks the whole loop }

✅ Secure Code

// Good: capture the bool, revert on failure (CEI ordering) error EthTransferFailed(address to, uint256 amount); function withdraw(uint256 amount) external { require(balances[msg.sender] >= amount, "insufficient"); // Checks balances[msg.sender] -= amount; // Effects (bool ok, ) = msg.sender.call{value: amount}(""); // Interactions if (!ok) revert EthTransferFailed(msg.sender, amount); ← reverts, rolls back -= } // Good: SafeERC20 normalises false/empty returns; measure real delta using SafeERC20 for IERC20; function stake(uint256 amount) external { uint256 before = token.balanceOf(address(this)); token.safeTransferFrom(msg.sender, address(this), amount); ← reverts on false/empty uint256 received = token.balanceOf(address(this)) - before; staked[msg.sender] += received; // credit only what arrived } // Good: check delegatecall success AND that target is a contract function run(address impl, bytes calldata data) external returns (bytes memory) { require(impl.code.length > 0, "not a contract"); ← empty addr returns true! (bool ok, bytes memory ret) = impl.delegatecall(data); require(ok, "delegatecall failed"); executed = true; return ret; // caller validates data } // Good: pull-over-push - each recipient bears their own risk function claim() external { uint256 amount = credits[msg.sender]; require(amount > 0, "nothing to claim"); credits[msg.sender] = 0; // effect first (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "claim failed"); // only the caller is affected }

✓ Prevention Checklist

  • Capture and require the bool of every low-level call
  • Use SafeERC20 for transfer/transferFrom/approve
  • Follow Checks-Effects-Interactions ordering
  • Prefer pull-over-push for multi-recipient payments
  • Verify returned data, not just the success flag
  • Guard delegatecall with an impl.code.length > 0 check
  • Measure real balance delta for fee-on-transfer tokens
  • Prefer checked call + reentrancy guard over transfer/send
  • Surface failures with custom errors, never swallow them
  • Treat the compiler "return value not used" warning as an error
  • Detect and handle partial failures in batch calls
  • Assert internal accounting matches on-chain balances

🔍 Detection & Tools

Analysis Tools:

Slither Mythril Echidna Foundry Manticore

Secure Libraries:

OpenZeppelin SafeERC20 ReentrancyGuard solc warnings

How to Test:

  • Run Slither detectors: unchecked-lowlevel, unchecked-send, unchecked-transfer
  • Route every transfer through a Rejector contract (no payable fallback)
  • Test with a token that returns false without reverting
  • Test with a USDT-class token that returns no data
  • Test with a fee-on-transfer / deflationary token
  • Add invariant tests: internal balances == actual balances

🌍 Real-World Incident Classes

  • Silent-transfer-failure accounting bugs: a withdrawal zeroes the internal balance then sends ETH with an unchecked send/call; an incompatible recipient makes it fail and funds are stranded while the books say "paid".
  • Non-standard token integrations: vaults/DEXes assuming strict ERC-20 credit phantom deposits for tokens that return false or no data - the reason SafeERC20 exists.
  • Masked delegatecall / proxy failures: executors and multicalls that discard the success flag record upgrades or actions that never happened.
  • Push-payment DoS: a single reverting or gas-heavy recipient in a distribution loop bricks payouts for everyone else.

Note: unchecked calls are among the most frequently reported findings in Solidity audits. Exact loss figures vary by report; the durable lesson is that a single missing require can either strand or steal funds.

📌 Quick Tips

  • DO NOT discard a low-level call's return value
  • DO NOT assume ERC-20 reverts on failure
  • DO NOT update state before confirming the call
  • DO capture and require the success bool
  • DO use SafeERC20 for all token movements
  • DO prefer pull-over-push payments
  • DO validate returned data and contract existence