Damn Vulnerable DeFi (Maldita DeFi Vulnerável) é uma série de jogos CTF (Capture the flag ou Capturar o sinalizador) onde o jogador deve encontrar uma vulnerabilidade para quebrar ou roubar um protocolo.
Esses jogos educativos são muito interessantes no sentido de que eles imitam aplicativos reais (flashloans, pool, yield, …), permitindo-nos aprender não apenas sobre segurança na web3, mas também o que é DeFi.
Link aqui: https://www.damnvulnerabledefi.xyz/index.html
Nesta série de artigos, apresentarei os desafios e suas soluções.
#1 Unstoppable (Imparável)
Há um pool de empréstimo com um milhão de tokens DVT de saldo, oferecendo empréstimos relâmpagos gratuitamente.
Se ao menos houvesse uma maneira de atacar e impedir que o pool oferecesse empréstimos relâmpagos...
Você começa com 100 tokens DVT de saldo.
Impede o pool de oferecer empréstimos relâmpagos.
Descrição do protocolo
Em primeiro lugar, precisamos entender como funciona o protocolo.
O protocolo é composto por 3 contratos principais:
- DamnValuableToken.sol \ Este é o Token ERC-20 da série de desafios: DVT. Este é um ERC-20 muito simples, sem recursos adicionais. Na construção, o max(uint256) é cunhado para o msg.sender.
-
UnstoppableLender.sol \
Este contrato oferece flashloans (empréstimos relâmpagos) para outros contratos que obedecem a uma interface específica, chamando esses contratos por meio de um método
receiveToken
. -
ReceiverUnstoppable.sol \
Este é um contrato pronto para uso capaz de interagir com o UnstoppableLender que já implementa o método
receiveToken
.
Claramente, o que precisamos verificar aqui é o pool de empréstimos, pois é esse que o desafio nos pede para quebrar:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
interface IReceiver {
function receiveTokens(address tokenAddress, uint256 amount) external;
}
/**
* @title UnstoppableLender
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract UnstoppableLender is ReentrancyGuard {
IERC20 public immutable damnValuableToken;
uint256 public poolBalance;
constructor(address tokenAddress) {
require(tokenAddress != address(0), "O endereço do Token não pode ser zero");
damnValuableToken = IERC20(tokenAddress);
}
function depositTokens(uint256 amount) external nonReentrant {
require(amount > 0, "Deve depositar pelo menos um token");
// Token de transferência do remetente. O remetente deve tê-los aprovado primeiro.
damnValuableToken.transferFrom(msg.sender, address(this), amount);
poolBalance = poolBalance + amount;
}
function flashLoan(uint256 borrowAmount) external nonReentrant {
require(borrowAmount > 0, "Deve tomar emprestado pelo menos um token");
uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
require(balanceBefore >= borrowAmount, "Não há tokens suficientes no pool");
// Garantido pelo protocolo através da função `depositTokens`
assert(poolBalance == balanceBefore);
damnValuableToken.transfer(msg.sender, borrowAmount);
IReceiver(msg.sender).receiveTokens(address(damnValuableToken), borrowAmount);
uint256 balanceAfter = damnValuableToken.balanceOf(address(this));
require(balanceAfter >= balanceBefore, "O empréstimo relâmpago ainda não foi pago");
}
}
Notas rápidas após a primeira leitura:
- O contrato usa solidity >0.8.0, onde overflow/underflow agora são verificados durante a compilação.
- Ele usa a biblioteca OpenZeppelin ReentrancyGuard e a usa em todas as funções, nenhum ataque de reentrância deve ser possível.
- Existe uma função de depósito que permite que qualquer pessoa envie DVT para o pool para aumentar seu saldo.
- Múltiplas verificações são feitas durante a execução do método
flashLoan
.
Se pensarmos no desafio, somos solicitados a impedir que o pool de credores ofereça empréstimos relâmpagos. Isso pode ser feito drenando todos os seus fundos ou quebrando sua lógica de verificação.
Solução
Se quebrarmos a lógica da função, temos isso:
-
require(borrowAmount>0)
\ Não podemos pedir um empréstimo relâmpago de 0. -
balanceBefore = DVT.balanceOf(address(this))
\ A função armazena o saldo, conforme contabilizado pelo contrato de DVT em uma variável chamadabalanceBefore.
-
require(balanceBefore >= borrowAmount)
\ Não podemos emprestar mais do que o pool realmente possui, a transferência falharia de qualquer maneira na linha 42. -
assert(poolBalance == balanceBefore)
\ Assert é um tipo especial de verificação, mas no final se comporta como um request e reverte toda a execução (mais detalhes aqui). \ E aqui está nossa vulnerabilidade.
O que acontece se eu transferir alguns tokens DVT para o pool sem usar sua função deposit
?
Sim, enquanto seu saldo no contrato DVT aumentará, sua própria variável interna poolBalance
, que deve ser sempre igual a damnValuableToken.balanceOf(address(this))
será distorcida pelo valor transferido, e a assertiva sempre falhará.
Para fazer isso, tudo o que precisamos fazer é abrir o arquivo challenge.js correspondente e adicioná-lo dentro da seção Exploit:
it('Exploit', async function () {
/** CODIFIQUE SEU EXPLOIT AQUI */
await this.token.connect(attacker).transfer(this.pool.address, 10);
});
Se você executar o teste usando:
>npx hardhat test .\test\unstoppable.challenge.js
O teste deve passar, o que significa que você resolveu o desafio.
Etapas de mitigação recomendadas
Uma maneira fácil de corrigir essa vulnerabilidade seria remover a variável poolBalance
e a asserção desnecessária. O contrato DVT já mantém o registro de cada saldo.
Espero que tenham gostado deste artigo e nos vemos na próxima vez para um novo desafio!
Artigo escrito por Salah I e traduzido por Marcelo Panegali.
Latest comments (0)