BlockChain solidity 重入漏洞分析
前置知识
solidity智能合约的转账主要通过send,transfer,call等函数实现。
1 | |
三者的主要区别在于:
- send transfer均有2300的最大gas消耗限制,如果一次操作消耗的gas大于2300,则操作失败。call方法执行则没有gas消耗限制
- transfer函数执行在失败后会回滚状态并终止后续步骤执行,而send和call函数则在失败时返回false,并不结束后续操作
- call函数在调用后会执行调用者的fallback函数,并将所有可用gas交给fallback函数使用
- transfer和send均为call函数的封装
总结下来看transfer在使用时最为安全(失败时会抛出异常并执行回滚操作),而send和call在使用时需要通过返回值的true/false来判断转账操作是否执行成功
漏洞成因
由于call函数没有2300gas的限制,导致在fallback函数中可以执行非常复杂的操作,包括再次向目标合约发起执行call操作的函数请求再次发起转账(由于gas不限制,因此可以执行复杂逻辑比如转账)。简单demo如下:
1 | |
可以看到当合约B调用attack函数时 会调用合约A的withdraw函数,withdraw会调用call方法向调用方(在这里也就是B的合约地址)转账。
在call函数转账成功后,会调用B合约的fallback函数。可以看到fallback函数中再次调用了A合约的withdraw方法,也就是说再次调用了call函数进行转账。而在这次call函数调用成功后,又回再次跳到B合约的fallback函数执行,在这次fallback中又会再次调用A合约的withdraw函数进行转账……
由此形成类似递归调用的逻辑,最终B函数通过一次withdraw调用可以把A合约中所有的ether转移走。这也就是重入漏洞的成因了。
再进一步
看如下合约代码:
1 | |
分析代码可以看出这是一个类似银行存储的合约,用户可以通过deposit函数存一定数量的ether,可以通过withdraw函数取走所有存储的ether。
可以看到:
- 在执行call操作之前,代码逻辑判断可当前账户存款数是否大于0 大于0时才会有提取逻辑
- 执行完call操作转账后后,通过balances[msg.sender] = 0设置存储金额为0
思路是在转账完成后将存款金额置0,这样即使通过重入攻击再次调用withdraw函数,也会因为无法绕过判断require(bal > 0)而无法操作。然而事实上并不是这样。在我们理解了call和重入漏洞之后可以知道,fallback函数的调用是在call函数调用完成之后立即进行的,也就是其调用时间是早于balances[msg.sender] = 0这一行代码执行的,因为当通过fallback函数再次调用withdraw时,balances[msg.sender]并不等于0,因此require(bal > 0)判断条件成立,再次执行call操作再次转账。简单来讲 函数的执行逻辑并不像开发者所想的那样是:
attack -> withdraw -> call -> balances[msg.sender] = 0 -> fallback -> withdraw -> require(bal > 0)失败 调用链结束
而是:
attack -> withdraw -> call -> fallback -> withdraw -> fallback -> withdraw -> …… -> address(this).balance = 0 call无法转账 -> balances[msg.sender] = 0
也就是说,重入攻击仍然可以进行。我们可以写出如下攻击合约:通过调用attack函数即可完成攻击
1 | |
方便调试,笔者已经将两个合约都部署到了Rinkeby测试网络中便于各位调试,合于地址分别为:
EtherStore: 0xe732C451cFaF6e03B4189B64A18310A60758635B
Attack: 0x380b184C3C5F20C2f5b58fD54B543c68A4B3dB78
在理解了这段代码之后,也就很好知道如何修复这个问题了,我们只需要将 balances[msg.sender] = 0; 这行代码移到call调用之前执行即可,这样一来在第一次调用withdraw时,会先将balances[msg.sender]置为0在进行call转账操作。那么当转账成功后fallback再次调用withdraw时,balances[msg.sender]就已经为0了。此时require(bal > 0)判断无法绕过也就不会再次进行转账了。
1 | |
除了上述的解决方案之外,solidity本身也提供了一些方案用于解决重入攻击:
- 在使用call函数进行转账时,可以通过call.value{gas:2300} 进行gas消耗限制(这种做法不被推荐,笔者也没详究原因)
- 使用OpenZeppelin提供的nonReentrant修饰符对转账函数进行修饰。相当于对被调用函数加互斥锁防止其被重复调用。
参考文献
[1]https://solidity-by-example.org/fallback/
[2]https://solidity-by-example.org/call/
[3]https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard-nonReentrant--