返回文章库

智能合约安全审计:自毁函数的攻击向量与防御策略

查找币 学术研究 安全研究 Web3安全 区块链安全
智能合约安全审计:自毁函数的攻击向量与防御策略

背景概述

在智能合约安全审计领域,我们通常关注三类常见的资金转移函数:transfer、send 和 call.value().gas()()。然而,有一类特殊的函数——selfdestruct(自毁函数),因其独特的强制转账特性,常常成为审计中容易被忽视的风险点。本文将深入剖析自毁函数的攻击原理,并通过实际案例展示其利用方式与防御方案。

前置知识:Solidity 中的转账机制

在 Solidity 中,向合约或地址转账主要有以下三种方式:

函数 行为特性 安全性
transfer 转账失败抛出异常,后续代码不执行 安全,但 Gas 限制为 2300
send 转账失败返回 false,后续代码继续执行 需检查返回值
call.value().gas()() 转账失败返回 false,后续代码继续执行 需防范重入攻击

关键点:以上三种方式都需要目标地址具备接收转账的能力(如实现 receive() 或 fallback() 函数)。而 selfdestruct 函数则提供了一种强制转账的机制——无需目标合约配合,即可将合约余额发送到指定地址。

自毁函数:双刃剑

自毁函数由以太坊虚拟机(EVM)原生提供,用于销毁合约并从区块链状态中移除其代码和存储。其核心特性包括:

然而,攻击者可以利用这一特性向目标合约“强制注入”非预期资金,从而破坏合约的正常逻辑。典型攻击场景是:合约使用 address(this).balance 获取余额,而攻击者通过自毁函数向该合约打入额外资金,导致余额计算偏离预期。

漏洞示例:幸运七游戏

目标合约分析

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract EtherGame {
    uint public targetAmount = 7 ether;
    address public winner;

    function deposit() public payable {
        require(msg.value == 1 ether, "You can only send 1 Ether");
        uint balance = address(this).balance;
        require(balance <= targetAmount, "Game is over");
        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }

    function claimReward() public {
        require(msg.sender == winner, "Not winner");
        (bool sent, ) = msg.sender.call{value: address(this).balance}("");
        require(sent, "Failed to send Ether");
    }
}

漏洞剖析

该合约实现了一个名为“幸运七”的游戏规则:

  1. 玩家每次调用 deposit() 时需发送恰好 1 ETH
  2. 合约通过 address(this).balance 获取当前余额
  3. 当余额等于 7 ETH 时,当前调用者成为 winner
  4. winner 可调用 claimReward() 提取全部余额

漏洞核心:deposit() 函数通过 address(this).balance 获取余额,而攻击者可以通过 selfdestruct 向合约强制转入任意数量的 ETH。假设攻击者在第 6 个玩家存入后,通过自毁函数向合约打入 1 ETH,此时合约余额变为 7 ETH,但第 7 个玩家调用 deposit() 时会因为 balance <= targetAmount 检查失败而无法参与游戏。更极端的情况,攻击者直接打入 7 ETH,导致游戏立即“结束”,但 winner 地址为 address(0),永远无法产生 winner。

攻击合约实现

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.10;

contract Attack {
    EtherGame etherGame;

    constructor(EtherGame _etherGame) {
        etherGame = EtherGame(_etherGame);
    }

    function attack() public payable {
        // 通过自毁强制向目标合约转账
        selfdestruct(payable(address(etherGame)));
    }
}

攻击步骤:

  1. 部署 Attack 合约,并将目标 EtherGame 合约地址传入
  2. 向 Attack 合约转入 1 ETH(或更多)
  3. 调用 attack() 函数,触发 selfdestruct
  4. Attack 合约销毁,其 ETH 强制转入 EtherGame 合约
  5. 此时 EtherGame 余额变为非预期的 7 ETH(或更多),游戏逻辑被破坏

防御方案

方案一:使用状态变量跟踪余额

contract EtherGameFixed {
    uint public targetAmount = 7 ether;
    address public winner;
    uint public balance;  // 使用状态变量代替 address(this).balance

    function deposit() public payable {
        require(msg.value == 1 ether, "You can only send 1 Ether");
        balance += msg.value;
        require(balance <= targetAmount, "Game is over");
        if (balance == targetAmount) {
            winner = msg.sender;
        }
    }

    function claimReward() public {
        require(msg.sender == winner, "Not winner");
        (bool sent, ) = msg.sender.call{value: balance}("");
        require(sent, "Failed to send Ether");
    }
}

方案二:审计检查清单

作为审计人员,在审查使用 address(this).balance 的合约时,应重点关注:

  1. 业务逻辑依赖:检查余额是否用于控制关键状态(如游戏结束条件、奖励分配等)
  2. 强制转账风险:评估是否存在通过 selfdestruct 或其他方式(如 coinbase 转账)注入非预期资金的可能性
  3. 替代方案:推荐使用内部状态变量跟踪余额,而非直接依赖合约地址余额

总结

selfdestruct 函数虽然为紧急情况提供了资金转移的便利,但其强制转账特性可能被攻击者利用,破坏依赖 address(this).balance 的合约逻辑。审计时需特别注意:

通过以上措施,可以有效防范自毁函数带来的安全风险,确保智能合约的稳健运行。


本文由查找币安全团队整理发布

前往社区查看和回复