欢迎订阅专栏:10分钟智能合约:进阶实战
未检查的返回值:定义、原理与防御
未检查的返回值 指智能合约在调用外部函数(尤其是返回 bool 表示成功/失败的操作)时,忽略了对返回值的验证,盲目假设调用总是成功。这可能导致错误的状态更新、资金损失或合约被操纵。
核心问题:Solidity 中的许多低级操作(如
call)和代币标准(如ERC20.transfer)不会自动触发revert,而是返回一个bool。若开发者不检查该值,调用失败也不会中断交易,导致逻辑错误。
1. 核心原理
在 EVM 中,有两种函数调用方式:
- 内部调用 / 直接调用:如
someExternalFunction(),如果被调用函数内部revert,整个交易会回滚。 - 低级调用:如
addr.call{value: 1}("")、addr.delegatecall()、addr.staticcall()以及 ERC20 的transfer/transferFrom(它们也会返回bool但不会自动revert)。这些调用返回一个(bool success, bytes data)元组,必须手动检查success。
漏洞本质:未检查返回值 ⇒ 调用失败时,调用方合约继续执行后续代码,基于错误的假设更新状态。
2. 常见场景
| 场景 | 描述 | 后果 |
|---|---|---|
| 低级 ETH 转账 | 使用 addr.call{value: x}("") 后未检查 success。 | 转账失败但继续执行,余额错误减少或奖励错误分配。 |
delegatecall | 调用目标合约的函数后未检查返回值。 | 逻辑执行失败但状态可能被错误修改。 |
| ERC20 转账 | token.transfer(to, amount) 返回 false,但未检查。 | 实际转账失败,但内部会计记录已扣减。 |
| 多重调用 | 连续调用多个外部函数,只检查最后一个。 | 中间失败未被发现,状态不一致。 |
send/transfer | send 返回 bool,transfer 会 revert(旧版本 transfer 行为有差异,现代 Solidity 中 transfer 失败会抛出异常,但 send 仍返回 bool)。 | send 失败继续执行。 |
3. 典型漏洞示例
3.1 低级 call 未检查
// 漏洞合约
contract Vault {
function withdraw(address payable to, uint amount) public {
// ❌ 未检查返回值
(bool success, ) = to.call{value: amount}("");
// 即使转账失败,仍然更新余额
balances[msg.sender] -= amount;
}
}
若 to 是未实现 receive 的合约或拒绝接收 ETH,call 返回 success = false,但合约仍然扣除了调用者的余额,导致用户资金丢失。
3.2 ERC20 transfer 未检查
// 漏洞合约
contract Payment {
function pay(address token, uint amount) public {
// ❌ 假设 transfer 总会成功
IERC20(token).transfer(msg.sender, amount);
emit Paid(msg.sender, amount);
}
}
某些 ERC20 代币(如 USDT)在转账失败时返回 false 而不会 revert。若未检查,调用者会误以为支付成功。
3.3 连续调用未检查
function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
for (uint i = 0; i < recipients.length; i++) {
IERC20(token).transfer(recipients[i], amounts[i]); // ❌ 未检查返回值
// 部分失败继续循环
}
}
若某个转账失败,后续仍会继续执行,导致部分成功部分失败的状态,难以回滚。
4. 防御措施
| 策略 | 实现方式 |
|---|---|
| 检查返回值 | (bool success, ) = addr.call{...} 后添加 require(success, "Call failed")。 |
使用 transfer 替代 send(已不推荐) | transfer 在失败时会自动 revert,但 Gas 限制 2300,不适合复杂接收方。现代推荐使用 call + 检查。 |
使用 OpenZeppelin Address.sendValue | Address.sendValue(payable(to), amount) 封装了 call 并检查返回值。 |
对 ERC20 使用 SafeERC20 | safeTransfer / safeTransferFrom 会检查返回值并 revert(如果返回 false)。 |
对 delegatecall 使用 require | (bool success, ) = target.delegatecall(data); require(success, "Delegatecall failed") |
使用 assert 进行不可恢复的检查 | 仅在预期永远为真时使用,否则使用 require。 |
安全代码示例:
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract SafeContract {
using Address for address payable;
using SafeERC20 for IERC20;
function withdraw(address payable to, uint amount) public {
to.sendValue(amount); // 失败时会 revert
balances[msg.sender] -= amount;
}
function pay(IERC20 token, address to, uint amount) public {
token.safeTransfer(to, amount); // 失败 revert
emit Paid(to, amount);
}
}
5. 真实案例
- King of the Ether:早期合约使用
send而未检查返回值,导致退款失败时状态仍被改变,造成资金损失。 - Etherpot 彩票:因
call返回值未检查,攻击者可以导致奖金发送失败但用户仍被标记为已领取。 - 大量 ERC20 兼容性问题:如
BAT代币的transfer返回bool但不遵守标准(没有返回值)导致某些合约失败,更典型的是未检查返回值的合约在遇到非标准实现时出现漏洞。
6. 总结
未检查的返回值是 Solidity 开发中极易犯的错误,源于开发者对低级调用和代币标准行为的误解。防御核心:
- 对所有低级调用(
call、delegatecall、staticcall)检查返回值。 - 对 ERC20 转账使用
SafeERC20库。 - 避免使用
send,推荐call+ 检查或Address.sendValue。
审计时应重点关注所有外部调用(包括看似安全的 transfer)是否被正确验证。一句简单的 require(success) 即可避免灾难。