Web3 & smart contracts
Verified by ROOTVIEL12 minUnchecked External Calls
A low-level call, send, or an external interaction whose success boolean (or revert) is ignored. The call silently fails, execution continues as if it succeeded, and the contract’s accounting diverges from reality — recording a payout that never happened.
Classification
- Domain
- Web3 & smart contracts
- ROOTVIEL support
- Verified by ROOTVIEL — A deterministic, LLM-free verifier settles a specific property under stated assumptions — either a structural property over a graph, or an effect reproduced in execution. It does not by itself mean the whole system is secure, nor that an end-to-end exploit was run beyond what the evidence scope states.
- Evidence scope
- Observed in execution — Reproduced in an isolated sandbox or local fork — the effect was actually triggered under stated conditions.
- Analysis family
- Rootchain
- CWE
- CWE-252
- OWASP
- SC06
Definition
An unchecked external call is any outbound interaction whose failure signal is discarded. Solidity’s low-level primitives — address.call, address.send, delegatecall, staticcall — do not revert when the callee fails; they return a boolean that is false on failure. If code ignores that boolean, a failed transfer or a reverted callee looks, to the rest of the function, exactly like success. The function then proceeds to update state — crediting, debiting, marking as paid — on a lie.
The same class appears when a contract calls an external function expecting it to revert on failure, but the callee returns a status value instead (the classic non-reverting token transfer that returns false), or returns nothing where the caller’s interface expected a bool. The unifying defect: a failure that the EVM made available is not acted upon.
Context
This surfaces anywhere a contract moves value or delegates work to another address: payouts, reward distribution, multi-recipient batches, integrations with external tokens, and forwarding patterns. It is especially dangerous where accounting is updated after the call on the assumption it succeeded.
- ▸Native-value payouts via send/transfer or call whose boolean result is not checked.
- ▸ERC-20 transfers where the returned bool is ignored, including tokens that return false instead of reverting, and non-standard tokens that return no value at all.
- ▸Batch distributions where one recipient’s failure is swallowed and the loop records all recipients as paid.
Solidity & EVM fundamentals
At the EVM level, the CALL family of opcodes returns a success flag (0 or 1) on the stack; they do not propagate the callee’s revert to the caller automatically. High-level Solidity calls to known contract types DO bubble reverts, but the low-level primitives return a boolean and leave the decision to the programmer.
- call / send return a bool
- address.call{value: x}("") and address.send(x) return (bool success, ...). On failure, success is false and execution continues; nothing reverts unless you make it.
- Low-level vs high-level
- A typed external call (IContract(a).f()) reverts if f() reverts. A low-level call(...) does not — it hands you a boolean. Mixing these mental models is a common source of the bug.
- Non-standard token returns
- The ERC-20 spec says transfer returns bool, but some tokens revert on failure, some return false, and some return no data. A caller that assumes one behavior silently mishandles the others — hence the SafeERC20 wrapper pattern.
- 63/64 gas rule (EIP-150)
- A call forwards at most 63/64 of remaining gas; a callee can be starved and fail for gas reasons, making silent failure a live possibility even for well-formed recipients.
The correct mental model: a low-level call’s boolean is not optional telemetry — it is the only signal that the interaction happened. Ignoring it is equivalent to writing to a database without checking whether the write committed.
Root cause
The root cause is treating an outcome-bearing operation as fire-and-forget: the contract updates internal accounting as though an external effect occurred, without confirming it did. The EVM faithfully reports failure via the boolean (or via a revert that a typed call would bubble); the code simply does not consume that report.
A secondary root cause is an interface-mismatch assumption — assuming a callee reverts on failure when it actually returns a status, or assuming it returns a bool when it returns nothing — so the caller’s success/failure handling is wrong even when it exists.
How the attack works, step by step
- 1A function performs an external effect (pay a recipient, transfer a token) using a low-level call and does not check the returned boolean, or calls a token and ignores its return value.
- 2The external effect fails: the recipient reverts, is out of gas, is a non-standard token returning false, or simply cannot accept the transfer.
- 3Because the failure signal is discarded, the function continues as if the effect succeeded and updates state — marks an invoice paid, decrements an owed balance, emits a success event, or advances to the next recipient in a batch.
- 4The contract’s recorded accounting now diverges from reality: it believes value left that never did (or that a recipient was served when they were not).
- 5Depending on the logic, this corrupts balances, strands funds, double-credits, or lets a crafted recipient force a favorable mis-accounting — an attacker may deliberately cause the call to fail to exploit the divergence.
Vulnerable example
A synthetic payout function. It sends native value with a low-level call but discards the boolean, then zeroes the owed balance regardless. If the recipient rejects the transfer (or is starved of gas), the balance is cleared though no value was received — the owed amount is lost to the accounting.
synthetic — do not use; illustrates the flaw// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IERC20Like { function transfer(address to, uint256 amount) external returns (bool); } // UNSAFE: success of the external call is ignored. contract VulnerablePayouts { mapping(address => uint256) public owed; function claimNative() external { uint256 amount = owed[msg.sender]; require(amount > 0, "nothing owed"); // Boolean result DISCARDED: a failed transfer looks like success. msg.sender.call{value: amount}(""); // State updated as if paid, even when the call failed. owed[msg.sender] = 0; } function distributeToken(IERC20Like token, address[] calldata to, uint256 each) external { for (uint256 i = 0; i < to.length; i++) { // Return value ignored: a token that returns false (or reverts // non-standardly) can leave recipients unpaid while the loop // continues and the caller believes distribution succeeded. token.transfer(to[i], each); } } }
In claimNative, a recipient whose receive() reverts (or runs out of the forwarded gas) causes the transfer to fail, yet owed is zeroed. In distributeToken, a non-standard token that returns false silently skips recipients. Both corrupt accounting without any revert.
Impact
- ▸Integrity of accounting: internal ledgers record effects that did not happen (funds "paid" but not received), or miss effects that did.
- ▸Fund loss / stranding: owed balances can be zeroed without payment, or value can accumulate unpaid while the system reports success.
- ▸Attacker-induced divergence: a recipient that deliberately fails the call can exploit the mis-accounting (e.g. to be marked settled without paying, depending on logic direction).
- ▸Silent and late-surfacing: because nothing reverts, the defect can go unnoticed until balances are reconciled, making it hard to detect post hoc.
Honesty: an ignored return value is a SIGNAL that accounting may diverge. It becomes a FINDING only when a controlled run shows the external effect failing while state is updated as though it succeeded, producing a concrete, verifiable divergence.
Detection: static, dynamic, symbolic, fuzzing, formal
- Static
- Flag every low-level call/send/delegatecall/staticcall whose returned boolean is not read, and every external token call whose return value is ignored. Linters and analyzers detect this reliably as a structural pattern — a strong heuristic SIGNAL.
- Dynamic
- In a controlled environment, supply a recipient/token that fails (reverts, returns false, consumes all gas) and observe whether the contract updates state as though it succeeded.
- Symbolic
- Ask whether a path exists where the call returns failure yet subsequent state writes execute unconditionally; the solver confirms the unchecked branch is reachable.
- Fuzzing / invariant
- Assert "internal recorded balances match actual transferred value" and fuzz with adversarial recipients/tokens; a divergence is a counterexample.
- Formal
- Prove that every state update predicated on an external effect is guarded by a check that the effect succeeded — i.e. no write is reachable on the failure branch. A proof is strong within its model.
Statuses remain distinct: a linter match is a heuristic SIGNAL (very reliable here, but still structural); a fuzzer divergence is an artifact to replay; a lab reproduction is EVIDENCE; a proof is the only near-guarantee, within its model. No tool output licenses the word "safe".
How autonomous agents investigate it
An autonomous investigator treats each unchecked call as a hypothesis "a failed effect here leads to a wrong state write" and tries to realize that failure concretely, rather than concluding from the pattern alone.
- 1Observation: enumerate every external call and classify it — boolean checked / unchecked, typed / low-level, token return handled / ignored — and note which state writes follow each.
- 2Hypothesis: for each unchecked site, propose "forcing this call to fail corrupts accounting" as a lead.
- 3Execution paths: deploy an adversarial recipient (reverting receive, gas sink) or a non-standard token (returns false / returns nothing) on a disposable local chain or local fork, and drive the function.
- 4Fuzzing / invariant checks: assert conservation (recorded == actually transferred) and fuzz recipient/token behavior to surface a divergence.
- 5Adversarial review: refute the lead — is the failure path actually reachable? does the state write really depend on the effect, or is it independently correct? Refuted leads are discarded with a note.
A static match is a hypothesis; a fuzzer divergence is an artifact to replay; only a replayed, deterministically verified accounting divergence is a finding. An LLM calling the attack "plausible" decides nothing.
How Rootchain analyzes it
Rootchain is evidence-first. A linter or model flagging an unchecked call is a lead, not a result — the pattern is very commonly benign (e.g. a best-effort notification). Rootchain promotes it to a Finding only by executing a controlled attack: on a disposable LOCAL blockchain or a LOCAL fork of real on-chain state, it makes the external effect fail and shows the contract updating state as if it succeeded, confirmed by a deterministic, AI-free verifier. No mode attacks a live public network.
- Epistemic ladder
- Observation → Hypothesis → Experiment → Evidence → Verification → Finding. Evidence here is a run where the effect provably fails and recorded accounting provably diverges from the real transfer.
- AI composes, never decides
- The model proposes adversarial recipients/tokens and the call sequence; a deterministic verifier compares recorded balances against actual transfers and decides success/failure without the model.
- Signal vs Finding
- An unchecked-return match is a Signal with provenance. A Finding requires the reproducible controlled demonstration of a real divergence.
This is a mature execute-and-verify family. Still, if the only reachable failure causes no observable divergence (the write is benign), Rootchain reports the lead as UNKNOWN or benign-by-evidence — never "secure". A clean run means the attempted corruption failed within scope.
Mitigation
Act on every failure signal in deterministic code, and prefer patterns that cannot silently diverge.
- ▸Always check the boolean from low-level calls and revert (or handle) on failure: require(success, "...").
- ▸Prefer typed/high-level external calls where reverts bubble automatically; use SafeERC20-style wrappers for tokens so non-standard return behaviors are normalized to revert-on-failure.
- ▸Apply Checks-Effects-Interactions so state is not optimistically updated before a confirmed effect; or, for payouts, use the pull-payment pattern so a failed send cannot corrupt a shared loop.
- ▸In batch distributions, isolate per-recipient failure (record failures explicitly, let recipients re-claim) rather than marking all as paid.
Corrected example
The corrected version checks the boolean and reverts on a failed native transfer, and uses a return-value-aware token call that reverts if the token signals failure or returns malformed data. Why it works: a failed effect now reverts the whole transaction, so state is never updated on a lie; the token helper normalizes non-standard returns. Limits follow.
synthetic — illustrates the fix and its limits// SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IERC20Like { function transfer(address to, uint256 amount) external returns (bool); } contract SaferPayouts { mapping(address => uint256) public owed; function claimNative() external { uint256 amount = owed[msg.sender]; require(amount > 0, "nothing owed"); // Effect FIRST (CEI): zero before paying so a reverting recipient // cannot be used to re-enter or to leave state optimistic. owed[msg.sender] = 0; // Check the boolean: a failed transfer reverts the whole tx, // restoring owed to its prior value. (bool ok, ) = msg.sender.call{value: amount}(""); require(ok, "transfer failed"); } // Normalizes non-standard tokens: reverts on false OR malformed return. function safeTransfer(IERC20Like token, address to, uint256 amount) internal { (bool ok, bytes memory data) = address(token).call( abi.encodeWithSelector(token.transfer.selector, to, amount) ); require(ok && (data.length == 0 || abi.decode(data, (bool))), "token transfer failed"); } function distributeToken(IERC20Like token, address[] calldata to, uint256 each) external { for (uint256 i = 0; i < to.length; i++) { safeTransfer(token, to[i], each); // any failure reverts the batch } } }
Limits of the fix: reverting the whole batch on one failed recipient trades silent divergence for a denial-of-service surface (a single bad recipient blocks all) — see the DoS & Gas Exhaustion class; a pull-payment design avoids that trade-off. Also, checking success proves the call did not revert; it does not prove the callee did what its name implies.
Verifying the fix
Verify by forcing failures in a controlled environment and confirming the contract no longer updates state on failure, with reproducible tests (conceptually Foundry and Echidna).
- 1Reproduction test (must now fail to corrupt): deploy a recipient whose receive() reverts and call claimNative; assert the whole call reverts and owed is unchanged.
- 2Token behavior matrix: test safeTransfer against a standard token, a token returning false, and a token returning no data; assert it reverts on the first two and succeeds on the compliant one.
- 3Invariant property (Echidna-style): assert recorded accounting equals actually transferred value across fuzzed recipient/token behaviors; it should hold within the budget.
- 4Baseline check: confirm compliant recipients and tokens are paid normally and events fire only on real success.
Report honestly: "forced failures now revert and accounting stayed consistent within the tested budget" is a reproduced-defense result, not immunity. A clean fuzzing run is UNKNOWN beyond its search space; only a proof that no state write is reachable on a failure branch approaches a guarantee, within its model.
Limitations
- ▸Checking success proves non-revert, not correct behavior of the callee — a compliant-looking token can still behave adversarially within its own logic.
- ▸Reverting on any failure can introduce denial-of-service (one bad recipient blocks a batch); the right trade-off depends on the design.
- ▸Static detection is reliable for the pattern but cannot tell a benign best-effort call from a safety-critical one; experimental confirmation is required.
- ▸Fuzzing/symbolic search is bounded; no divergence found means UNKNOWN, not safety.
References
- [1]SWC-104 — Unchecked Call Return Value (SWC Registry)
- [2]Solidity docs — Security Considerations (sending and receiving Ether, low-level calls)
- [3]OWASP Smart Contract Top 10 — SC06 Unchecked External Calls
- [4]MITRE CWE-252 — Unchecked Return Value
- [5]OpenZeppelin — SafeERC20 (normalizing token return values)
- [6]EIP-150 — Gas cost changes (the 63/64 call-gas rule)
References point to public, well-known technical sources for further study.
Related articles
The same external call can also be a reentrancy vector.
Denial of Service & Gas ExhaustionReverting on failure can let one recipient block a batch.
Delegatecall MisuseA distinct hazard of another low-level primitive.
How Rootchain proves findingsEvidence-first execute-and-verify methodology.
