10分钟智能合约:进阶实战-4.2 未检查的返回值

46 阅读4分钟

欢迎订阅专栏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/transfersend 返回 booltransferrevert(旧版本 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.sendValueAddress.sendValue(payable(to), amount) 封装了 call 并检查返回值。
对 ERC20 使用 SafeERC20safeTransfer / 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 开发中极易犯的错误,源于开发者对低级调用和代币标准行为的误解。防御核心:

  • 对所有低级调用(calldelegatecallstaticcall)检查返回值
  • 对 ERC20 转账使用 SafeERC20
  • 避免使用 send,推荐 call + 检查或 Address.sendValue

审计时应重点关注所有外部调用(包括看似安全的 transfer)是否被正确验证。一句简单的 require(success) 即可避免灾难。