Unchecked External Call Return Value
Overview
Remediation Guide: How to Fix Unchecked External Call Return Value
The unchecked call detector identifies external calls (CALL, CALLCODE, DELEGATECALL, STATICCALL) whose boolean return value (success) is not tested in a subsequent conditional branch. When an external call fails — due to insufficient gas, a reverting callee, or sending Ether to a non-payable recipient — it returns false. If the caller ignores this return value, it continues executing under the assumption that the call succeeded, potentially leading to inconsistent state, double-payments, or failed transfers that are never retried.
Sigvex inspects the CFG of decompiled EVM bytecode. For each CALL opcode, it checks whether the top-of-stack return value is subsequently used in a JUMPI (conditional jump) instruction before execution moves to the next relevant statement.
Why This Is an Issue
Unchecked call return values are listed in SWC-104 and have been consistently exploited. The King of the Ether Throne contract (2016) suffered a critical bug: when the contract tried to refund a dethroned king, it used send() which returns false on failure — the old king’s address was a contract that rejected ETH. The refund silently failed while the game continued, permanently locking funds.
Modern .call{}() syntax makes it even easier to forget to check the return value since no revert automatically occurs.
How to Resolve
// Before: Vulnerable — return value not checked
function sendReward(address recipient, uint256 amount) internal {
recipient.call{value: amount}(""); // Return value ignored
// Execution continues even if transfer failed
}
// After: Fixed — always check return value
function sendReward(address recipient, uint256 amount) internal {
(bool success, ) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");
}
For ERC-20 tokens, use OpenZeppelin’s SafeERC20:
import "@audited/token/ERC20/utils/SafeERC20.sol";
using SafeERC20 for IERC20;
function sendTokens(IERC20 token, address recipient, uint256 amount) internal {
// safeTransfer reverts if transfer returns false
token.safeTransfer(recipient, amount);
}
Examples
Vulnerable Code
contract VulnerableKing {
address public currentKing;
uint256 public claimPrice;
function claim() external payable {
require(msg.value >= claimPrice);
address previousKing = currentKing;
uint256 refund = claimPrice;
currentKing = msg.sender;
claimPrice = msg.value;
// VULNERABLE: if previousKing is a contract that reverts on ETH receipt,
// the call fails silently and ETH is permanently locked
previousKing.call{value: refund}(""); // Return value IGNORED
}
}
Fixed Code
contract SecureKing {
address public currentKing;
uint256 public claimPrice;
mapping(address => uint256) public pendingReturns;
function claim() external payable {
require(msg.value >= claimPrice);
pendingReturns[currentKing] += claimPrice;
currentKing = msg.sender;
claimPrice = msg.value;
// No direct transfer — let previous king withdraw
}
function withdraw() external {
uint256 amount = pendingReturns[msg.sender];
require(amount > 0, "Nothing to withdraw");
pendingReturns[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Sample Sigvex Output
{
"detector_id": "unchecked-call",
"severity": "high",
"confidence": 0.87,
"description": "CALL at offset 0x6c in function claim() has its return value discarded (no JUMPI on stack top after CALL). Failure of the external transfer will not revert execution.",
"location": { "function": "claim()", "offset": 108 }
}
Detection Methodology
- External call identification: Locates
CALL,CALLCODE,DELEGATECALL, andSTATICCALLopcodes in each function’s CFG. - Return value tracking: Checks whether the return value (top of stack after the opcode) is popped and discarded (
POPinstruction) or flows into a conditional branch (JUMPI). - Context awareness: Distinguished between calls to trusted fixed addresses (e.g., known safe precompiles) where unchecked returns are acceptable, and calls to dynamic targets where the return must be checked.
- Severity classification: Direct Ether transfers with unchecked returns are critical; calls to unknown external contracts are also critical; calls to known ERC-20 tokens without SafeERC20 are high severity.
Limitations
False positives:
- Precompile calls (e.g.,
ecrecover,sha256viaCALLto addresses 1-9) do not need return value checks — these are whitelisted. - Intentional “fire and forget” calls (e.g., notifying a callback without caring about the result) may generate false positives.
False negatives:
transfer()andsend()use a limited 2300 gas stipend and revert on failure in older Solidity — but these are now deprecated and their behavior changed in post-Istanbul EVM.- Calls wrapped in a try/catch block may not be recognized as properly checked.
Related Detectors
- Reentrancy — external calls that also expose reentrancy
- Access Control — detects missing authorization on functions that make external calls