理解以太坊 Reentrancy 重入漏洞及其发生原因

Posted by envoy on January 8, 2021

太长不看版 TL;DR

  • 重入漏洞是指一个函数尚未执行完毕就被执行线程再次进入时发生错误。
  • 造成重入漏洞的是Solidity语言和以太坊虚拟机的设计上的不足之处,具体是指:
    • 调用者信息泄漏,即被调用者有权访问调用者的内部状态和函数。
    • 公共上下文,即互不信任的调用者和被调用者共用同一个线程。
    • 异常抑制,即通过call等低级操作调用函数时,被调用者抛出的异常不会传递给调用者。
  • 部分程序出错的责任应归咎于语言的设计者,而不仅是写代码的程序员。

什么是可重入

维基百科对可重入的解释:

若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。

例如,以下C++代码中,函数add1Unsafe的作用是将全局变量n1。如果有两个线程同时进入该函数,则返回的结果可能有三种情况:

  1. 线程1得到2,线程2得到3
  2. 线程1得到3,线程2得到2
  3. 线程1和线程2都得到3

显然,情况3不符合将n1的需求,出现错误,所以它是不可重入的。

int n = 1;

int add1Unsafe() {
    n = n + 1;
    int m = n;
    return m;
}

经过修改后,下面的函数add1Safe不存在上述问题,即使有多个线程同时进入也总能返回正确结果,因此它是可重入的。

int add1Safe(int n) {
    n = n + 1;
    int m = n;
    return m;
}

以太坊的重入漏洞

请看以下使用Solidity语言编写的智能合约片段,withdraw函数实现取款功能:

在执行msg.sender.call{value: ...}时,向合约的调用方转账,但转账时可能会执行调用方合约的代码,而该代码可能再次调用withdraw函数。

因此,在调用方的余额被清零之前(balances[msg.sender] = 0),他会多次收到不属于他的钱的转账,导致本合约的财产损失。这就是重入漏洞。

pragma solidity ^0.8.0;

contract UnsafeBank {
    mapping(address => uint) balances;

    function withdraw() external {
        (bool success, ) = msg.sender.call{value: balances[msg.sender]}("");
        if (success) {
            balances[msg.sender] = 0;
        }
    }
}

经过修改,在转账之前将余额清零,并用transfer代替call,即可避免该漏洞:

pragma solidity ^0.8.0;

contract SafeBank {
    mapping(address => uint) balances;

    function withdraw() external {
        uint amount = balances[msg.sender];
        balances[msg.sender] = 0;
        payable(msg.sender).transfer(amount);
    }
}

漏洞发生的原因

重入漏洞能够发生,以下三点缺一不可。

埋下地雷——调用者信息泄漏(Caller Information Leak)

根据软件工程的经验,我们知道,在写代码的时候应该遵循“高内聚、低耦合”的原则,一个模块内部尽量少持有外界的知识,尽量少与外界进行交互。

因此,一个函数在被调用时,不应该隐式知道调用者是谁,而应该让调用者显式主动告诉这个函数他是谁(如果调用者不告知,则被调用者无法知道调用者的身份)。

而在以太坊智能合约中,可以通过msg.sender轻易地知道调用者的地址,从而访问其内部状态和调用其函数,无需经过调用者同意,埋下了安全隐患。

引爆地雷——公共上下文(Public Context)

开发者编写的智能合约部署时,将提交到区块链网络上,区块链节点上的以太坊虚拟机(EVM)负责运行合约。 EVM可以被视为一个单进程单线程的单体应用,不同开发者编写的合约会在同一个线程和上下文中执行。

一般来说,在合约代码不开源的情况下,开发者不信任其他开发者编写的合约。 然而,A在合约中向B转账时,会自动触发B合约的fallback函数,执行任意代码。结合上面”调用信息泄漏”所述,B合约的fallback函数可以反过来调用A合约的函数。由于这些操作都在同一个线程中进行,B合约可以通过操纵环境信息,来影响A合约的行为。

最坏情况下,所有合约都可以被其他不信任的合约以这种方式影响,这非常不安全,导致开发者需要谨慎地编写代码。而假如Solidity语言和平台设计良好,例如将函数调用通过异步的方式放在不同的上下文中执行,开发者这种畏手畏脚的做法就会变成完全不必要的。

杀掉吹哨人——异常抑制(Suppressed Exception)

当A合约以B.函数名的方式调用B合约的函数时,如果B合约通过requireassert抛出异常,A合约将接收到异常,A合约和B合约对环境做的修改将全部回滚,回到执行前的状态。

根据Solidity官方文档,当A合约使用低级操作(call, send, delegatecall, callcode, staticcall)调用B合约的函数时,如果B合约抛出异常,该异常会被EVM丢弃,而不会传递给A合约。相反,A合约只会收到一个bool类型的返回值,表示该调用是否成功。从表面上看,通过返回值的形式来表达异常并没有问题,但如果有一串很长的调用链,你就会发现问题了:

A->B->C->D->E

如上所示,A~E分别表示五个合约,A调用B,B调用C,以此类推。其中C调用D使用了低级操作call,其他调用使用合约名.函数名的方式。假如C抛出一个异常,则CDE三者对环境做的修改就会回滚,但B没有接收到异常,因此默认情况下A和B做的修改依然会生效。如果A合约的函数需要执行一个事务(指令要么全部成功,要么全部失败),但整个调用链上的代码被部分执行,不满足事务的需求,而此时A是全然不知的。

为了避免这种窘境,在使用低级操作之后,需要人工检查返回值是true还是false,如果是表示执行失败的false,则人工抛出异常,才可以维持调用链上的全部合约都接收到异常。这种人工抛出异常的方式加重了开发者的负担,而且造成了调用链的信任问题,即调用者需要依次检查调用链上的所有合约的代码是否有使用低级操作和抛出异常,才能信任被调用方,但这种检查很多时候是无法做到的(因为被调用者可能是运行时动态添加的,而不是将合约地址静态写死在代码里,况且大部分的合约都不开源)。

总结

写出有bug和有安全漏洞的代码并不只是开发者的错。上述三个条件只要有一个不成立,重入漏洞就不会发生。我们可以看到,语言设计者有充分的机会在语言诞生的初期,就通过设计精巧的语法和类型系统来终结大多数的bug出现的可能性,从而让开发者完全没有办法写出某些bug。因此,更深层次地说,语言的设计缺陷才是造成bug的主要原因。