Skip to main content
Sigvex

Unchecked External Call Return Value

Detects external calls whose boolean return value is not checked, allowing silent failures that leave the contract in an inconsistent state.

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

  1. External call identification: Locates CALL, CALLCODE, DELEGATECALL, and STATICCALL opcodes in each function’s CFG.
  2. Return value tracking: Checks whether the return value (top of stack after the opcode) is popped and discarded (POP instruction) or flows into a conditional branch (JUMPI).
  3. 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.
  4. 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, sha256 via CALL to 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() and send() 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.
  • Reentrancy — external calls that also expose reentrancy
  • Access Control — detects missing authorization on functions that make external calls

References