慢雾发布Balancer 第一次被黑详细分析(内含分析详情)
慢雾区·2020-07-02 阅读 7

前言

2020 年 6 月 28 日,自动化做市商服务提供者 Balancer 遭受攻击,慢雾安全团队在收到情报后对本次攻击事件进行了全面的分析,下面就这次攻击事件,为大家展开具体的技术分析。

知识储备

自动做市商服务(AMM)

Balancor 是一个提供 AMM 服务的合约,也就是自动化做市商服务,自动化做市商服务提供者采用代币池中的各种代币之间的数量的比例确定代币之间的价格,用户可通过这种代币之间的动态比例获取代币之间的价格,进而在合约中进行代币之间的兑换。

通缩型代币

通缩代币模型是随着时间的推移从市场上减少代币的一种模型。可以通过多种方法将代币从市场上减少,包括代币回购和代币创建者进行的代币销毁。本次攻击的主角-STA 代币就是一款通缩型代币,它是通过在转账的时候燃烧转账用户的余额实现代币的通缩。主要的实现代码如下(以 transfer 为例):

function transfer(address to, uint256 value) public returns (bool) {

    require(value <= _balances[msg.sender]);

    require(to != address(0));

    // 代币通缩逻辑

    uint256 tokensToBurn = cut(value);

    uint256 tokensToTransfer = value.sub(tokensToBurn);

    _balances[msg.sender] = _balances[msg.sender].sub(value);

    _balances[to] = _balances[to].add(tokensToTransfer);

    // 进行代币通缩

    _totalSupply = _totalSupply.sub(tokensToBurn);

    emit Transfer(msg.sender, to, tokensToTransfer);

    emit Transfer(msg.sender, address(0), tokensToBurn);

    return true;

  }

了解以上两点后,我们就可以开始进行详细的技术分析。

技术细节

通过代币转移概览,我们可以看到,攻击者(0x81d)多次向 Balancer 合约(0x0e5)发送 WETH 兑换 STA 代币。


为了知道更加具体的交易细节,我们使用 OKO 合约浏览工具对交易进行分析:https://oko.palkeo.com/0x013be97768b702fe8eccef1a40544d5ecb3c1961ad5f87fee4d16fdc08c78106/


通过分析交易内具体的细节,可以发现攻击者频繁调用了 swapExactAmountIn这个函数(上图列出的只是一部分,读者可自行通过连接访问查看具体的结果)。接下来我们就 swapExactAmountIn 这个函数对代码展开分析,代码如下:

function swapExactAmountIn(

        address tokenIn,

        uint tokenAmountIn,

        address tokenOut,

        uint minAmountOut,

        uint maxPrice

    )

        external

        _logs_

        _lock_

        returns (uint tokenAmountOut, uint spotPriceAfter)

    {

        require(_records[tokenIn].bound, "ERR_NOT_BOUND");

        require(_records[tokenOut].bound, "ERR_NOT_BOUND");

        require(_publicSwap, "ERR_SWAP_NOT_PUBLIC");

        // 获取兑换时转入代币和要转出的代币的余额

        Record storage inRecord = _records[address(tokenIn)];

        Record storage outRecord = _records[address(tokenOut)];

        require(tokenAmountIn <= bmul(inRecord.balance, MAX_IN_RATIO), "ERR_MAX_IN_RATIO");

        uint spotPriceBefore = calcSpotPrice(

                                    inRecord.balance,

                                    inRecord.denorm,

                                    outRecord.balance,

                                    outRecord.denorm,

                                    _swapFee

                                );

        require(spotPriceBefore <= maxPrice, "ERR_BAD_LIMIT_PRICE");

        // 计算兑换代币的数额

        tokenAmountOut = calcOutGivenIn(

                            inRecord.balance,

                            inRecord.denorm,

                            outRecord.balance,

                            outRecord.denorm,

                            tokenAmountIn,

                            _swapFee

                        );

        require(tokenAmountOut >= minAmountOut, "ERR_LIMIT_OUT");

        // 更新转入和转出代币的余额

        inRecord.balance = badd(inRecord.balance, tokenAmountIn);

        outRecord.balance = bsub(outRecord.balance, tokenAmountOut);

        spotPriceAfter = calcSpotPrice(

                                inRecord.balance,

                                inRecord.denorm,

                                outRecord.balance,

                                outRecord.denorm,

                                _swapFee

                            );

        require(spotPriceAfter >= spotPriceBefore, "ERR_MATH_APPROX");     

        require(spotPriceAfter <= maxPrice, "ERR_LIMIT_PRICE");

        require(spotPriceBefore <= bdiv(tokenAmountIn, tokenAmountOut), "ERR_MATH_APPROX");

        emit LOG_SWAP(msg.sender, tokenIn, tokenOut, tokenAmountIn, tokenAmountOut);

        // 拉取用户用于兑换的代币和将用户要兑换的代币推送给用户

        _pullUnderlying(tokenIn, msg.sender, tokenAmountIn);

        _pushUnderlying(tokenOut, msg.sender, tokenAmountOut);

        return (tokenAmountOut, spotPriceAfter);

}

通过分析 swapExactAmountIn 函数,可以知道函数的主要的流程如下:

1、获取进行兑换的两种代币的余额 

2、根据代币的余额计算价格,检查交易前价格是否合理 

3、计算目标兑换代币的转出数量

4、更新进行兑换的两种代币的余额 

5、计算兑换后的价格,并检查价格是否合理 

6、拉取用户用于兑换的代币,并将用户需要兑换的目标代币转给用户

通过分析该函数,我们并未发现太多异常,这是一个正常的兑换流程,但需要注意的是这里获取两个代币余额的方式不是通过 balanceOf 的方式,而是用存储于 _records 变量中的 balance 的值来获取指定代币的余额,理解这点有利于分析攻击者接下来的操作。既然这是个正常的流程, 那为什么攻击者需要频繁调用这个函数呢?这里就要引入这次的主角 - 通缩型代币 STA。

根据上文分析,我们知道,STA 在转账的时候会将转账者的一部分余额销毁掉,达到通缩的目的,根据这一特性,Balancer 合约在向用户支付 STA 代币的时候,其中一部分的代币会被燃烧掉,即用户收到的 STA 代币会比预期的要少,根据 STA 的转账代码,Balancer 的 STA 余额会被正常的减少,但是用户收到的 STA 余额是燃烧过后的余额,也就是说用户在进行兑换的时候,兑换结果是亏的。举个例子,攻击者使用1个 WETH 兑换 STA,假设兑换出来的 STA 的数量为30000个,但是由于燃烧的原因,Balancer 发给攻击者的 STA 只有27000个,也就是说,本来攻击者应用1个 WETH 兑换出30000个 STA,但是最后只拿到了27000个,也就是这一次交易亏了。而对于 Balancer 的资金池而言,WETH 的数量确实增加了1个,同时也少掉了30000个 STA,对本身的兑换算法并不受影响。根据这样的逻辑,攻击者每一次兑换 STA,都是在做亏本买卖。但是攻击者又不傻,这买卖肯定是不能亏本的。那么攻击者最终的获利手段究竟是什么呢?我们需要根据交易细节接着分析。

在调用了24次 swapExactAmountIn 函数后,攻击者已经将 Balancer 中的 STA 数量控制在一个低点,此时 STA 兑 WETH 的价格已经很高了,这时候攻击者开始使用 swapExactAmountIn 函数,使用 STA 兑换 WETH。


按照正常的流程,即使现在 STA 兑换 WETH 的价格已经很高,但是由于攻击者在使用 WETH 兑换 STA 的时候,由于燃烧机制,攻击者是没有拿到用于兑换的 WETH 等额价值的 STA,所以即使攻击者使用兑换出来的全部 STA 去兑换 WETH,由于兑换过程中会导致 Balancer 中的 STA 余额变大,导致 STA 兑 WETH 的价格降低,攻击者最终还是亏的,攻击者之所以能在 STA 换 WETH 的过程中获利,原因在于交易链中调用的 gulp 函数,函数的代码具体如下:

function gulp(address token)

        external

        _logs_

        _lock_

    {

        require(_records[token].bound, "ERR_NOT_BOUND");

        _records[token].balance = IERC20(token).balanceOf(address(this));

}

可以看到,gulp 函数主要是对 _records 变量中的 balance 进行修正。从上文可以知道,_records 中存储的是对应币种的余额信息,那么调用 gulp 函数,实际上就是对相应的代币的余额进行修正。那么攻击者为什么要调用这个函数呢?通过观察上图调用链中攻击者在调用 swapExactAmountIn 传入的 STA 的数量,可以发现传入的数量为1,那么根据 STA 的燃烧机制,在转账过程中,攻击者实际上没有向 Balancer 合约进行 STA 转账。


转账的 1 STA 在转账的过程中燃烧了。紧接着我们再次回顾 swapExactAmountIn函数

function swapExactAmountIn(

        address tokenIn,

        uint tokenAmountIn,

        address tokenOut,

        uint minAmountOut,

        uint maxPrice

    )

        external

        _logs_

        _lock_

        returns (uint tokenAmountOut, uint spotPriceAfter)

    {

        require(_records[tokenIn].bound, "ERR_NOT_BOUND");

        require(_records[tokenOut].bound, "ERR_NOT_BOUND");

        require(_publicSwap, "ERR_SWAP_NOT_PUBLIC");

        Record storage inRecord = _records[address(tokenIn)];

        Record storage outRecord = _records[address(tokenOut)];

        require(tokenAmountIn <= bmul(inRecord.balance, MAX_IN_RATIO), "ERR_MAX_IN_RATIO");

        uint spotPriceBefore = calcSpotPrice(

                                    inRecord.balance,

                                    inRecord.denorm,

                                    outRecord.balance,

                                    outRecord.denorm,

                                    _swapFee

                                );

        require(spotPriceBefore <= maxPrice, "ERR_BAD_LIMIT_PRICE");

        // 计算兑换的代币数量,即使没有收到 STA,此处依然进行了计算

        tokenAmountOut = calcOutGivenIn(

                            inRecord.balance,

                            inRecord.denorm,

                            outRecord.balance,

                            outRecord.denorm,

                            tokenAmountIn,

                            _swapFee

                        );

        require(tokenAmountOut >= minAmountOut, "ERR_LIMIT_OUT");

        // 更新转入和转出代币的余额

        inRecord.balance = badd(inRecord.balance, tokenAmountIn);

        outRecord.balance = bsub(outRecord.balance, tokenAmountOut);

        spotPriceAfter = calcSpotPrice(

                                inRecord.balance,

                                inRecord.denorm,

                                outRecord.balance,

                                outRecord.denorm,

                                _swapFee

                            );

        require(spotPriceAfter >= spotPriceBefore, "ERR_MATH_APPROX");     

        require(spotPriceAfter <= maxPrice, "ERR_LIMIT_PRICE");

        require(spotPriceBefore <= bdiv(tokenAmountIn, tokenAmountOut), "ERR_MATH_APPROX");

        emit LOG_SWAP(msg.sender, tokenIn, tokenOut, tokenAmountIn, tokenAmountOut);

        // 拉取用户用于兑换的代币和将用户要兑换的代币推送给用户

        _pullUnderlying(tokenIn, msg.sender, tokenAmountIn);

        _pushUnderlying(tokenOut, msg.sender, tokenAmountOut);

        return (tokenAmountOut, spotPriceAfter);

}

虽然 Balancer 合约没有收到 STA,但是由于兑换数量是直接取用户传入的值,所以即使没有向 Balancer 转 STA,由于 STA 兑换 WETH 价格很高,依然能换出大量的 WETH。这里还有个问题,虽然 Balancer 合约没有收到 STA,但是相关的入账却记录在了 _records 中(42行)。这会导致一个问题,随着兑换次数的增加,Balancer 池子中 STA 的记录值会不断增加,STA 兑换 WETH 的价格会逐步降低,这样下去是无法持续获利的,如何每次都维持最低的价格进行兑换呢?答案就在gulp 函数中。

由于攻击者在使用 STA 兑换 WETH 的时候,由于燃烧的原因,Balancer 合约实际并没有收到 STA,导致虽然 swapExactAmountIn 使用 _records 记录了相应的入账,但是 Balancer 合约的 STA 代币的真实余额并没有发生变化。当调用 gulp 函数的时候,由于 gulp 函数获取到的是合约真正持有的 token balance,会导致覆盖先前调用 swapExactAmountIn 函数时执行 inRecord.balance = badd(inRecord.balance, tokenAmountIn) 的值,那么在下次兑换的时候,攻击者就能消除因兑换导致 Balancer 池中 STA 代币 _records 记录值的增多而导致价格的降低带来的影响,使攻击者始终能以最高的价格兑换 WETH,从而进行获利。

除了 WETH 之外,攻击者使用了同样的方法用 STA 兑换了池中的 WBTC,SNX 及 LINK 代币,并通过 Uniswap 将相应的代币套现,最终折回 WETH,归还闪电贷的 10.4万 WETH。到此攻击完成。

完整攻击过程如下

1、从 dYdX 进行贷款

2、不断地调用 swapExactAmountIn 函数,将 Balancer 池中的 STA 数量降到低点,推高 STA 兑换 其他代币的价格 

3、使用 1 STA 兑换 WETH,并在每次兑换完成后(调用 swapExactAmountIn 函数)调用 gulp 函数,覆盖 STA 的余额,使 STA 兑换 WETH 的价格保持在高点 

4、使用同样的方法攻击代币池中的其他代币

5、偿还闪电贷贷款

6、获利离场

修复建议

本次攻击主要是因为通缩型代币带来的不兼容性问题,当用户在使用通缩型代币进行兑换的时候,合约没有有效的对接收到的通缩型代币的余额进行校验,导致余额记录错误。从而产生套利。那么修复方案也很简单,即合约在处理兑换逻辑过程中,需要检查进行兑换的两种代币在兑换过程中合约是否收到了相应的代币,保证代币的余额正确记录,不能只是依赖 ERC20 标准中关于转账的返回值,从而避免因代币余额记录错误导致的问题。

区块链安全Balancer慢雾
本文仅代表作者观点,不代表本站立场,若侵犯了您的合法权益,请点击联系我们。