Unchecked External Call Exploit Generator
Sigvex exploit generator that validates unchecked call return values by sending ETH to a contract that always reverts and checking whether the victim's state updates regardless.
Unchecked External Call Exploit Generator
Overview
The unchecked external call exploit generator validates findings from the unchecked_call detector by executing the target function with two different recipient contracts: one that accepts ETH transfers and one that always reverts. If the victim contract updates its internal state regardless of which recipient receives (or rejects) the funds, the return value of the external call is not being checked.
This vulnerability class dates to the early days of Ethereum. The King of the Ether (2016) contract lost thousands of dollars because it used send() without checking the boolean return value. When the recipient was a contract that could not accept ETH, the “king” role was transferred but funds were silently lost.
Note: Exploit generation in Sigvex is for vulnerability validation purposes only.
Attack Scenario
Pattern 1 — Silent fund loss:
- The victim bank contract tracks user balances:
balances[msg.sender] = amount. - A user calls
withdraw(). The contract zeroes the balance and callspayable(msg.sender).send(amount). - If
msg.senderis a contract without areceive()function (or one that always reverts),send()returnsfalse. - The victim does not check the return value. The user’s balance is now 0 but they received nothing.
- Funds are permanently locked in the contract.
Pattern 2 — Attacker-triggered state corruption:
- The attacker calls
withdraw()from a contract whosereceive()reverts. - The external call fails silently.
- Because the state update happens before or simultaneously with the unchecked call, the internal accounting diverges from actual ETH flows.
- The attacker can exploit this divergence to re-trigger logic that assumes the withdrawal succeeded.
Pattern 3 — Out-of-gas silent failure: The generator also tests execution with only 100 gas, which causes the external call to run out of gas and fail. An unchecked call will ignore this failure.
Exploit Mechanics
The generator first verifies the target bytecode contains at least one of CALL (0xF1), STATICCALL (0xFA), or DELEGATECALL (0xF4). If none are present, the finding is rejected immediately.
Three scenarios are run:
- Normal recipient: A simulated recipient contract that accepts all ETH transfers and returns successfully.
- Failing recipient: A simulated recipient contract that always reverts, rejecting any ETH transfer.
- Out-of-gas execution: The same calldata executed with a severely constrained gas limit, causing the external call to run out of gas and fail silently.
Initial storage configures the user’s internal balance to a known value. Post-execution, the balance slot is read back to check whether it was updated regardless of the external call result.
Verdict:
- Scenario 1 succeeds AND Scenario 2 succeeds AND slot 1 changed despite call failure → critical (confidence 0.95): state corruption confirmed.
- Scenario 1 succeeds AND Scenario 2 succeeds but no slot change → likely (confidence 0.75): silent failure, no immediate state corruption but call failures are unhandled.
- Scenario 1 succeeds AND Scenario 2 reverts → protected: the victim propagates call failure correctly.
The generated PoC demonstrates the exploit and the fix:
// ATTACKER: Always rejects ETH
contract UncheckedCallAttacker {
fallback() external payable { revert("I don't accept payments"); }
receive() external payable { revert("I don't accept payments"); }
function exploit() external {
IVictim(victim).withdraw();
// Victim's balance mapping says we withdrew
// but we never received the ETH (call failed silently)
}
}
// VULNERABLE: send() return not checked
function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0; // state updated
payable(msg.sender).send(amount); // return value IGNORED
}
// SECURE: always check return value
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
balances[msg.sender] = 0;
(bool success,) = payable(msg.sender).call{value: amount}("");
require(success, "Transfer failed"); // revert if call failed
}
Remediation
- Detector: Unchecked Call Detector
- Remediation Guide: Unchecked Call Remediation
Always check the boolean return from call, send, and delegatecall. The EVM call opcodes (CALL, STATICCALL, DELEGATECALL) all push a success flag onto the stack; ignoring it is semantically incorrect:
// check with require
(bool success,) = payable(recipient).call{value: amount}("");
require(success, "ETH transfer failed");
// check with conditional
(bool success,) = payable(recipient).call{value: amount}("");
if (!success) revert TransferFailed();
// use transfer() if 2300 gas limit is acceptable
payable(recipient).transfer(amount); // auto-reverts on failure
Note: transfer() forwards only 2300 gas and may fail for recipient contracts with complex receive() logic. Prefer call with an explicit success check for gas-forward flexibility.
References
- King of the Ether Postmortem (2016)
- SWC-104: Unchecked Call Return Value
- Consensys Best Practices: External Calls