Unchecked Return Value Attacks
Smart Contract Vulnerability Deep Dive
Unchecked Return Values in Solidity: The Silent Protocol Killer (And How to Fix It)
Unchecked return values are easily one of the most deceptive vulnerabilities in smart contract development. Your code compiles cleanly, deploys without a hitch, and looks like it works perfectly - while silently swallowing failed transactions in the background.
When an ETH transfer fails and nobody notices, or a token deposit records credit without actually receiving tokens, the consequences are disastrous. We're talking permanently locked funds, broken accounting, and complete protocol drainage.
In this guide, we'll break down exactly how functions like send() and call() fail in complete silence. We’ll revisit the infamous King of the Ether Throne exploit, analyze real-world audit findings involving non-standard ERC-20 tokens like USDT, and show you how to write production-ready code.
What Exactly Is an Unchecked Return Value Vulnerability?
An unchecked return value vulnerability happens when a smart contract makes an external call - like sending ETH to a user or interacting with another contract - but forgets to verify if that call actually worked.
Here’s the catch: in Solidity, low-level functions like .send() and .call() do not automatically revert when they fail. Instead, they just quietly return a boolean false. If a developer ignores this return value, the contract keeps blindly trucking along, executing the next lines of code as if everything went perfectly.
The Broken ATM Analogy 🏧
Imagine using an ATM that sometimes jams. But instead of showing a glaring red "Out of Order" or "Error" screen, it just happily prints a receipt saying: "Withdrawal Successful!"
You walk away thinking you got your cash. Your bank account deducts the balance. But the money is still jammed inside the machine. That’s exactly what happens when a smart contract ignores the return value of a failed ETH transfer. The contract updates its internal accounting, but the actual money never moves.
Why Unchecked Return Values Are a Stealthy Threat 🥷
Unchecked return values have been crashing protocols since Ethereum’s earliest days. What's scary is that instead of dying out, they are actually climbing the security vulnerability rankings:
| 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 |
This vulnerability blasted its way from #10 up to #6 in the 2025 OWASP Smart Contract Top 10. That's a massive red flag that developers are still routinely blowing up their protocols with this entirely preventable bug.
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
Fact check: A whopping 10% of all analyzed smart contracts suffer from unchecked call vulnerabilities, and 18% of professional audit findings catch unchecked external calls in the wild.

The King of the Ether Throne: A Crypto History Lesson 👑💥
The King of the Ether Throne wasn't just a weird early Ethereum game - it was one of the blockchain’s very first security disasters. It publicly exposed an unchecked return value vulnerability that developers still copy-paste into existence today.
What Was King of the Ether?
Launched way back in February 2016, King of the Ether Throne was essentially a financial game of tag. You sent more ETH to the contract than the current "King," and BAM - you successfully purchased the throne. The contract was then supposed to automatically forward your payment to the old king as their compensation.
The concept was brilliant in its simplicity: Pay a premium to rule, and the deposed king walks away wealthy. What could go wrong?
The Bug: February 6-8, 2016
During the "Turbulent Age," the contract used .send() to pay deposed monarchs - but never checked 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 - becoming one of Ethereum's first public smart contract security disclosures and a textbook example of SWC-104.
The Lesson
The game's developers later stated the fix was simple: always check the return value of .send(). This single missing line of code - require(success) - would have prevented every failed payment.
Want to exploit unchecked return value vulnerabilities yourself in a safe lab? The Smart Contract Hacking course includes hands-on exercises where you'll attack vulnerable contracts - exactly like real security researchers do.
How Unchecked Return Value Attacks Work: Step-by-Step
Understanding the mechanics is crucial. Let's break down how unchecked return values lead to exploitable conditions.
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 |
Unlike
transfer(), functions like .send() and .call() return false on failure and continue execution. If you don't check that boolean, your contract has a silent vulnerability.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
The attacker finds a contract that uses .send() or .call() without checking the return value - or uses IERC20.transfer() without SafeERC20.
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.
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.
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
Let's examine real vulnerability patterns, starting simple and progressing to more realistic scenarios.
Example 1: The Classic Unchecked .send() (Beginner)
This is the simplest and most common pattern - straight from the SWC-104 registry.
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?
-
.send()returnsfalseif the recipient is a contract whose fallback uses more than 2,300 gas -
The return value is completely ignored
-
paidOutis set totrueregardless of whether the winner actually received ETH -
Anyone can then call
withdrawLeftOver()and steal the funds
Example 2: Unchecked .call() for ETH Transfer (Intermediate)
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 (Advanced - Real Audit Finding)
This pattern is frequently flagged in Code4rena, Sherlock, and professional security audits. It's especially dangerous because it 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
falseon failure instead of reverting. Thedeposit()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 returnsfalse, get credited, then callwithdraw()to drain real tokens deposited by other users.
Example 4: Unchecked .delegatecall() (Advanced - OWASP SC06)
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
}
}
This exact pattern is cited by OWASP SC06:2025 as a textbook example of unchecked external calls. The Punk Protocol hack exploited a similar vulnerability to drain $8.9 million in August 2021.
Ready to write exploits like these yourself? The Smart Contract Hacking course covers unchecked return values alongside reentrancy, flash loans, oracle manipulation, and more. Learn from JohnnyTime (12+ years in cybersecurity) and Trust (#1 Code4rena warden).
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 is the industry-standard solution for ERC-20 token interactions. It handles both tokens that return false on failure AND non-standard tokens (like USDT) that return nothing.
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
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: Every contract that interacts with ERC-20 tokens. Non-negotiable.
Limitation: Does not protect against tokens with truly pathological behavior (e.g., Tether Gold returning false on success).
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.
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.
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.

Secure Code Examples
Secure ETH Vault with Return Value Checking
// SECURE CONTRACT - Production-ready
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 - Production-ready
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 implementation handles USDT, BNB, OMG, and all 130+ known non-standard ERC-20 tokens. It would have prevented every unchecked return value audit finding in recent Code4rena and Sherlock contests.
Advanced Variations: The ERC-20 Minefield
The unchecked return value problem goes far deeper than .send(). The ERC-20 token ecosystem is full of non-standard implementations that create subtle but critical vulnerabilities.
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 Actually Works Under the Hood
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
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.
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.
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
These advanced patterns separate junior auditors from senior researchers. The Smart Contract Hacking course covers unchecked return values, non-standard token interactions, and SafeERC20 alongside flash loans, oracle manipulation, and more. Join 2,000+ security researchers in our Discord community.
Common Misconceptions
"Solidity 0.8+ automatically handles unchecked return values."
Tap to revealSolidity 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.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 revealBy 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 revealMany 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.
Related Vulnerabilities
Unchecked return values don't exist in isolation - they're closely related to other critical smart contract 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 - How well do you really know this vulnerability?
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-senddetectors -
Test with non-standard tokens - include USDT-like tokens in your test suite
-
Get professional audits before mainnet deployment
Conclusion: The Most Avoidable Vulnerability in Smart Contracts
The unchecked return value vulnerability is uniquely frustrating because:
-
The Solidity compiler warns you about it
-
Static analysis tools detect it automatically
-
The fix is literally one line of code -
require(success) -
Yet it still appears in 10% of deployed contracts and climbed to OWASP #6 in 2025
The pattern is simple: every external call can fail. Every return value must be checked. Every token interaction should use SafeERC20.
Don't let a missing require(success) be the reason your protocol loses millions.
Take Your Security Skills to the Next Level
Understanding unchecked return values is just the beginning. To become a professional smart contract auditor, you need hands-on experience with real exploit scenarios.
The Smart Contract Hacking course delivers:
-
320+ videos covering unchecked returns, reentrancy, flash loans, oracle manipulation, and more
-
40+ hands-on exercises exploiting and securing real contracts
-
Expert instruction from JohnnyTime, Trust (#1 Code4rena warden), and Pashov
-
2,000+ member Discord for support and job opportunities
-
SSCH Certification to validate your expertise
Our students win audit contests, land security jobs, and earn significant bug bounties. See their success stories.
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.