Unchecked Return Value Attacks

How the attack works and how to prevent it

JohnnyTime
JohnnyTime · Updated August 05, 2026
25 min read
Total Stolen Pending
Last Attack Pending
Latest Victim Pending

Unchecked Return Values in Solidity: Detection and Prevention

An unchecked return value lets a contract continue after an external operation fails. The code may compile and execute without reverting even though an ETH transfer, token operation, or low-level call did not complete.

The resulting state can record a payment or deposit that never occurred. Depending on the surrounding logic, this can lock funds, create false balances, or expose assets to withdrawal.

This guide explains the return behavior of send(), call(), and ERC-20 operations, revisits the King of the Ether Throne incident, and shows how to use explicit checks and SafeERC20.

Quick answer: SWC-104, also called unchecked call return value, appears when Solidity code ignores whether a low-level call, token transfer, or delegatecall succeeded. The fix is to check the returned boolean, revert on failure, and use SafeERC20 for token transfers that may return false or no value.

What exactly is an unchecked return value vulnerability?

An unchecked return value vulnerability occurs when a contract makes an external call, such as sending ETH or calling another contract, without verifying whether the operation succeeded.

In Solidity, low-level functions such as .send() and .call() return false on failure instead of automatically reverting. If the caller ignores that value, subsequent state changes still execute.

The broken ATM analogy

Imagine an ATM that jams but still prints a receipt saying "Withdrawal Successful."

The account balance is reduced even though the cash never leaves the machine. A contract creates the same inconsistency when it updates internal accounting after an unchecked failed transfer.


Why unchecked return values matter

Unchecked return values have appeared in contracts since Ethereum's early years and remain represented in current vulnerability classifications:

$0K+
Tracked Losses (OWASP 2025)
0+
ERC-20 Tokens With Missing Returns
OWASP #6
Smart Contract Top 10 (2025)
Classification Identifier Name
SWC Registry SWC-104 Unchecked Call Return Value
OWASP SC Top 10 SC06 (2025) Unchecked External Calls
DASP Top 10 #4 Unchecked Low Level Calls
CWE (MITRE) CWE-252 Unchecked Return Value

Unchecked external calls moved from #10 to #6 in the 2025 OWASP Smart Contract Top 10, indicating that the pattern remains common in assessed systems.

SWC-104: Unchecked Call Return Value

SWC-104 is the Smart Contract Weakness Classification label for unchecked call return values. In plain terms, the contract asks another address to do something, receives a success or failure signal, and then ignores that signal.

The most common Solidity version looks like this:

(bool success, ) = recipient.call{value: amount}("");
// success is ignored
paid[recipient] = true;

That code can mark a payment as complete even when the ETH transfer failed. The same pattern appears with send, delegatecall, staticcall, raw ERC-20 transfer, and raw ERC-20 transferFrom.

For auditors, the important question is not "does this code compile?" It is: what state changes if the external operation fails? If accounting, ownership, votes, debt, shares, rewards, or payout status update after a failed call, the bug is no longer theoretical.

The two major attack surfaces

Low-Level ETH Transfers
"Functions like .send() and .call() return false on failure but never revert. Ignoring the return value means failed ETH transfers go unnoticed."
Affected: .send(), .call(), .delegatecall()
💰
ERC-20 Token Operations
"Some tokens return false instead of reverting on failure. Others (like USDT) return nothing at all - breaking standard Solidity interfaces."
Affected: transfer(), transferFrom(), approve()
👻
Phantom Function Calls
"Low-level .call() to an address with no code returns true by EVM design. The contract thinks the call succeeded when nothing actually happened."
Risk: Silent success on empty addresses

Frequency numbers for this bug class circulate widely - 10% of analyzed contracts in one dataset, 18% of audit findings in another - but the underlying samples are not published, so treat them as indicative rather than measured.

Unchecked return value attack diagram - Phantom deposits: the ledger climbs, the vault stays empty
Phantom deposits: the ledger climbs, the vault stays empty

The King of the Ether Throne incident

King of the Ether Throne was an early Ethereum game affected by unchecked .send() return values. Its post-mortem became a widely cited example of this vulnerability.

What was King of the Ether?

Launched in February 2016, King of the Ether Throne allowed a player to become the current "King" by sending more ETH than the previous player. The contract then attempted to compensate the previous king automatically.

The payment flow depended on every compensation transfer succeeding.

The bug: February 6-8, 2016

During the "Turbulent Age," the contract used .send() to pay deposed monarchs without checking the return value.

// THE VULNERABLE LINE
currentMonarch.etherAddress.send(compensation);
// Return value completely ignored!

When a player's address was a contract wallet (like an Ethereum Mist wallet), the 2,300 gas stipend forwarded by .send() wasn't enough for the wallet's fallback function. The send failed silently, but the game continued - recording the payment as successful and crowning a new king.

The impact

  • Three transactions failed silently during the Turbulent Age

  • 98.5 ETH had to be manually refunded (including a 7.77 ETH failed refund and a 42.273 ETH failed compensation)

  • Players lost their rightful ETH with no on-chain indication anything went wrong

  • The contract marked payments as complete even though money never moved

The King of the Ether post-mortem was published February 20, 2016, one of Ethereum's first public smart contract security disclosures and still the standard illustration of SWC-104.

The lesson

The direct fix was to check the return value of .send() and revert when it returned false.


How unchecked return value attacks work: step by step

The following sequence shows how a failed call can create inconsistent state.

Low-level call return behavior

Function Returns On Failure Gas Forwarded
.send(amount) bool Returns false (no revert) 2,300 gas
.call{value: amount}("") (bool, bytes) Returns false (no revert) All remaining gas
.delegatecall(data) (bool, bytes) Returns false (no revert) All remaining gas
.transfer(amount) Nothing Automatically reverts 2,300 gas
Check the result of every low-level call.
Unlike transfer(), functions such as .send() and .call() return false on failure and allow execution to continue.

The attack flow

Step What Happens Contract State
1 User calls a function (e.g., sendPrize()) Normal state
2 Contract uses .send() or .call() to transfer ETH Pending...
3 Transfer fails (recipient rejects, gas limit, etc.) Returns false
4 Return value is not checked false is ignored
5 Contract continues executing next lines State updates proceed
6 State records the transfer as complete Inconsistent state
7 Funds are stuck or available for theft Exploitable condition

The attack phases

1
Tap to reveal
Identify Unchecked External Call

The attacker finds a contract that uses .send() or .call() without checking the return value - or uses IERC20.transfer() without SafeERC20.

2
Tap to reveal
Trigger a Failed Transfer

The attacker deploys a contract whose receive() function deliberately reverts, or exploits gas limits to cause the transfer to fail. For ERC-20 attacks, they use a token that returns false on failure instead of reverting.

3
Tap to reveal
Exploit the Inconsistent State

Because the contract didn't check the return value, it recorded the transfer as successful. The attacker can now exploit the mismatch - withdrawing funds that were never actually sent, or depositing tokens that never arrived.

4
Tap to reveal
Drain or Lock Funds

Depending on the vulnerability, the attacker either steals funds (the contract pays out based on false state), locks funds permanently (the contract thinks it already paid), or gets free token deposits credited to their balance.

{
  "title": "🎬 Unchecked send: 'paid' on paper, prize stays stealable",
  "stage": { "width": 920, "height": 440 },
  "nodes": [
    { "id": "attacker", "label": "Attacker", "role": "calls withdrawLeftOver", "emoji": "🧑‍💻", "x": 60, "y": 200, "color": "red" },
    { "id": "lotto", "label": "The Lotto", "role": "pays via .send()", "emoji": "🎟️", "x": 440, "y": 60, "color": "cyan" },
    { "id": "winner", "label": "Winner (contract)", "role": "needs > 2300 gas", "emoji": "📜", "x": 440, "y": 330, "color": "purple" }
  ],
  "links": [
    { "from": "lotto", "to": "winner" },
    { "from": "attacker", "to": "lotto" },
    { "from": "lotto", "to": "attacker" }
  ],
  "nets": [
    { "id": "atk", "label": "Attacker" },
    { "id": "vault", "label": "Lotto balance" }
  ],
  "legend": [
    { "cls": "call", "label": "contract call" },
    { "cls": "token", "label": "ETH transfer" },
    { "cls": "sig", "label": "state write" },
    { "cls": "fail", "label": "failed / reverted" }
  ],
  "scenarios": {
    "Vulnerable (return value ignored)": [
      { "note": "The lotto holds a <b>10 ETH</b> prize and pays the winner with <b>.send()</b> - never checking the return value.", "hi": ["lotto"], "bal": { "lotto": "prize: 10 ETH", "winner": "0 ETH" }, "net": { "atk": "0", "vault": "10 ETH" } },
      { "note": "sendToWinner() runs. <b>.send()</b> forwards only 2,300 gas to the winner's contract.", "hi": ["lotto","winner"], "chip": { "from": "lotto", "to": "winner", "label": "📤 .send(10 ETH)", "cls": "token" } },
      { "note": "The winner's fallback needs more than 2,300 gas, so the send <b>fails and returns false</b> - the ETH never leaves.", "tone": "bad", "hi": ["winner"], "chip": { "from": "winner", "to": "lotto", "label": "✗ send failed", "cls": "fail" } },
      { "note": "But the boolean is ignored: the lotto sets <b>paidOut = true</b> anyway. The 10 ETH is still in the contract.", "tone": "bad", "hi": ["lotto"], "chip": { "from": "lotto", "to": "lotto", "label": "paidOut = true", "cls": "sig" }, "bal": { "lotto": "10 ETH (stuck)" } },
      { "note": "Now <b>withdrawLeftOver()</b> is unlocked (it only needs paidOut). Anyone calls it and drains the 'already paid' prize.", "tone": "bad", "hi": ["attacker","lotto"], "chip": { "from": "lotto", "to": "attacker", "label": "💸 withdraw 10 ETH", "cls": "token" }, "bal": { "lotto": "0 ETH", "attacker": "+10 ETH" }, "net": { "atk": "+10 ETH", "vault": "0 ETH" } }
    ],
    "Fixed (require the result)": [
      { "note": "Same setup, but the payout is now guarded with <b>require(success)</b> after the send (or a pull-payment pattern).", "hi": ["lotto"], "bal": { "lotto": "prize: 10 ETH", "winner": "0 ETH" }, "net": { "atk": "0", "vault": "10 ETH" } },
      { "note": "sendToWinner() runs and the send to the contract wallet fails, returning false - exactly as before.", "hi": ["lotto","winner"], "chip": { "from": "lotto", "to": "winner", "label": "📤 .send(10 ETH)", "cls": "token" } },
      { "note": "Now <b>require(success)</b> catches the false and <b>reverts the whole transaction</b>. paidOut is never set.", "tone": "ok", "hi": ["lotto"], "chip": { "from": "lotto", "to": "lotto", "label": "require(success) ✗ revert", "cls": "fail" }, "bal": { "lotto": "prize: 10 ETH" } },
      { "note": "Nothing is recorded as paid, so withdrawLeftOver() stays locked. The prize can never be drained against a phantom payout.", "tone": "ok", "hi": ["lotto"], "bal": { "lotto": "10 ETH safe" }, "net": { "atk": "0", "vault": "10 ETH" } }
    ]
  }
}

Unchecked return value vulnerable code examples

The examples progress from an unchecked .send() to token and delegatecall cases.

Example 1: Unchecked .send()

This is the basic pattern documented by SWC-104.

This contract is intentionally vulnerable. Never use this pattern in production.

// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
pragma solidity ^0.8.20;

contract VulnerableLotto {
    bool public paidOut = false;
    address public winner;
    uint256 public winAmount;

    constructor(address _winner) payable {
        winner = _winner;
        winAmount = msg.value;
    }

    function sendToWinner() public {
        require(!paidOut, "Already paid");

        // VULNERABILITY: Return value of send() is IGNORED!
        payable(winner).send(winAmount);

        // State updates even if send() failed
        paidOut = true;
    }

    function withdrawLeftOver() public {
        require(paidOut, "Winner not paid yet");
        // Anyone can drain remaining funds after "payout"
        payable(msg.sender).transfer(address(this).balance);
    }
}

Why is this vulnerable?

  1. .send() returns false if the recipient is a contract whose fallback uses more than 2,300 gas

  2. The return value is completely ignored

  3. paidOut is set to true regardless of whether the winner actually received ETH

  4. Anyone can then call withdrawLeftOver() and steal the funds


Example 2: Unchecked .call() for ETH transfer

Modern Solidity favors .call() over .send(), but it has the same unchecked return problem.

// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
pragma solidity ^0.8.20;

contract VulnerableVault {
    mapping(address => uint256) public balances;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // Update state first (good CEI practice)
        balances[msg.sender] = 0;

        // VULNERABILITY: Return value of .call() is IGNORED!
        payable(msg.sender).call{value: amount}("");

        // If call failed, user's balance is already zeroed out
        // Their ETH is now permanently locked in the contract
    }
}

Impact: If .call() fails (recipient contract reverts, out of gas, etc.), the user's balance is already set to 0. Their ETH is permanently locked in the contract - they can never withdraw it again.


Example 3: Unchecked ERC-20 return value

Missing ERC-20 return checks are a recurring finding in public audit contests such as Code4rena and Sherlock, largely because the code looks correct at first glance.

// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract VulnerableTokenVault {
    mapping(address => mapping(address => uint256)) public tokenBalances;

    function deposit(IERC20 token, uint256 amount) external {
        // VULNERABILITY: Return value of transferFrom() is NOT checked!
        // If token returns false instead of reverting, deposit is
        // credited even though no tokens were transferred
        token.transferFrom(msg.sender, address(this), amount);

        tokenBalances[msg.sender][address(token)] += amount;
    }

    function withdraw(IERC20 token, uint256 amount) external {
        require(tokenBalances[msg.sender][address(token)] >= amount, "Insufficient balance");
        tokenBalances[msg.sender][address(token)] -= amount;

        // VULNERABILITY: Same issue on withdrawal
        token.transfer(msg.sender, amount);
    }
}

Why is this dangerous?

  • Tokens like ZRX return false on failure instead of reverting. The deposit() credits the user's balance even when no tokens arrive.

  • Tokens like USDT don't return a value at all. With Solidity >= 0.4.22, the ABI decoder expects return data and may revert on successful transfers - making the contract completely incompatible with Tether.

  • An attacker can call deposit() with a token that returns false, get credited, then call withdraw() to drain real tokens deposited by other users.


Example 4: Unchecked .delegatecall()

Unchecked delegatecall return values are especially dangerous because failed delegatecalls can leave the contract in a completely inconsistent state.

// VULNERABLE CONTRACT - DO NOT USE IN PRODUCTION
pragma solidity ^0.8.20;

contract VulnerableProxy {
    address public implementation;

    function forward(bytes memory _data) public {
        // VULNERABILITY: delegatecall return value IGNORED!
        implementation.delegatecall(_data);
        // If the delegatecall fails, execution continues
        // Contract state may be partially modified
    }
}

OWASP cites this exact pattern under SC06:2025 as an example of unchecked external calls. The Punk Protocol hack exploited a similar vulnerability to drain $8.9 million in August 2021.


How to prevent unchecked return value vulnerabilities: best practices

Preventing unchecked return values requires different techniques depending on whether you're sending ETH or interacting with tokens.

1. Always check return values of low-level calls

This is the most fundamental fix. Every .call(), .send(), .delegatecall(), and .staticcall() must have its return value captured and verified:

// CORRECT: Always check the return value
(bool success, ) = recipient.call{value: amount}("");
require(success, "ETH transfer failed");

2. Use OpenZeppelin's SafeERC20 for all token interactions

SafeERC20 handles tokens that return false on failure and non-standard tokens, such as USDT, that return no value.

import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

using SafeERC20 for IERC20;

// Instead of: token.transfer(recipient, amount);
token.safeTransfer(recipient, amount);

// Instead of: token.transferFrom(sender, recipient, amount);
token.safeTransferFrom(sender, recipient, amount);

3. Use the checks-effects-interactions pattern

Structure every function so that state updates happen BEFORE external calls, and return values are always verified AFTER:

function withdraw(uint256 amount) external {
    // CHECKS
    require(balances[msg.sender] >= amount, "Insufficient balance");

    // EFFECTS
    balances[msg.sender] -= amount;

    // INTERACTIONS (with return value check!)
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

Prevention effectiveness comparison

Effectiveness95/100

What it does: Wraps transfer(), transferFrom(), and approve() with low-level calls that handle both standard and non-standard token return values.

When to use: Use this wrapper for contracts that interact with ERC-20 tokens, especially tokens with non-standard return behavior.

Limitation: Does not protect against tokens with truly pathological behavior (e.g., Tether Gold returning false on success).

Effectiveness90/100

What it does: Captures the boolean return from .call(), .send(), or .delegatecall() and reverts if it's false.

When to use: Every low-level call in every contract.

Limitation: Requires developer discipline. Also, low-level calls return true if the target has no code (EVM design) - you must verify target code existence separately.

Effectiveness70/100

What it does: Slither has 4 dedicated detectors: unchecked-transfer, unchecked-lowlevel, unchecked-send, unused-return. Mythril detects SWC-104 via symbolic execution.

When to use: As part of CI/CD pipeline - catches issues before deployment.

Limitation: Cannot catch all edge cases. May produce false positives. Should be combined with manual review.

Effectiveness40/100

What it does: .transfer() auto-reverts on failure - no return value to check. But it only forwards 2,300 gas.

When to use: Legacy contracts only. Avoid in new development.

Limitation: The 2,300 gas limit broke after the Istanbul hard fork. Recipients that are smart contracts may not have enough gas to execute their receive logic. Consensys recommends using .call() instead.

Unchecked return value attack diagram - Check the boolean, or the ledger starts lying
Check the boolean, or the ledger starts lying

Secure code examples

Secure ETH vault with return value checking

// SECURE CONTRACT - ETH transfer return check
pragma solidity ^0.8.20;

contract SecureVault {
    mapping(address => uint256) public balances;

    event Deposit(address indexed user, uint256 amount);
    event Withdrawal(address indexed user, uint256 amount);

    function deposit() public payable {
        require(msg.value > 0, "Must deposit more than 0");
        balances[msg.sender] += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    function withdraw() public {
        uint256 amount = balances[msg.sender];

        // STEP 1: CHECKS
        require(amount > 0, "No balance to withdraw");

        // STEP 2: EFFECTS - Update state BEFORE external call
        balances[msg.sender] = 0;

        // STEP 3: INTERACTIONS - Check the return value!
        (bool success, ) = payable(msg.sender).call{value: amount}("");
        require(success, "ETH transfer failed");

        emit Withdrawal(msg.sender, amount);
    }
}

Security features at a glance

Feature Protection
(bool success, ) capture Captures return value
require(success, ...) Reverts on failed transfer
CEI pattern State updated before external call
Events Transparency and monitoring

Secure token vault with SafeERC20

// SECURE CONTRACT - SafeERC20 example
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

contract SecureTokenVault {
    using SafeERC20 for IERC20;

    mapping(address => mapping(address => uint256)) public tokenBalances;

    event TokenDeposit(address indexed user, address indexed token, uint256 amount);
    event TokenWithdrawal(address indexed user, address indexed token, uint256 amount);

    function deposit(IERC20 token, uint256 amount) external {
        // SafeERC20 handles:
        // 1. Tokens that return false (e.g., ZRX)
        // 2. Tokens that return nothing (e.g., USDT)
        // 3. Tokens that revert on failure
        token.safeTransferFrom(msg.sender, address(this), amount);

        tokenBalances[msg.sender][address(token)] += amount;
        emit TokenDeposit(msg.sender, address(token), amount);
    }

    function withdraw(IERC20 token, uint256 amount) external {
        require(tokenBalances[msg.sender][address(token)] >= amount, "Insufficient balance");

        tokenBalances[msg.sender][address(token)] -= amount;
        token.safeTransfer(msg.sender, amount);

        emit TokenWithdrawal(msg.sender, address(token), amount);
    }
}

This pattern supports standard ERC-20 tokens as well as commonly encountered tokens with missing or false return values, including USDT, BNB, and OMG.


Non-standard ERC-20 return behavior

The ERC-20 ecosystem includes non-standard implementations that return false, return no data, or impose additional approval requirements.

The missing return value bug - 130+ tokens affected

In 2018, researcher Lukas Cremer discovered that at least 130 deployed ERC-20 tokens on Ethereum had missing return values on transfer(), transferFrom(), or approve(). These tokens were built from early OpenZeppelin templates that didn't return true on success.

Notable affected tokens:

Token Issue Market Cap Impact
USDT (Tether) transfer() returns void $80B+ market cap
BNB (Binance Coin) transfer() returns void Major exchange token
OMG (OmiseGo) transfer() returns void Top 50 at time of discovery
KNC (Kyber Network) Missing returns on some functions Major DeFi token

Real-world impact: BNB tokens were stuck in Uniswap V1 because the DEX's contract couldn't handle the non-standard return value.

How SafeERC20 handles return data

SafeERC20 uses a three-way logic check that handles every edge case:

// Simplified internal logic of SafeERC20
function _callOptionalReturn(IERC20 token, bytes memory data) private {
    // 1. Use low-level call to bypass Solidity's return data check
    (bool success, bytes memory returndata) = address(token).call(data);
    require(success, "SafeERC20: low-level call failed");

    // 2. THE KEY CHECK: accept empty return OR decoded true
    if (returndata.length > 0) {
        require(abi.decode(returndata, (bool)),
            "SafeERC20: ERC20 operation did not succeed");
    }
    // returndata.length == 0 && success = assumed successful (USDT path)
}

This handles:

  • Standard tokens (return true) - decoded and verified

  • Tokens returning false - caught and reverted

  • Non-standard tokens like USDT (return nothing) - accepted if call succeeded

  • Reverts - propagated upward

Comparing defense libraries

Vulnerable

Raw IERC20 Calls

Calling token.transfer() directly breaks with USDT (no return value) and misses failures from tokens that return false instead of reverting. Never use directly.

Partial Defense

Solmate SafeTransferLib

Handles return values correctly but does not check code size of the token address. If the token address has no code, safeTransfer() returns success even though nothing happened.

Recommended

OpenZeppelin SafeERC20

Handles all edge cases: missing returns, false returns, reverts, and verifies code existence. Use forceApprove() for USDT's zero-first approval requirement.

The USDT approval trap

USDT has an additional non-standard behavior: it requires the allowance to be set to 0 before changing it to a new non-zero value. This means safeApprove() fails if the current allowance is non-zero. OpenZeppelin provides forceApprove() to handle this:

// This FAILS with USDT if current allowance is non-zero:
token.safeApprove(spender, newAmount); // REVERTS!

// This WORKS with USDT:
token.forceApprove(spender, newAmount);
// Internally: first sets allowance to 0, then to newAmount

Common misconceptions

?

"Solidity 0.8+ automatically handles unchecked return values."

Tap to reveal
MYTH

Solidity 0.8+ only added overflow/underflow protection. Low-level calls (.call(), .send(), .delegatecall()) still return false on failure without reverting. You must manually check every return value.

?

"Using .transfer() instead of .call() eliminates the unchecked return problem."

Tap to reveal
MYTH

.transfer() auto-reverts, but only forwards 2,300 gas. After the Istanbul hard fork, this is too little for many smart contract recipients. Consensys recommends .call() with return value checking instead.

?

"If I call a function on a contract that doesn't exist, .call() will return false."

Tap to reveal
MYTH

By EVM design, .call() and .delegatecall() return true when called on an address with no code. Your contract will think the call succeeded when nothing happened. Always verify target.code.length > 0 before calling.

?

"All ERC-20 tokens revert on transfer failure, so checking return values is optional."

Tap to reveal
MYTH

Many tokens (ZRX, BAT) return false instead of reverting. Others (USDT, BNB, OMG) return nothing at all. At least 130 tokens have this "missing return value bug." Always use OpenZeppelin's SafeERC20.


Unchecked return values relate to several external-call vulnerability classes.

Reentrancy attacks share a common root cause with unchecked return values: the dangers of external calls. While reentrancy exploits the callback mechanism during ETH transfers, unchecked returns exploit the silent failure of those same transfers. A contract vulnerable to one is often vulnerable to both.

Delegatecall and call attacks are directly connected - the delegatecall function is itself a low-level call whose return value can go unchecked. The Punk Protocol hack combined an unprotected initialize() function with unchecked delegatecall returns to drain $8.9 million.

Denial of service attacks can also be triggered by unchecked returns. When a contract uses require() on a .send() return without a fallback, a malicious recipient can force all transactions to revert - permanently blocking the contract for all users.


Test your unchecked return value knowledge

5 questions on return values, SafeERC20, and low-level calls

Question 1 of 5
= 0.4.22 expects return data, causing contracts to revert on successful USDT transfers unless SafeERC20 is used."}, {"question": "What happens when you call .call() on an address with no contract code?", "options": ["It returns false", "It reverts", "It returns true (success)", "It throws an out-of-gas error"], "correct": 2, "explanation": "By EVM design, .call() returns true when called on an address with no code (EOA or destroyed contract). This is a dangerous phantom success that can trick contracts into thinking a call succeeded."}, {"question": "Which tool has 4 dedicated detectors for unchecked return values?", "options": ["Mythril", "Slither", "Remix", "Hardhat"], "correct": 1, "explanation": "Slither (by Trail of Bits) has 4 dedicated detectors: unchecked-transfer (High), unchecked-lowlevel (Medium), unchecked-send (Medium), and unused-return (Medium)."}, {"question": "What is the recommended way to handle ERC-20 token transfers in 2025?", "options": ["Use token.transfer() with require()", "Use address.transfer() for all tokens", "Use OpenZeppelin SafeERC20 with safeTransfer()", "Use low-level call with abi.encode"], "correct": 2, "explanation": "OpenZeppelin SafeERC20 is the industry standard. It handles standard tokens, tokens that return false, and non-standard tokens like USDT that return nothing - all in a single library."} ]'>

Frequently asked questions about unchecked return values

An unchecked return value is when your smart contract sends ETH or calls another contract but doesn't verify whether the operation actually succeeded. Low-level functions like .send() and .call() return false on failure instead of reverting. If you ignore that return value, your contract continues as if everything worked - even when it didn't.

No. Solidity 0.8 only added automatic overflow/underflow protection. Low-level calls (.call(), .send(), .delegatecall(), .staticcall()) still return false on failure without reverting. You must always manually check the return value with require(success).

Early ERC-20 implementations (before the standard was finalized) didn't consistently return a boolean. At least 130 tokens - including USDT, BNB, and OMG - were deployed with transfer() functions that return nothing. This creates a compatibility issue with Solidity contracts that expect a bool return. Use OpenZeppelin's SafeERC20 to handle all variants.

.send() returns false on failure (2,300 gas). .transfer() auto-reverts on failure (2,300 gas). .call() returns false on failure (forwards all remaining gas). In modern Solidity, .call() with explicit return value checking is the recommended approach, because the 2,300 gas limit of .send() and .transfer() can cause failures with smart contract recipients.

Use static analysis tools like Slither (run slither . --detect unchecked-transfer,unchecked-lowlevel,unchecked-send) or Mythril (detects SWC-104). The Solidity compiler also warns about unused return values from low-level calls. For token interactions, ensure you're using SafeERC20 everywhere - a simple grep for .transfer( and .transferFrom( without the safe prefix can reveal vulnerable patterns.


Quick reference: unchecked return value prevention checklist

Before deploying any contract that makes external calls or interacts with tokens:

  • Check every .call() return value - (bool success, ) = addr.call{value: amount}(""); require(success);

  • Check every .send() return value - or better yet, replace .send() with .call()

  • Check every .delegatecall() return value - failed delegatecalls are especially dangerous

  • Use SafeERC20 for ALL token interactions - safeTransfer(), safeTransferFrom(), forceApprove()

  • Verify target address has code before low-level calls - require(target.code.length > 0)

  • Run Slither with unchecked-transfer, unchecked-lowlevel, unchecked-send detectors

  • Test with non-standard tokens - include USDT-like tokens in your test suite

  • Get professional audits before mainnet deployment


What to check in your own code

This is one of the cheapest bug classes to close. The Solidity compiler warns about unused return values from low-level calls, Slither flags them with four dedicated detectors, and the fix is usually one line: require(success).

For token operations, use SafeERC20 rather than assuming every token follows the same return convention.


Practice external-call review

External-call review becomes easier after testing failed ETH and token transfers against both vulnerable and fixed contracts.

The Smart Contract Hacking course includes:

  • 320+ videos covering unchecked returns, reentrancy, flash loans, oracle manipulation, and related topics

  • 40+ hands-on exercises exploiting and securing real contracts

  • Instruction from JohnnyTime, Trust, Pashov, and other security researchers

  • A 2,000+ member Discord for technical discussion and peer support

  • SSCH Certification for participants who complete the requirements

Review student outcomes and success stories to decide whether the training fits your goals.

View the course curriculum and enrollment options

Sources and editorial notes

Reviewed by JohnnyTime. Last updated .

Master Unchecked Return Value Attacks in a safe lab

Practice the exploit path, debug the vulnerable code, and learn the prevention workflow auditors use in real reviews.

Exploit setup Root-cause tracing Patch review
Practice Unchecked Return Value Attacks Free Trial