Atualizado em 14 de junho de 2024: Um membro da comunidade examinou cuidadosamente este blog e forneceu informações sobre o incidente ADU, que é uma nova forma não coberta em nossa categorização anterior. Obrigado, e todo feedback perspicaz é bem-vindo!
Para aumentar a estabilidade do mercado, os tokens de reflexão (também conhecidos como tokens de recompensa) são criados para oferecer aos investidores uma via adicional de geração de renda. Isso incentiva os investidores a manterem seus tokens em vez de negociá-los. Durante a infame temporada de meme coins de 2021, os tokens de reflexão tornaram-se um mecanismo indispensável, captando rapidamente a atenção do mercado após serem lançados em plataformas como DxSale (por exemplo, SafeMoon V1).
Apesar do frenesi ter diminuído e o mercado ter esfriado em 2023, nosso sistema detectou dezenas de milhares de incidentes de hacks explorando tais mecanismos de token. Esses ataques do tipo ceifador, embora relativamente pequenos em valor quando comparados a outros tipos de ataques DeFi, resultaram em perdas não negligenciáveis para os ativos dos usuários.
Neste blog, nosso foco principal é compartilhar insights relacionados à segurança a partir de nossa pesquisa. Especificamente, primeiro forneceremos uma breve introdução ao mecanismo do token de reflexão. Em seguida, revisaremos incidentes de segurança relacionados a tokens de reflexão, com foco naqueles que exploram o mecanismo do token de reflexão. Depois, discutiremos um potencial problema de segurança de forma teórica. Por fim, compartilharemos alguns pensamentos sobre mitigação e soluções.
0x1 Mecanismo do Token de Reflexão
Até onde sabemos, esse mecanismo foi introduzido pela primeira vez pelo Reflect Finance, projetado para distribuir uma porcentagem do valor da transação como taxas para todos os detentores de tokens de forma não transacional. Em março de 2021, o renomado SafeMoon V1 foi lançado na rede BNB, o que popularizou ainda mais o token de reflexão.
0x1.1 Conceitos Básicos
Antes de mergulhar nos detalhes, alguns conceitos fundamentais devem ser introduzidos para uma melhor compreensão.
Existem dois tipos de espaços: r-space e t-space, lidos como espaço refletido e espaço verdadeiro, respectivamente. As criptomoedas dos dois espaços possuem taxas de câmbio baseadas no volume de circulação relativo. Além disso, a moeda no r-space é deflacionária, ou seja, a cada transação uma certa porcentagem será queimada e, como resultado, o valor queimado é deduzido do volume de circulação.
Considere que Alice, Bob e Eve são todos capazes de realizar transações em ambos os espaços, conforme mostrado na figura abaixo. Se Alice e Bob conduzirem transferências pareadas no t-space, Eve não receberá nenhuma recompensa. No entanto, se todos os três primeiro converterem seus tokens para o r-space e então deixarem Alice e Bob transferirem entre si, Eve acabará obtendo renda passiva ao converter seus tokens de volta para o t-space. Essa é a ideia básica do mecanismo do token de reflexão.
Observe que nem todas as contas, como o pool de fornecimento de liquidez do token, são capazes de negociar no r-space, ou seja, certas contas precisam ser excluídas do r-space.
0x1.2 Explicação no Nível do Contrato
Agora vamos nos aprofundar no contrato REFLECT do Reflect Finance para explorar esse mecanismo.
Este contrato primeiro define diversas variáveis para o gerenciamento de contas:
mapping (address => uint256) private _rOwned; // token refletido mantido pelo usuário
mapping (address => uint256) private _tOwned; // token verdadeiro mantido pelo usuário
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // se o usuário está excluído do r-space
address[] private _excluded; // contas que estão excluídas do r-space
Em seguida, define as constantes essenciais do contrato. Pode-se observar que _rTotal é definido como um determinado múltiplo de _tTotal (ou seja, o totalSupply do token, que é usado como valor de retorno da função totalSupply):
uint256 private constant MAX = ~uint256(0);
uint256 private constant _tTotal = 10 * 10**6 * 10**9;
uint256 private _rTotal = (MAX - (MAX % _tTotal));
Do ponto de vista da funcionalidade e da interação do usuário, as funções deste contrato se enquadram nas três categorias a seguir: consulta de saldo e transferência de tokens, juntamente com uma função reflect diferenciada. As duas primeiras são compatíveis com o padrão ERC-20, porém a lógica interna difere dos outros tokens ERC-20. Cada uma dessas funções será detalhada abaixo.
0x1.2.1 Funções para consulta de saldo
O cálculo do saldo difere para usuários excluídos e não excluídos:
function balanceOf(address account) public view override returns (uint256) {
if (_isExcluded[account]) return _tOwned[account];
return tokenFromReflection(_rOwned[account]);
}
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
Pode ser expresso pela seguinte fórmula:

A taxa na fórmula acima é calculada invocando a função _getRate, que é na verdade calculada a partir do valor de retorno da função _getCurrentSupply dentro do contrato.
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
function _getCurrentSupply() private view returns(uint256, uint256) {
uint256 rSupply = _rTotal;
uint256 tSupply = _tTotal;
for (uint256 i = 0; i < _excluded.length; i++) {
if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
rSupply = rSupply.sub(_rOwned[_excluded[i]]);
tSupply = tSupply.sub(_tOwned[_excluded[i]]);
}
if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
return (rSupply, tSupply);
}
Não é difícil derivar a fórmula correspondente a partir do trecho de código acima:

0x1.2.2 Funções para transferência de tokens
Em geral, há quatro cenários para transferir ativos, como segue:
function _transfer(address sender, address recipient, uint256 amount) private {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
require(amount > 0, "Transfer amount must be greater than zero");
if (_isExcluded[sender] && !_isExcluded[recipient]) {
_transferFromExcluded(sender, recipient, amount); // t-space -> r-space
} else if (!_isExcluded[sender] && _isExcluded[recipient]) {
_transferToExcluded(sender, recipient, amount); // r-space -> t-space
} else if (!_isExcluded[sender] && !_isExcluded[recipient]) {
_transferStandard(sender, recipient, amount); // r-space -> r-space
} else if (_isExcluded[sender] && _isExcluded[recipient]) {
_transferBothExcluded(sender, recipient, amount); // t-space -> t-space
} else {
_transferStandard(sender, recipient, amount); // r-space -> r-space
}
}
Para contas excluídas, tanto _rOwned quanto _tOwned devem ser adicionados ou subtraídos do espaço respectivo. Para contas não excluídas, apenas _rOwned precisa ser considerado. Por exemplo, o trecho de código abaixo mostra a implementação de transferência de ativos do r-space para o t-space, onde sender é uma conta não excluída e recipient é uma conta excluída.
function _transferToExcluded(address sender, address recipient, uint256 tAmount) private {
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_tOwned[recipient] = _tOwned[recipient].add(tTransferAmount);
_rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
_reflectFee(rFee, tFee);
emit Transfer(sender, recipient, tTransferAmount);
}
-
A função
_getValuescalcula o valor correspondente, transferAmount e taxa (ou seja, valor = transferAmount + taxa) para ambos os espaços.function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256) { (uint256 tTransferAmount, uint256 tFee) = _getTValues(tAmount); uint256 currentRate = _getRate(); (uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate); return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee); } -
A função
_reflectFeereflete a taxa no r-space. Especificamente,_rTotalé reduzido pela taxa no r-space (ou seja,rFee), o que por sua vez diminui a taxa. De acordo com a fórmula de cálculo balanceOf(usuario), as taxas são refletidas para todos os detentores de tokens não excluídos dessa maneira.function _reflectFee(uint256 rFee, uint256 tFee) private { _rTotal = _rTotal.sub(rFee); _tFeeTotal = _tFeeTotal.add(tFee); }
0x1.2.3 A função reflect
Além do acionamento passivo do mecanismo do token de reflexão durante o processo de transferência, os usuários podem invocar ativamente a função reflect para iniciar esse mecanismo. Especificamente, se alguém consumir os tokens que possui para invocar essa função, a taxa diminuirá à medida que rSupply diminui, beneficiando assim os outros detentores de tokens. Em outras palavras, sacrificar-se pelo bem dos outros. Ao fazer isso, os mantenedores do projeto podem incentivar os detentores de tokens através da utilização dessa função.
function reflect(uint256 tAmount) public {
address sender = _msgSender();
require(!_isExcluded[sender], "Excluded addresses cannot call this function");
(uint256 rAmount,,,,,,) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rTotal = _rTotal.sub(rAmount);
_tFeeTotal = _tFeeTotal.add(tAmount);
}
Os códigos fornecidos acima formam o núcleo do mecanismo. Diferentes tokens podem incorporar funções adicionais específicas para personalizar sua implementação. Por exemplo, alguns podem usar taxas de transação para alimentar uma função de "swap and liquify" (trocar e liquidificar) para evitar corridas quando grandes investidores decidem vender seus tokens.
0x2 Post-Mortem de Tokens de Reflexão Comprometidos
Como mencionado anteriormente, nosso foco principal são os ataques que exploram o mecanismo do token de reflexão. Portanto, incidentes não relacionados a esse mecanismo, como o ataque ao SafeMoon V2 (um problema comum de queima pública ERC20) e o recente ataque ao ZongZi (relacionado a uma manipulação de preço à moda antiga explorando o preço spot), não são abordados.
Realizamos uma análise aprofundada desses ataques para desmistificar suas causas raiz. Você pode consultar aqui uma lista de todos esses incidentes. Descobrimos que a maioria deles são incidentes de segurança normais causados por vulnerabilidades de código ou operações administrativas inadequadas. No entanto, alguns são bastante suspeitos (por exemplo, a presença de uma backdoor), os quais nos referimos como incidentes de segurança anormais. Nas subseções a seguir, primeiro introduziremos os incidentes de segurança normais e depois analisaremos os anormais em detalhes.
0x2.1 Incidentes de segurança normais
Nossa investigação sugere que esses incidentes decorrem de dois tipos de problemas, ou seja, problemas de nível de código e problemas de nível de operação, individualmente ou em combinação.
-
Problemas de nível de código. Isso surge da implementação precária do contrato, provavelmente porque os desenvolvedores não compreenderam completamente o mecanismo do token de reflexão, levando a uma inconsistência entre o fornecimento real de tokens e o valor registrado de
totalSupply, que pode ser usada para manipular a taxa:-
1.1 Queima de custo zero
-
1.2 Dedução extra de
rSupplydurante transferências de tokens -
1.3 Confusão entre valores do r-space e do t-space (com perda de precisão para obter lucros)
-
-
Problemas de nível de operação. Isso resulta de operações inadequadas pelos administradores. Especificamente, nesses incidentes, refere-se à configuração inadequada dos endereços do par AMM, que não são devidamente excluídos.
Vale destacar que TODOS os problemas de nível de código listados podem levar a inconsistências entre o fornecimento real e o totalSupply, tornando os contratos vulneráveis. No entanto, isso não significa necessariamente que essas vulnerabilidades sejam exploráveis ou, mais precisamente, lucrativas o suficiente para valer a pena explorar, pois pode haver um custo para o atacante manipular a taxa. Por simplicidade, a seguir, usaremos o termo "explorável" para significar "lucrativo o suficiente para valer a pena explorar". Como resultado, um problema de nível de operação em alguns cenários é necessário para tornar essas vulnerabilidades exploráveis.
Especificamente, o problema 1.1 pode ser explorado diretamente, enquanto os problemas 1.2 e 1.3 requerem uma combinação com o problema 2 para se tornarem exploráveis. Portanto, os incidentes em discussão podem ser divididos em dois tipos, Tipo-I e Tipo-II, com base nessas observações. Abaixo está uma tabela delineando os dados relevantes:
| Tipo | Incidente(s) | Causa(s) Raiz | # (%) |
|---|---|---|---|
| I | CATOSHI | Apenas problema de nível de código (1.1) | 1 (0,79%) |
| II (a) | BEVO, FETA, ADU | Combinação de ambos (1.2 & 2) | 3 (2,38%) |
| II (b) | SHEEP e outros 120+ | Combinação de ambos (1.3 & 2) | 122 (96,83%) |
A tabela revela que os incidentes do Tipo-II representam uma proporção significativa. Especificamente, há 2 variações dentro do Tipo-II: Tipo-II-a (ou seja, problema 1.2 com problema 2) e Tipo-II-b (ou seja, problema 1.3 com problema 2). Além disso, para incidentes do tipo SHEEP da categoria Tipo-II-b, os exploits sugerem que os atacantes (por exemplo, este) podem estar usando métodos automatizados para identificar contratos vulneráveis similares. Detalhes específicos serão explorados nas subseções subsequentes.
0x2.1.1 Tipo-I: Apenas Problema de Nível de Código (Problema 1.1)
Apenas um incidente pertence ao Tipo-I, ou seja, o incidente CATOSHI, uma queima de custo zero que afeta o fornecimento total.
Vamos primeiro dar uma olhada na função burnOf no contrato CATOSHI:
function burnOf(uint256 tAmount) public {
uint256 currentRate = _getRate();
uint256 rAmount = tAmount.mul(currentRate);
_tTotal = _tTotal.sub(tAmount);
_rTotal = _rTotal.sub(rAmount);
emit Transfer(_msgSender(), address(0), tAmount);
}
Obviamente, o valor queimado por essa função não é deduzido do chamador (ou seja, msg.sender). No entanto, _rOwned[msg.sender] deve ser reduzido por rAmount, e se a conta estiver excluída, _tOwned[msg.sender] também deve ser reduzido por tAmount.
Devido a essa falha, os atacantes podem inicialmente queimar uma grande quantidade de tokens a custo zero e depois invocar a função reflect do contrato. Como tanto _tTotal quanto _rTotal foram significativamente reduzidos de forma proporcional:

A taxa pode ser facilmente manipulada para baixo ao invocar a função reflect, fazendo com que o balanceOf(atacante) aumente substancialmente. Isso permite que os atacantes lucrem com o saldo inflado.

Por quê? Observe que o novo saldo do atacante é calculado da seguinte forma:

A razão entre balanceOf(atacante) e balanceOf(atacante)' é:

Como

Portanto

O que significa que o atacante colhe mais tokens que podem ser trocados por tokens valiosos (WETH neste caso) para obter lucro.
0x2.1.2 Tipo-II-a: Combinação do Problema 1.2 e do Problema 2
Os incidentes do Tipo-II-a envolvem a combinação de dois problemas:
- Problema 1.2: Dedução extra de
rSupplydurante transferências de tokens. - Problema 2: Par AMM não está excluído.
No Tipo-II-a, há três incidentes de ataque, que podem ser ainda divididos em duas subcategorias de acordo com as formas de vulnerabilidade no problema 1.2, como segue:
1. Reflexão extra na função _reflectFee
Dois incidentes pertencem a esta subcategoria, ou seja, o incidente BEVO e o incidente FETA. A seguir, usaremos o contrato BEVO para ilustração.
Conforme introduzido em 'Funções para Transferência de Tokens' (seção 0x1.2.2), cada transferência de token aciona a reflexão de uma parte da taxa de transação. No BEVO, as taxas são divididas em duas partes adicionais: queima e caridade, além da original.
function _reflectFee(uint256 rFee, uint256 rBurn, uint256 rCharity, uint256 tFee, uint256 tBurn, uint256 tCharity) private {
_rTotal = _rTotal.sub(rFee).sub(rBurn).sub(rCharity); // rCharity é deduzido de _rTotal
_tFeeTotal = _tFeeTotal.add(tFee);
_tBurnTotal = _tBurnTotal.add(tBurn);
_tCharityTotal = _tCharityTotal.add(tCharity);
_tTotal = _tTotal.sub(tBurn);
}
Observe que a conta de caridade está excluída, o que significa que o valor enviado para esta conta é queimado, conforme mostrado na função _sendToCharity.
function _sendToCharity(uint256 tCharity, address sender) private {
uint256 currentRate = _getRate();
uint256 rCharity = tCharity.mul(currentRate);
address currentCharity = _charity[0];
_rOwned[currentCharity] = _rOwned[currentCharity].add(rCharity);
_tOwned[currentCharity] = _tOwned[currentCharity].add(tCharity); // como a conta de caridade está excluída, a parte de caridade é queimada
emit Transfer(sender, currentCharity, tCharity);
}
A partir do trecho de código acima, podemos ver que há dois lugares para refletir e queimar a parte de caridade, fazendo com que o fornecimento real de tokens se torne inconsistente com o totalSupply durante as transferências. À medida que mais tokens são transferidos, o valor de rSupply será menor que o fornecimento de tokens no pool devido à diminuição adicional.
A descrição puramente teórica pode ser um pouco abstrata, então vamos usar um exemplo para esclarecer o processo. Suponha que Alice queira transferir 10 tokens para Bob, e 3 tokens são deduzidos da seguinte forma: 1 para a taxa, 1 para a queima e 1 para a caridade. Como a parte de caridade é tanto refletida quanto queimada, a divisão real é de 2 tokens refletidos (1 taxa + 1 caridade) e 2 tokens queimados (1 queima + 1 caridade). Junto com os 7 tokens restantes a serem transferidos para Bob, um total de 11 tokens estão envolvidos nesse processo, o que é incorreto.
Mas por que essa inconsistência pode ser explorada para obter lucros? Abaixo, vamos agitar nossa varinha mágica matemática para derivar as consequências.
Suponha que tenhamos adquirido anteriormente alguns tokens do pool (ou seja, par PancakeSwap), denotados como rAmount no r-space e tAmount no t-space. Como o pool não foi excluído, vamos denotar _rOwned[pair] como rReserve, com o valor correspondente no t-space também denotado como tReserve. Então temos:

Devido à diminuição adicional, rSupply é agora menor que o fornecimento de tokens no pool:

Recordando a seção 'Funções para Consulta de Saldo' (0x1.2.1), a taxa atual pode ser calculada usando a seguinte fórmula:

Neste momento, se refletirmos os tokens que possuímos através da função reflect (que é renomeada como função deliver neste contrato), a taxa torna-se taxa':

Como

Então temos

Combinando as fórmulas 1, 3 e 6, podemos derivar a seguinte desigualdade:

Isso significa que a quantidade de tokens que podemos colher diretamente do pool (via função skim) é ainda maior do que o que entregamos, tornando-o lucrativo porque o custo de invocar a função reflect pode ser coberto. Depois disso, o atacante pode trocar os tokens colhidos por tokens valiosos (WBNB neste caso) para obter lucro.
Observe que o contrato BEVO também é vulnerável ao problema 1.3, que não foi explorado no ataque.
2. Cálculo incorreto de rTransferAmount na função _getRValues
Apenas um incidente pertence a esta forma, ou seja, o incidente ADU. Vamos primeiro dar uma olhada no trecho de código abaixo.
function _transferStandard(address sender, address recipient, uint256 tAmount) private {
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee, uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getValues(tAmount);
_rOwned[sender] = _rOwned[sender].sub(rAmount);
_rOwned[recipient] = _rOwned[recipient].add(rTransferAmount);
_takeTeam(tTeam);
_reflectFee(rFee, tFee);
emit Transfer(sender, recipient, tTransferAmount);
}
function _getValues(uint256 tAmount) private view returns (uint256, uint256, uint256, uint256, uint256, uint256) {
(uint256 tTransferAmount, uint256 tFee, uint256 tTeam) = _getTValues(tAmount, _taxFee, _teamFee);
uint256 currentRate = _getRate();
(uint256 rAmount, uint256 rTransferAmount, uint256 rFee) = _getRValues(tAmount, tFee, currentRate);
return (rAmount, rTransferAmount, rFee, tTransferAmount, tFee, tTeam);
}
function _getTValues(uint256 tAmount, uint256 taxFee, uint256 teamFee) private pure returns (uint256, uint256, uint256) {
uint256 tFee = tAmount.mul(taxFee).div(100);
uint256 tTeam = tAmount.mul(teamFee).div(100);
uint256 tTransferAmount = tAmount.sub(tFee).sub(tTeam); // tTeam é deduzido de tAmount
return (tTransferAmount, tFee, tTeam);
}
function _getRValues(uint256 tAmount, uint256 tFee, uint256 currentRate) private pure returns (uint256, uint256, uint256) {
uint256 rAmount = tAmount.mul(currentRate);
uint256 rFee = tFee.mul(currentRate);
uint256 rTransferAmount = rAmount.sub(rFee); // No entanto, não há rTeam deduzido de rAmount
return (rAmount, rTransferAmount, rFee);
}
Podemos ver que tanto a taxa de imposto quanto a taxa de equipe devem ser deduzidas durante as transferências. No entanto, na função _getTvalues, o tTransferAmount é subtraído tanto por tFee quanto por tTeam, enquanto na função _getRValues, apenas rFee é subtraído. Essa discrepância leva ao problema de inconsistência mencionado anteriormente, que piora à medida que mais transferências de tokens ocorrem.
Como o par também não está excluído no token, esse token é explorável. Especificamente, um atacante poderia usar exploits similares ao BEVO para colher mais tokens ADU via a função skim do par após chamar a função deliver.
No entanto, dado o estado on-chain naquele momento, é impossível para o atacante trocar os tokens ADU colhidos por WBNB para obter lucro (devido à instrução require na função tokenFromReflection). Portanto, o atacante precisaria empregar uma estratégia de exploração mais complexa para lucrar, que não será detalhada aqui.
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
0x2.1.3 Tipo-II-b: Combinação do Problema 1.3 e do Problema 2
Os incidentes do Tipo-II-b envolvem a combinação de dois problemas:
- Problema 1.3: Confusão entre valores do r-space e do t-space (observando que a perda de precisão também deve estar presente para obter lucros).
- Problema 2: Par AMM não está excluído.
O Problema 1.3 decorre do tratamento incorreto de valores entre o r-space e o t-space durante a implementação da função interna _burn.
function burn(uint256 _value) public {
_burn(msg.sender, _value);
}
function _burn(address _who, uint256 _value) internal {
require(_value <= _rOwned[_who]);
_rOwned[_who] = _rOwned[_who].sub(_value); // _rOwned deve ser subtraído por um valor do r-space
_tTotal = _tTotal.sub(_value); // _tTotal deve ser subtraído por um valor do t-space
// Para a semântica da função de queima, _rTotal também deve ser subtraído de um valor do r-space.
emit Transfer(_who, address(0), _value);
}
Considerando as constantes essenciais do contrato, o valor no r-space é tipicamente um grande múltiplo do valor no t-space. Portanto, invocar a função burn com um _value que é da mesma ordem de grandeza que tSupply inflará significativamente a taxa.
No entanto, ao contrário dos casos do Tipo-I, o chamador não pode queimar tokens mantendo seu próprio saldo inalterado. Em outras palavras, é difícil, senão impossível, para o atacante colher mais tokens. Portanto, como os casos do Tipo-II-b poderiam ser exploráveis?
Tomando o incidente do token SHEEP como exemplo. O valor dos tokens SHEEP mantidos pelo atacante pode ser denotado como:

Onde o preço do SHEEP pode ser expresso pelo preço spot no par PancakeSwap, calculado como:

Então o Valor pode ser expresso ainda como:

O atacante então executa repetidamente a função burn e eventualmente sincroniza o par. Como nem o atacante nem o par estão excluídos, seus saldos diminuem devido à inflação da taxa que mencionamos acima. Assim, a razão se ajusta para:

Com base nas definições anteriores, podemos expressar ainda essas razões como:

Onde X representa a soma dos _value queimados.
Aqui vem a mágica: a razão posterior é claramente menor que a anterior se simplificarmos 8 e 9 ainda mais, o que nos deixa perplexos porque o lucro será um valor negativo neste caso:

Na verdade, o atacante aproveitou um problema de perda de precisão no token de reflexão. Para usuários não excluídos, de acordo com a fórmula que fornecemos, o cálculo do saldo é na verdade arredondado para baixo na função tokenFromReflection. Assim, o valor de retorno da consulta balanceOf pode ser menor que seu valor teórico. Ou seja, a razão' pode ser maior que a razão se levarmos em consideração esse problema.
function tokenFromReflection(uint256 rAmount) public view returns(uint256) {
require(rAmount <= _rTotal, "Amount must be less than total reflections");
uint256 currentRate = _getRate();
return rAmount.div(currentRate);
}
Ao depurar a transação de ataque, podemos calcular o saldo teórico do atacante e do par antes e depois dessas manipulações. Os resultados de nossos cálculos estão delineados na tabela abaixo:
Δ na tabela é um valor extremamente pequeno, muito inferior a 1.
Ao analisar o rastreamento dentro da função de sincronização do par, podemos calcular que os saldos teóricos do atacante e do par são na verdade 27,523 e 2,972, respectivamente, resultando em uma razão de 9,26. No entanto, devido à perda de precisão, os saldos são arredondados para baixo para 27 e 2, respectivamente, inflando a razão para 13,50. Como resultado, o Lucro torna-se um valor positivo.
Por fim, o atacante pode lucrar realizando uma troca reversa.
0x2.2 Incidentes de segurança anormais
Nesta subseção, compartilharemos nossas descobertas da investigação dos tokens FDP e DBALL. Nossa análise indica que os gestores de ambos os tokens FDP e DBALL invocaram funções privilegiadas problemáticas, atuando efetivamente como backdoors, o que colocou os projetos em risco e eventualmente levou a ataques. Especificamente, no projeto DBALL, identificamos uma série de transações suspeitas pelo proprietário do token, que fornecem evidências claras para considerá-lo um rug pull.
As explorações direcionadas a esses dois tokens se assemelham estreitamente àquelas discutidas na seção 'Tipo-II-a: Combinação do Problema 1.2 e do Problema 2' descrita em 0x2.1.2. No entanto, ao analisar as razões pelas quais o fornecimento real de tokens pode divergir do totalSupply, algumas atividades suspeitas vêm à tona.
0x2.2.1 O incidente FDP
A discrepância entre o fornecimento real de tokens e o totalSupply no caso FDP decorre da invocação da função transferOwnership, uma função privilegiada que só pode ser invocada pelo proprietário do contrato. Como o nome sugere, essa função deveria alterar a propriedade do contrato. No entanto, no contrato FDP, essa função não tem nada a ver com a transferência de propriedade. Em vez disso, ela aumenta _rOwned[newOwner] sem alterar o totalSupply. Isso claramente viola os princípios de design do processo normal de cunhagem de tokens.
function transferOwnership(address newOwner) public virtual onlyOwner {
(, uint256 rTransferAmount,, uint256 tTransferAmount,,) = _getValues(_tTotal);
_rOwned[newOwner] = _rOwned[newOwner].add(rTransferAmount);
emit Transfer(address(0), newOwner, tTransferAmount);
}
As transações que chamaram essa função estão resumidas na tabela a seguir:
| Timestamp | Hash da Transação | Chamador | O newOwner |
|---|---|---|---|
| 2021-06-05 17:23:57 | 0x46fa1f97...4606d9bc | 0xef309c...262586 | 0x9e96af...24481a |
| 2021-06-05 17:24:06 | 0x686e0d82...d6ebfb62 | 0xef309c...262586 | 0xb0c426...a72063 |
| 2021-06-05 17:26:12 | 0x44285339...7a526320 | 0xef309c...262586 | 0xef309c...262586 |
| 2021-06-05 19:39:00 | 0xaff7a688...dfe3f344 | 0xef309c...262586 | 0x9e96af...24481a |
| 2022-06-05 18:08:10 | 0x2c413604...f7718f25 | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 18:49:16 | 0x8f4309ca...97d4bcec | 0xef309c...262586 | 0xef309c...262586 |
| 2022-06-05 23:33:44 | 0xaa029544...3ff7b629 | 0xef309c...262586 | 0xef309c...262586 |
0x2.2.2 O incidente DBALL
O caso DBALL é mais complicado. Usando o MetaSleuth para analisar o fluxo de fundos do proprietário do DBALL, observamos um desequilíbrio no fluxo de entrada e saída de tokens DBALL para este endereço, com a fonte de fundos para esta transação não sendo registrada.
Consultando os estados históricos on-chain, finalmente identificamos que o saldo DBALL do proprietário mudou antes e depois de esta transação. Podemos observar que o proprietário chamou a função privilegiada manualDevBurn para queimar 1 token no t-space. A implementação dessa função é a seguinte:
function manualDevBurn (uint256 tAmount) public onlyOwner() {
uint256 rAmount = tAmount.mul(_getRate());
if (_isExcluded[_msgSender()]) {
_tOwned[_msgSender()] = _tOwned[_msgSender()] - (tAmount);
}
_rOwned[_msgSender()] = _rOwned[_msgSender()] - (rAmount);
_tOwned[address(0)] = _tOwned[address(0)] + (tAmount);
_rOwned[address(0)] = _rOwned[address(0)] + (rAmount);
emit Transfer(_msgSender(), address(0), tAmount);
}
À primeira vista, tudo parece bem. No entanto, como o contrato especifica uma versão do compilador inferior a 0.8, ocorre um underflow aritmético durante a subtração de _rOwned[_msgSender()], transitando de 0 para quase type(uint256).max. Essa manipulação sutil permite que o proprietário altere seu saldo, mas também resulta em inconsistência no fornecimento de tokens.
Isso é apenas um erro acidental? Nossa investigação sugere que é mais provavelmente um rug pull intencional. Os motivos estão resumidos da seguinte forma:
-
O proprietário passou apenas 1 token para a função
manualDevBurn, mas em menos de meia hora, uma quantidade de DBALL igual ao fornecimento total foi transferida para um endereço associado através de esta transação. -
Esse endereço associado imediatamente realizou uma troca no par PancakeSwap e obteve aproximadamente 56 WBNB.
- A análise dos fluxos de fundos desses dois endereços revela que ambos eventualmente transferiram BNB através do Tornado.Cash.
0x3 Um Problema Potencial no Cálculo da taxa
Além dos incidentes que discutimos anteriormente, também descobrimos que, teoricamente, existe um problema potencial no cálculo do saldo de usuários não excluídos que merece uma discussão mais aprofundada. Esse problema pode surgir durante o cálculo da taxa.
Vamos dar uma olhada na função _getCurrentSupply. Nessa função, a instrução if no final determina se rSupply é menor que a taxa inicial (ou seja, _rTotal / _tTotal).
function _getRate() private view returns(uint256) {
(uint256 rSupply, uint256 tSupply) = _getCurrentSupply();
return rSupply.div(tSupply);
}
function _getCurrentSupply() private view returns(uint256, uint256) {
uint256 rSupply = _rTotal;
uint256 tSupply = _tTotal;
for (uint256 i = 0; i < _excluded.length; i++) {
if (_rOwned[_excluded[i]] > rSupply || _tOwned[_excluded[i]] > tSupply) return (_rTotal, _tTotal);
rSupply = rSupply.sub(_rOwned[_excluded[i]]);
tSupply = tSupply.sub(_tOwned[_excluded[i]]);
}
if (rSupply < _rTotal.div(_tTotal)) return (_rTotal, _tTotal);
return (rSupply, tSupply);
}
Durante o ciclo de vida desde a implantação do contrato até o lançamento do projeto, se essa instrução for verdadeira, os saldos de todos os usuários não excluídos seriam zero. No entanto, uma vez que o projeto foi lançado e as transações começaram, a intenção original da instrução if foi perdida.
Como rSupply diminuirá devido ao mecanismo do token de reflexão, a taxa diminuirá de acordo. Se, após uma determinada transação, rSupply cair abaixo da taxa inicial, a taxa atual irá saltar, resultando em perdas de saldo para todos os usuários não excluídos. Além disso, é teoricamente possível que

Isso faz com que a taxa se torne zero devido à perda de precisão, potencialmente desencadeando um pânico de divisão por zero.
0x4 Mitigação e Soluções
O mecanismo do token de reflexão oferece uma forma de aumentar a estabilidade do mercado ao incentivar os investidores a manterem seus tokens em vez de negociarem para receber recompensas adicionais. No entanto, também introduz novos desafios de segurança e riscos potenciais, como a confusão entre valores do r-space e do t-space. Portanto, é crucial que desenvolvedores blockchain e investidores obtenham uma melhor compreensão do mecanismo e seus riscos potenciais e busquem soluções.
A BlockSec fornece serviços e produtos de segurança para as fases pré e pós-lançamento. Nossos serviços de auditoria de segurança realizam revisões completas para garantir a segurança e a transparência do código. Nosso produto Phalcon oferece monitoramento contínuo de segurança e capacidades de detecção de ataques, permitindo que operadores e investidores monitorem projetos e tomem ações automáticas quando riscos de segurança são detectados.
Leitura Relacionada
- Como as Blockchains L2 Podem Fazer Melhor para Proteger Seus Usuários
- Os 10 Principais Incidentes de Segurança "Incríveis" em 2023
- Análise Completa Conceitual de Uma Ascensão do Bitcoin com Inscrição
Sobre a BlockSec
A BlockSec é um provedor de serviços de segurança Web3 full-stack. A empresa está comprometida em aprimorar a segurança e a usabilidade para o emergente mundo Web3, a fim de facilitar sua adoção em massa. Para isso, a BlockSec fornece serviços de auditoria de segurança de contratos inteligentes e cadeias EVM, a plataforma Phalcon para desenvolvimento de segurança e bloqueio proativo de ameaças, a plataforma MetaSleuth para rastreamento e investigação de fundos, e a extensão MetaSuites para construtores web3 navegarem com eficiência no mundo cripto.
Até o momento, a empresa atendeu mais de 300 clientes como Uniswap Foundation, Compound, Forta e PancakeSwap, e recebeu dezenas de milhões de dólares americanos em duas rodadas de financiamento de investidores proeminentes, incluindo Matrix Partners, Vitalbridge Capital e Fenbushi Capital.
-
Website: https://blocksec.com/
-
Email: [email protected]
-
Twitter:https://twitter.com/BlockSecTeam
-
MetaSleuth: https://metasleuth.io/
-
MetaSuites: https://blocksec.com/metasuites



