Visão Geral do Contexto
Em nosso artigo anterior, analisamos o código malicioso escondido nos contratos. Desta vez, vamos explorar um método de ataque muito comum: o front-running.
Conhecimento Prévio
Quando falamos em front-running, a primeira coisa que provavelmente vem à mente é uma corrida de atletismo. No atletismo, o preparo físico dos competidores é quase idêntico; quanto mais cedo um deles começar, maior será a chance de vencer. Então, como ocorre o front-running na rede Ethereum?
Para entender os ataques de front-running, é necessário um sólido entendimento sobre o processo de transações da Ethereum. Vamos usar o diagrama abaixo, que ilustra o fluxo de transações, para entender o que acontece quando uma transação é iniciada na rede Ethereum.
Conforme mostrado no diagrama, uma transação passa por sete estágios, desde a assinatura até o empacotamento:
- Assinatura do conteúdo da transação com uma chave privada;
- Escolha do Gas Price (Preço do Gas);
- Envio da transação assinada;
- Transmissão da transação entre vários nós;
- Entrada da transação no pool de transações;
- Mineradores extraem transações com altos Gas Prices;
- Os mineradores empacotam as transações e mineram um novo bloco.
Depois que uma transação é enviada, ela é lançada no pool de transações para aguardar o empacotamento pelos mineradores. Os mineradores extraem as transações do pool para empacotamento e mineração de blocos. De acordo com os dados do Etherscan, o limite de Gas do bloco atual é de cerca de 30 milhões, um valor ajustado dinamicamente. Se basearmos nossos cálculos em uma transação padrão que custa 21.000 de Gas, então um bloco Ethereum pode acomodar atualmente cerca de 1428 transações. Portanto, quando o volume de transações no pool é alto, muitas transações não podem ser empacotadas imediatamente e permanecem no pool aguardando. Isso leva a uma pergunta: com tantas transações no pool, qual delas o minerador empacota primeiro?
Os nós de mineração podem definir seus parâmetros, mas a maioria dos mineradores classifica as transações com base no valor da taxa de transação. As transações com taxas mais altas têm prioridade para empacotamento e mineração de blocos, enquanto as transações com taxas mais baixas precisam aguardar até que todas as transações com taxas mais altas sejam empacotadas. É claro que as transações entram continuamente no pool. Independentemente da ordem em que entram, as transações com taxas mais altas sempre serão empacotadas primeiro. As transações com taxas excessivamente baixas podem nunca ser empacotadas.
Então, como são geradas as taxas de transação?
Vamos primeiro dar uma olhada na fórmula de cálculo da taxa de transação da Ethereum:
Tx Fee (Transaction Fee) = Gas Used * Gas Price, ou seja,
TX Fee (Taxa da Transação) = Gas Usado * Preço do Gas
Aqui, o Used Gas é calculado pelo sistema, enquanto o Gas Price pode ser personalizado. Portanto, a taxa de transação final depende do Gas Price definido.
Exemplo:
Suponha que o Gas Price seja definido como 10 GWEI e o Used Gas seja 21.000 (WEI é a menor unidade no Ethereum, em que 1 WEI = 10^-18 Ether e GWEI é 1 bilhão de WEI, ou seja, 1 GWEI = 10^-9 Ether). Portanto, de acordo com a fórmula de cálculo da taxa de transação, a taxa de transação pode ser calculada como:
10 GWEI (Gas Price) * 21,000 (Gas Used) = 0.00021 Ether (Transaction Fee)
Nos contratos, vemos com frequência a função Call
definindo um Gas Limit. Vamos dar uma olhada no que isso significa:
O termo "Gas Limit" (limite de gas) pode ser entendido literalmente - significa a quantidade máxima de gas que você está disposto a gastar em uma transação. Quando a transação envolve interações de contrato complexas e não se sabe ao certo a quantidade de Gas que será usada, o Gas Limit (limite de gas) pode ser definido. Quando a transação é empacotada, somente o Used Gas real será cobrado como taxa e todo o gas excedente será reembolsado. Obviamente, se o Used Gas real exceder o Gas Limit durante a operação, isso resultará em um evento de "Out of gas" (falta de gas), fazendo com que a transação seja revertida.
É claro que a escolha de um Gas Price adequado nas transações reais também é fundamental. Na ETH GAS STATION, podemos ver os Gas Prices em tempo real correspondentes à velocidade de empacotamento:
Conforme mostrado no diagrama acima, a maior velocidade de empacotamento atual corresponde a um Gas Price de 2. Portanto, podemos garantir que nossa transação seja processada o mais rápido possível, definindo o Gas Price para um valor maior ou igual a 2 ao enviar a transação.
A esta altura, você provavelmente pode adivinhar o método de ataque de front-running - ele envolve o aumento do Gas Price ao enviar uma transação para garantir que ela seja priorizada pelos mineradores para empacotamento. A seguir, vamos entender como os ataques de front-running são realizados, examinando um código de contrato.
Exemplo de Contrato
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
contract FindThisHash {
bytes32 public constant hash =
0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;
constructor() payable {}
function solve(string memory solution) public {
require(hash == keccak256(abi.encodePacked(solution)), "Incorrect answer");
(bool sent, ) = msg.sender.call{value: 10 ether}("");
require(sent, "Failed to send Ether");
}
}
Análise do Ataque
Pelo código do contrato, podemos ver que o implantador do contrato FindThisHash
forneceu um valor de hash. Qualquer pessoa pode enviar uma resposta por meio da função solve()
. Se o valor de hash da solução corresponder ao valor de hash do implantador, ele poderá receber uma recompensa de 10 Ether. Para o propósito de nosso exemplo, vamos excluir a possibilidade de o implantador receber a recompensa ele mesmo.
Vamos trazer de volta nossa velha amiga Eve (a invasora) para ver como ela usa um ataque de front-running para ficar com a recompensa que deveria pertencer a Bob (a vítima):
- Alice (implantadora de contrato) implanta o contrato
FindThisHash
com 10 Ether; - Bob encontra a string correta cujo valor de hash é o valor de hash de destino;
- Bob chama a função
solve(“Ethereum”)
e define o valor do Gas Price para 15 Gwei; - Eve está monitorando o pool de transações, esperando que alguém envie a resposta correta;
- Eve vê a transação de Bob, define um Gas Price mais alto (100 Gwei) do que Bob e chama
solve("Ethereum")
; - A transação de Eve é empacotada pelos mineradores antes da transação de Bob;
- Eve ganha o prêmio de 10 Ether.
Aqui, a série de ações de Eve é um ataque padrão de front-running. Assim, podemos definir o front-running na Ethereum como influência na ordem de empacotamento das transações, definindo um Gas Price mais alto para realizar o ataque.
Então, como podemos evitar esses ataques?
Correções sugeridas
Ao escrever contratos, você pode usar o esquema Commit-Reveal:
https://medium.com/swlh/exploring-commit-reveal-schemes-on-ethereum-c4ff5a777db8
O Solidity by Example fornece a seguinte solução na forma do código. Vamos ver se ele pode se defender perfeitamente contra ataques de front-running.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/Strings.sol";
contract SecuredFindThisHash {
// Struct é usada para armazenar detalhes de commit
struct Commit {
bytes32 solutionHash;
uint commitTime;
bool revealed;
}
// O hash que precisa ser resolvido
bytes32 public hash =
0x564ccaf7594d66b1eaaea24fe01f0585bf52ee70852af4eac0cc4b04711cd0e2;
// Endereço do ganhador
address public winner;
// Preço a ser pago
uint public reward;
// Status do jogo
bool public ended;
// Mapeamento para armazenar os detalhes de commit com o endereço
mapping(address => Commit) commits;
// Modificador para verificar se o jogo está ativo
modifier gameActive() {
require(!ended, "Already ended");
_;
}
constructor() payable {
reward = msg.value;
}
/*
Função commit para armazenar o hash calculado usando keccak256 (endereço em minúsculas + solução + segredo).
Os usuários só podem fazer commit uma vez e se o jogo estiver ativo.
*/
function commitSolution(bytes32 _solutionHash) public gameActive {
Commit storage commit = commits[msg.sender];
require(commit.commitTime == 0, "Already committed");
commit.solutionHash = _solutionHash;
commit.commitTime = block.timestamp;
commit.revealed = false;
}
/*
Função para obter os detalhes do commit. Isso retorna uma tupla de (solutionHash, commitTime, revealStatus);
Os usuários podem obter a solução somente se o jogo estiver ativo e eles tiverem confirmado um solutionHash
*/
function getMySolution() public view gameActive returns (bytes32, uint, bool) {
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");
return (commit.solutionHash, commit.commitTime, commit.revealed);
}
/*
Função para revelar o commit e receber a recompensa.
Os usuários podem obter a solução revelada somente se o jogo estiver ativo e eles tiverem feito commit de um solutionHash antes desse bloco e ainda não o tiverem revelado.
Isso gera um keccak256 (msg.sender + solução + segredo) e o verifica com o hash do commit anterior.
Os front runners não conseguirão passar nessa verificação, pois o msg.sender é diferente.
Em seguida, a solução real é verificada usando keccak256(solution); se a solução corresponder, o vencedor é declarado,
o jogo é encerrado e o valor do prêmio é enviado ao vencedor.
*/
function revealSolution(
string memory _solution,
string memory _secret
) public gameActive {
Commit storage commit = commits[msg.sender];
require(commit.commitTime != 0, "Not committed yet");
require(commit.commitTime < block.timestamp, "Cannot reveal in the same block");
require(!commit.revealed, "Already commited and revealed");
bytes32 solutionHash = keccak256(
abi.encodePacked(Strings.toHexString(msg.sender), _solution, _secret)
);
require(solutionHash == commit.solutionHash, "Hash doesn't match");
require(keccak256(abi.encodePacked(_solution)) == hash, "Incorrect answer");
winner = msg.sender;
ended = true;
(bool sent, ) = payable(msg.sender).call{value: reward}("");
if (!sent) {
winner = address(0);
ended = false;
revert("Failed to send ether.");
}
}
}
Primeiro, podemos ver que o código fixo usa a struct Commit para registrar as informações enviadas pelo jogador, onde:
-
commit.solutionHash = _solutionHash
= keccak256 (endereço do jogador + resposta + senha) — isso registra o hash da resposta enviada pelo jogador -
commit.commitTime = block.timestamp
— isso registra a hora de envio -
commit.revealed
= false — isso registra o status
A seguir, vamos ver como esse contrato funciona:
- Alice implementa o contrato
SecuredFindThisHash
usando dez Ether; - Bob encontra a string correta cujo valor de hash é o valor de hash de destino;
- Bob calcula
solutionHash = keccak256 (endereço de Bob + "Ethereum" + segredo de Bob)
; - Bob chama
commitSolution(_solutionHash)
, enviando o solutionHash que acabou de ser calculado; - No próximo bloco, Bob chama a função
revealSolution ("Ethereum",Bob's secret)
, insere a resposta e a senha escolhida e reivindica a recompensa.
Vamos dar uma olhada em como esse contrato evita o front-running. Em primeiro lugar, na quarta etapa, Bob envia o hash desses três valores: (endereço de Bob + "Ethereum" + segredo de Bob). Portanto, ninguém sabe o que Bob enviou. Essa etapa também registra o timestamp do bloco e o verifica primeiro no revealSolution()
da quinta etapa. Isso é feito para evitar a execução antecipada durante a revelação no mesmo bloco, pois a resposta em texto simples precisa ser passada ao chamar revealSolution()
. Por fim, ele verifica se o hash da resposta e a senha inserida por Bob correspondem ao solutionHash
enviado anteriormente. Essa etapa impede que alguém chame diretamente o revealSolution()
sem passar pelo commitSolution()
. Após a verificação bem-sucedida, ele verifica se a resposta está correta e, por fim, emite a recompensa.
Então, esse contrato realmente impede que Eve copie as respostas perfeitamente?
É claro que não! Então, o que está acontecendo?
Vemos que em revealSolution()
, a única restrição é commit.commitTime < block.timestamp. Portanto, suponha que Bob envie uma resposta no primeiro bloco e imediatamente chame revealSolution ("Ethereum", o segredo de Bob)
no segundo bloco, definindo Gas Price = 15 Gwei. Eve, por meio do monitoramento do pool de transações, obtém a resposta. Depois de obter a resposta, ela imediatamente define Gas Price = 100 Gwei, chama commitSolution()
no segundo bloco, envia a resposta e constrói várias transações de Gas Price alto para preencher o segundo bloco, espremendo assim a transação de Bob no terceiro bloco. No terceiro bloco, ela chama revealSolution("Ethereum", o segredo de Eve)
com Gas Price = 100 Gwei e recebe a recompensa.
Portanto, a pergunta é: Como podemos evitar efetivamente esses ataques?
É simples, você só precisa definir um valor uint256 revealSpan e verificar em commitSolution()
com require(commit.commitTime + revealSpan >= block.timestamp, "Cannot commit in this block", ou não pode fazer commit nesse bloco)
;. Isso pode impedir que a Eve copie a resposta. No entanto, durante a distribuição de prêmios, ainda é impossível impedir que a pessoa que enviou a resposta reivindique o prêmio primeiro.
Além disso, no espírito do rigor do código, a função revealSolution()
no código fixo não define commit.revealed
como True após a execução. Embora isso não afete nada, é recomendável desenvolver bons hábitos de codificação ao escrever o código, definindo a chave para o estado correto após a execução da lógica da função.
Links de Referência (em inglês):
Solidity by Example
https://solidity-by-example.org/hacks/front-running/
O que é gas na Ethereum? Taxas de transação da Ethereum
https://2miners.com/blog/what-is-gas-in-ethereum-ethereum-transaction-fees/
Esse artigo foi escrito por SlowMist e traduzido por Fátima Lima. O original pode ser lido aqui.
Latest comments (0)