external-call-safety
External Call Safety
Detect vulnerabilities arising from unsafe interactions with external contracts and non-standard token behaviors that break protocol assumptions. Covers OWASP SC06 (Unchecked External Calls) plus the entire "weird ERC20" problem space.
When to Use
- Auditing any contract that calls external contracts (token transfers, cross-contract interactions)
- Reviewing protocols that support arbitrary/user-supplied ERC20 tokens
- Analyzing ETH payment distribution logic (airdrops, reward distribution, refunds)
- Verifying low-level call safety (
call,delegatecall,staticcall) - When a protocol claims to support "any ERC20 token"
When NOT to Use
- Reentrancy-specific analysis (use reentrancy-pattern-analysis — though there is overlap)
- Oracle/price feed analysis (use oracle-flashloan-analysis)
- Pure access control review (use semantic-guard-analysis)
Part 1: External Call Safety
Vulnerability Class 1: Unchecked Return Values
Low-level calls (call, delegatecall, staticcall) return a boolean indicating success. If unchecked, failed calls are silently ignored.
// VULNERABLE: Return value not checked
function withdraw(uint256 amount) external {
balances[msg.sender] -= amount;
payable(msg.sender).call{value: amount}(""); // Can fail silently!
// User's balance decreased but ETH not sent
}
// SAFE: Check return value
function withdraw(uint256 amount) external {
balances[msg.sender] -= amount;
(bool success, ) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed");
}
Detection Algorithm:
For each low-level call expression:
1. Is the return value captured? (bool success, bytes memory data) = ...
2. Is the success boolean checked? require(success) or if(!success) revert
3. If not captured or not checked → UNCHECKED RETURN VALUE
Severity:
- ETH transfer unchecked → CRITICAL (funds lost)
- Token operation unchecked → HIGH (state desync)
- Non-financial call unchecked → MEDIUM
Vulnerability Class 2: Gas Stipend Limitations
// DANGEROUS: transfer() and send() forward only 2300 gas
payable(recipient).transfer(amount); // Reverts if recipient needs > 2300 gas
payable(recipient).send(amount); // Returns false, often unchecked
// SAFE: Use call() with gas
(bool success, ) = payable(recipient).call{value: amount}("");
require(success, "Transfer failed");
Why 2300 gas is dangerous:
- Contracts with
receive()orfallback()that do more than emit an event will fail - EIP-1884 changed
SLOADgas cost, breaking some existing contracts - Multi-sig wallets and smart contract wallets often need more gas
Vulnerability Class 3: Return Data Bomb
A malicious contract can return extremely large data to consume the caller's gas.
// Vulnerable to return data bomb
(bool success, bytes memory data) = untrustedContract.call(calldata);
// If untrustedContract returns 1MB of data, copying it costs massive gas
// SAFE: Limit return data or ignore it
(bool success, ) = untrustedContract.call(calldata); // Ignore return data
// Or use assembly to limit return data size
Vulnerability Class 4: Delegatecall to Untrusted Contract
// CRITICAL: delegatecall executes untrusted code in OUR storage context
function execute(address target, bytes calldata data) external {
target.delegatecall(data); // Untrusted code can overwrite ANY storage
}
// delegatecall should ONLY be used with trusted, immutable targets
Part 2: Token Integration Safety ("Weird ERC20" Tokens)
Issue 1: Fee-on-Transfer Tokens
Some tokens deduct a fee during transfer() and transferFrom(). The recipient receives less than the specified amount.
// VULNERABLE: Assumes received amount equals input amount
function deposit(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount; // Credits MORE than actually received!
}
// SAFE: Check actual balance change
function deposit(uint256 amount) external {
uint256 balanceBefore = token.balanceOf(address(this));
token.transferFrom(msg.sender, address(this), amount);
uint256 balanceAfter = token.balanceOf(address(this));
uint256 actualReceived = balanceAfter - balanceBefore;
balances[msg.sender] += actualReceived; // Credits actual amount
}
Known fee-on-transfer tokens: STA, PAXG, USDT (fee currently 0 but can be activated), RFI/SAFEMOON forks.
Issue 2: Rebasing Tokens
Rebasing tokens change all balances proportionally without transfers. Protocol's accounting desynchronizes from actual balances.
// VULNERABLE: Stores absolute balance amounts
function deposit(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
userDeposit[msg.sender] = amount; // After rebase, actual balance differs!
}
// Mitigation options:
// 1. Store shares instead of amounts
// 2. Wrap rebasing token (wstETH pattern)
// 3. Explicitly state: "rebasing tokens not supported"
Known rebasing tokens: stETH, AMPL, OHM, YAM, BASED.
Issue 3: Missing Return Values
Some tokens don't return a boolean from transfer()/transferFrom()/approve(), breaking the ERC20 standard.
// VULNERABLE: Assumes return value exists
bool success = token.transfer(recipient, amount); // Reverts if token returns nothing
// SAFE: Use SafeERC20
using SafeERC20 for IERC20;
token.safeTransfer(recipient, amount); // Handles missing return values
Known tokens with missing returns: USDT, BNB, OMG, KNC (legacy versions).
Issue 4: Tokens with Callbacks (ERC-777)
ERC-777 tokens trigger tokensToSend() on the sender and tokensReceived() on the recipient during transfers, enabling reentrancy.
ERC-777 callback hooks:
transfer() → calls tokensReceived() on recipient
transferFrom() → calls tokensToSend() on sender, tokensReceived() on recipient
send() → calls tokensToSend() on sender, tokensReceived() on recipient
ANY of these can re-enter the calling contract!
Cross-reference: See reentrancy-pattern-analysis for detailed ERC-777 reentrancy detection.
Issue 5: Unsafe Approve Pattern
// VULNERABLE: Approve race condition
token.approve(spender, newAmount);
// Between the approval TX and the spending TX, the spender can:
// 1. Spend the OLD allowance
// 2. Then spend the NEW allowance
// Total spent: oldAmount + newAmount (double spending)
// SAFE: Reset to zero first, or use increaseAllowance
token.approve(spender, 0); // Reset
token.approve(spender, newAmount); // Set new
// Or use SafeERC20
token.safeIncreaseAllowance(spender, amount);
// ALSO DANGEROUS: Some tokens (USDT) revert on non-zero to non-zero approve
token.approve(spender, newAmount); // REVERTS if current allowance != 0
// MUST reset to 0 first for USDT
Issue 6: Tokens with Blacklists
Some tokens can blacklist addresses, causing transfers to/from those addresses to revert.
// VULNERABLE: Assumes transfer always succeeds for valid amounts
function distribute(address[] calldata users, uint256[] calldata amounts) external {
for (uint i = 0; i < users.length; i++) {
token.transfer(users[i], amounts[i]); // Reverts if ANY user is blacklisted
// Entire batch fails!
}
}
// SAFE: Handle per-user failures
function distribute(address[] calldata users, uint256[] calldata amounts) external {
for (uint i = 0; i < users.length; i++) {
try IERC20(token).transfer(users[i], amounts[i]) {
// Success
} catch {
// Log failure, skip this user, don't block others
}
}
}
Known blacklist tokens: USDC, USDT, TUSD.
Issue 7: Tokens with Max Supply / Transfer Limits
Some tokens have maximum transfer amounts per transaction or maximum holding amounts per address.
// Protocol may assume any amount can be transferred
// But some tokens: require(amount <= maxTransferAmount)
// This can brick protocols that batch large transfers
Part 3: Payment Pattern Analysis
Push vs Pull Pattern
PUSH (Dangerous):
Contract sends funds TO recipients
- Can fail if recipient is a contract that reverts
- Can be DoS'd by one malicious recipient
- Gas costs unpredictable
PULL (Safe):
Recipients claim funds FROM contract
- Each claim is independent
- One user's failure doesn't affect others
- Gas costs predictable per claim
Detection:
For each function that sends ETH or tokens to external addresses:
If sending to user-supplied addresses in a loop → PUSH pattern
If sending to individual addresses via claim function → PULL pattern
PUSH pattern with untrusted recipients → HIGH risk of DoS
Workflow
Task Progress:
- [ ] Step 1: Find all external calls (call, delegatecall, staticcall, transfer, send)
- [ ] Step 2: Verify return values are checked for all external calls
- [ ] Step 3: Identify all token interactions and classify token assumptions
- [ ] Step 4: Check for fee-on-transfer compatibility (balance before/after pattern)
- [ ] Step 5: Check for rebasing token compatibility
- [ ] Step 6: Verify SafeERC20 usage for tokens with missing return values
- [ ] Step 7: Check approve patterns for race conditions and USDT compatibility
- [ ] Step 8: Analyze payment distribution pattern (push vs pull)
- [ ] Step 9: Score findings and generate report
Output Format
## External Call Safety Report
### Finding: [Title]
**Function:** `functionName()` at `Contract.sol:L42`
**Category:** [Unchecked Return | Fee-on-Transfer | Rebasing | Missing Return | Callback | Approve Race | DoS]
**Severity:** [CRITICAL | HIGH | MEDIUM]
**Issue:**
[Description of the unsafe external call or token integration issue]
**Affected Tokens:**
[List of known tokens that trigger this issue, e.g., USDT, USDC, stETH]
**Vulnerable Code:**
[Code snippet]
**Attack Scenario:**
1. [Step-by-step exploitation]
**Recommendation:**
[Use SafeERC20, balance-before-after, pull pattern, etc.]
Quick Detection Checklist
- Are ALL low-level
callreturn values checked (require(success))? - Does the protocol use
SafeERC20for all token interactions? - Does the deposit function use balance-before-after pattern for fee-on-transfer tokens?
- Does the protocol explicitly handle or reject rebasing tokens?
- Does
approve()reset to 0 before setting new allowance (USDT compatibility)? - Are batch payment operations using pull pattern (not push)?
- Is
delegatecallonly used with trusted, immutable targets? - Are return data sizes from untrusted contracts limited?
- Does the protocol handle token blacklisting gracefully?
For weird ERC20 catalog, see {baseDir}/references/weird-erc20.md. For call safety patterns, see {baseDir}/references/call-safety-patterns.md.
Rationalizations to Reject
- "We only support standard ERC20 tokens" → USDT is the most used token and it's non-standard (no return value, fee capability)
- "The call will always succeed" → Smart contract wallets, blacklisted addresses, and gas changes can cause failures
- "We trust the token contract" → Token contracts can be upgraded (proxies) or have hidden features
- "transfer() is safe enough" → 2300 gas stipend breaks with gas repricing EIPs; use call()
- "We checked the token before listing" → Fee-on-transfer can be toggled on after listing (USDT has this capability)
- "Rebasing tokens are rare" → stETH is one of the largest tokens by TVL