科普 | 智能合约安全审计入门篇 —— 自毁函数

By:小白@慢雾安全团队

背景概述

上次我们了解了什么是溢出漏洞和如何预防和发现它。这次我们要了解的是 solidity 中自带的函数 —— selfdestruct 自毁函数

前置知识

我们先来了解 solidity 中能够转账的操作都有哪些:

1. transfer:转账出错会抛出异常后面代码不执行;

2. send:转账出错不会抛出异常只返回 true/false 后面代码继续执行;

3. call.value().gas()():转账出错不会抛出异常只返回 true/false 后面代码继续执行,且使用 call 函数进行转账容易发生重入攻击(这里可查阅:智能合约安全审计-入门篇之重入漏洞)。

上面三种都需要目标接收转账才能成功将代币转入目标地址,下面我们来看一个不需要接受就能给合约转账的函数:自毁函数。

自毁函数由以太坊智能合约提供,用于销毁区块链上的合约系统。当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,然后其存储和代码从状态中被移除。然而,自毁函数也是一把双刃剑,一方面它可以使开发人员能够从以太坊中删除智能合约并在紧急情况下转移以太币。另一方面自毁函数也可能成为攻击者的利用工具,攻击者可以利用该函数向目标合约“强制转账”从而影响目标合约的正常功能(比如开发者使用 address(this).balance 来取合约中的代币余额就可能会被攻击)。今天我们就来看一个攻击者利用自毁函数的强制转账特性对智能合约发起攻击导目标合约瘫痪的案例。

漏洞示例

下面我们来看目标合约:

// SPDX-License-Identifier: MITpragma 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"); }}

漏洞分析

EtherGame 合约实现的功能是一个游戏,我们这里可以称它为“幸运七”。玩家每次向 EtherGame 合约中打入一个以太,第七个成功打入以太的玩家将成为 winner。winner 可以提取合约中的 7 个以太。

玩家每次玩游戏时都会调用 EtherGame.deposit 函数向合约中先打入一个以太,随后函数会检查合约中的余额(balance)是否小于等于 7 ,只有合约中的余额小于等于 7 时才能继续否则将回滚。合约中的余额(balance)是通过 address(this).balance 取到的,这就意味着我们只要有办法在产生 winner 之前改变 EtherGame 合约中的余额让他等于 7 就会使该合约瘫痪。这样我们的攻击方向就明确了,只要我们强制给 EtherGame 合约打入一笔以太让该合约中的余额大于或等于 7 这样后面的玩家将无法通过 EtherGame.deposit 的检查,从而使 EtherGame 合约瘫痪,永远无法产生 winner。

但是 EtherGame.deposit 函数中存在验证:require(msg.value == 1 ether, "You can only send 1 Ether"),这里要求我们每次只能打一个以太进去,所以通过正常路径是不可能一次向 EtherGame 打入大于 1 枚的以太的,但是我们又需要打入大于 1 枚的以太到 EtherGame 合约中,所以需要找到另外的路径,来将以太转入到 EtherGame 合约中。

这里就要请出我们今天的主角:自毁函数——selfdestruct。从前置知识中我们可以看到,当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,我们可以构造一个攻击合约,然后触发 selfdestruct 函数让攻击合约自毁,攻击合约中的以太就会发送给目标合约。这样我们就可以一次向 EtherGame 合约中打入多枚以太,而不通过 EtherGame.deposit 函数,从而完成攻击。

举个例子:在极端情况下,如果已经有六个玩家参与了游戏且成功向合约中各自打入了 1 个以太,此时合约中有 6 枚以太,这样我们只需要用 selfdestruct 强制打入一枚以太,而不走 EtherGame.deposit 的逻辑,就会导致 EtherGame 合约记账错误, 从而导致合约瘫痪(DoS),就会造成合约中的 6 枚以太无法取出,因为此时还没有诞生出 winner。(当然也可以通过 EtherGame.deposit 将以太转入到合约中,这样是可以成为 winner 然后取出合约中的 7 枚以太,不过这种情况我们就先不做讨论,本篇仅讨论 selfdestruct 的本身的机制可能带来的攻击面)。

下面我们来看攻击合约:

攻击合约

contract Attack { EtherGame etherGame;
constructor(EtherGame _etherGame) { etherGame = EtherGame(_etherGame); }
function attack() public payable { address payable addr = payable(address(etherGame)); selfdestruct(addr); }

这里我们还是引用三个角色来讲解攻击合约的攻击过程(以下过程纯属虚构,目的是为了帮助大家更好的理解攻击过程,请勿较真!)

玩家一:Alice

玩家二:Bob

攻击者:Eve

1. 开发者部署 EtherGame 合约;

2. 玩家 Alice 决定玩游戏,她这辈子玩游戏从来没赢过,她觉得这个游戏可以让她体验一次当 winner 的快感,所以她决定连续调用 EtherGame.deposit 存入 7 个以太这样她就一定是 winner!正当她操作到第六次眼看还有一次今成功的时候,意外发生了(此时合约中已经有 Alice 存入的 6 个以太了);

3. 攻击者 Eve 部署 Attack 合约并在构造函数中传入 EtherGame 合约的地址;

4. 攻击者 Eve 调用 Attack.attack 并设置 msg.value = 1 ,函数触发 selfdestruct 将这 1 个以太强制打入 EtherGame 合约中。此时 EtherGame 合约中有 7 个以太(分别为 Alice 的六个以太和攻击者刚刚打入的 1 个以太);

5. 这时玩家 Bob 也决定玩游戏,存入 1 个以太后合约中有 7+1=8 个以太,无法通过 require(balance <= targetAmount, "Game is over") 的检查并回滚。到这里我们已经成功的使 EtherGame 合约瘫痪了,这个游戏将永远不会产生 winner,Alice 的 winner 梦也就此破灭了,6 个以太被永远的锁在了 EtherGame 合约中。哎,可怜的 Alice 。

下面是攻击流程图:

修复建议

看到这里我相信大家对自毁函数的功能及其危害都有一定的了解了。下面我们还是用开发者和审计者这两个角色来分析如何发现和预防通过自毁函数的攻击:

(1)作为开发者

这里我们就拿上面的漏洞合约 EtherGame 来说,这个合约可以被攻击者攻击是因为依赖了 address(this).balance 来获取合约中的余额且这个值可以影响业务逻辑,所以我们这里可以设置一个变量 balance,只有玩家通过 EtherGame.deposit 成功向合约打入以太后 balance 才会增加。这样只要不是通过正常途径进来的以太都不会影响我们的 balance 了,避免强制转账导致的记账错误。下面是修复代码:

// SPDX-License-Identifier: MITpragma solidity ^0.8.10;
contract EtherGame { uint public targetAmount = 3 ether; uint public balance; address public winner;
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"); }}

(2)作为审计者

作为审计者我们需要结合真实的业务逻辑来查看 address(this).balance 的使用是否会影响合约的正常逻辑,如果会影响那我们就可以初步认为这个合约存在被攻击者强制打入非预期的资金从而影响正常业务逻辑的可能(比如被 selfdestruct 攻击)。在审计过程中还需要结合实际的代码逻辑来进行分析。