Atualizado em 14 de junho de 2024: Um membro da comunidade examinou cuidadosamente este blog e forneceu informações sobre o incidente da ADU, que é uma nova forma não abordada em nossa categorização anterior. Obrigado, e todo feedback perspicaz é bem-vindo!
Para aumentar a estabilidade do mercado, os reflection tokens (também conhecidos como reward tokens) são criados para oferecer aos investidores uma via adicional para obter renda. Isso incentiva os investidores a manter seus tokens em vez de negociá-los. Durante a famosa temporada de meme coins de 2021, os reflection tokens se tornaram um mecanismo indispensável, rapidamente capturando a atenção do mercado após serem lançados em plataformas como a DxSale (por exemplo, SafeMoon V1).
Apesar do frenesi diminuir e o mercado esfriar em 2023, nosso sistema detectou dezenas de milhares de incidentes de hackeamento explorando esses mecanismos de token no mundo real. Esses ataques do tipo "ceifador", embora relativamente pequenos em valor em comparação com 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 de nossa pesquisa. Especificamente, primeiro forneceremos uma breve introdução ao mecanismo do reflection token. Em seguida, revisaremos incidentes de segurança relacionados a reflection tokens, com foco naqueles que exploram o mecanismo do reflection token. Depois, discutiremos teoricamente um problema de segurança potencial. Por fim, compartilharemos algumas reflexões sobre mitigação e soluções.
0x1 Mecanismo do Reflection Token
Até onde sabemos, esse mecanismo foi introduzido pela primeira vez pela 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 BNB chain, o que popularizou ainda mais o reflection token.
0x1.1 Conceitos Básicos
Antes de entrar nos detalhes, alguns conceitos fundamentais devem ser introduzidos para obter 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, para 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 realizarem transferências entre si no t-space, Eve não receberá nenhuma recompensa. No entanto, se os três primeiro converterem seus tokens para o r-space e depois deixarem Alice e Bob transferirem entre si, Eve eventualmente ganhará renda passiva ao converter seus tokens de volta para o t-space. Essa é a ideia básica do mecanismo do reflection token.
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 em Nível de Contrato
Agora vamos nos aprofundar no contrato REFLECT da Reflect Finance para explorar esse mecanismo.
Este contrato primeiro define várias variáveis para gerenciamento de contas:
mapping (address => uint256) private _rOwned; // reflected token held by user
mapping (address => uint256) private _tOwned; // true token held by user
mapping (address => mapping (address => uint256)) private _allowances;
mapping (address => bool) private _isExcluded; // if user is excluded from r-space
address[] private _excluded; // accounts that are excluded from r-space
Em seguida, ele 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 o 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 interação do usuário, as funções deste contrato se enquadram nas três categorias seguintes: consulta de saldo e transferência de token, além de uma função reflect distinta. As duas primeiras são compatíveis com o padrão ERC-20, no entanto, a lógica interna varia em relação a 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);
}
Isso pode ser expresso pela seguinte fórmula:

O rate na fórmula acima é calculado 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 token
Em geral, existem quatro cenários para transferir ativos, conforme 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 somados ou subtraídos do respectivo espaço. Para contas não excluídas, apenas _rOwned precisa ser considerado. Por exemplo, o trecho de código abaixo mostra a implementação da 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 amount, transferAmount e fee correspondentes (ou seja, amount = transferAmount + fee) 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 o rate. De acordo com a fórmula de cálculo balanceOf(user), 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 reflection token 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, o rate diminuirá à medida que rSupply diminui, proporcionando assim benefícios a outros detentores de tokens. Em outras palavras, sacrificar a si mesmo em prol dos outros. Ao fazer isso, os mantenedores do projeto podem incentivar os detentores de tokens por meio 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 adaptar sua implementação. Por exemplo, alguns podem usar taxas de transação para alimentar uma função de "swap and liquify" para evitar debandada quando baleias decidem vender seus tokens.
0x2 Post-Mortem dos Rekt Reflection Tokens
Como mencionado anteriormente, nosso foco principal está em ataques que exploram o mecanismo do reflection token. Portanto, incidentes não relacionados a esse mecanismo, como o ataque ao SafeMoon V2 (um problema comum de public burn em ERC20) e o recente ataque ZongZi (relacionado a uma exploração à moda antiga de manipulação de preço explorando o preço à vista), não são abordados.
Realizamos uma análise aprofundada desses ataques para desvendar suas causas raiz. Você pode consultar aqui para 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 um backdoor), aos quais nos referimos como incidentes de segurança anormais. Nas subseções a seguir, primeiro apresentaremos os incidentes de segurança normais e depois examinaremos 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 em nível de código e problemas em nível de operação, individualmente ou em combinação.
-
Problemas em nível de código. Isso surge de uma implementação ruim do contrato, provavelmente devido aos desenvolvedores não compreenderem totalmente o mecanismo do reflection token, levando a uma inconsistência entre a oferta real de tokens e o valor registrado de
totalSupply, que pode ser usada para manipular o rate:-
1.1 Burn de custo zero
-
1.2 Dedução extra de
rSupplydurante as transferências de token -
1.3 Confusão entre valores de r-space e t-space (com perda de precisão para obter lucros)
-
-
Problemas em nível de operação. Isso resulta de operação inadequada por parte dos administradores. Especificamente, nesses incidentes, isso se refere à configuração inadequada dos endereços de par AMM, que não são devidamente excluídos.
Vale ressaltar que TODOS os problemas de nível de código listados podem levar a inconsistências entre a oferta 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 explorá-las, já que pode haver um custo para o atacante manipular o rate. Para simplificar, a seguir usaremos o termo "explorável" para significar "lucrativo o suficiente para valer a pena explorar". Como resultado, um problema em nível de operação em alguns cenários é necessário para tornar essas vulnerabilidades exploráveis.
Especificamente, o problema 1.1 pode ser diretamente explorado, enquanto os problemas 1.2 e 1.3 exigem 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 em nível de código (1.1) | 1 (0,79%) |
| II (a) | BEVO, FETA, ADU | Combinação de ambos (1.2 e 2) | 3 (2,38%) |
| II (b) | SHEEP, e mais de 120 outros | Combinação de ambos (1.3 e 2) | 122 (96,83%) |
A tabela revela que os incidentes do Tipo-II representam uma proporção significativa. Especificamente, existem 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 semelhantes ao SHEEP da categoria Tipo-II-b, as explorações sugerem que os atacantes (por exemplo, este aqui) podem estar usando métodos automatizados para identificar contratos similarmente vulneráveis. Detalhes específicos serão explorados nas subseções seguintes.
0x2.1.1 Tipo-I: Apenas Problema em Nível de Código (Problema 1.1)
Apenas um incidente pertence ao Tipo-I, ou seja, o incidente CATOSHI, um burn de custo zero afetando a oferta 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, a quantidade queimada por essa função não é deduzida do chamador (ou seja, msg.sender). No entanto, _rOwned[msg.sender] deveria ser reduzido em rAmount, e se a conta for excluída, _tOwned[msg.sender] também deveria ser reduzido em tAmount.
Devido a esse descuido, os atacantes podem inicialmente queimar uma grande quantidade de tokens a custo zero e, em seguida, invocar a função reflect do contrato. Como tanto _tTotal quanto _rTotal foram significativamente reduzidos proporcionalmente:

O rate pode ser facilmente manipulado para baixo invocando a função reflect, fazendo com que balanceOf(attacker) 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(attacker) e balanceOf(attacker)' é:

Como

Portanto

Isso 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 as transferências de token. - Problema 2: O par AMM não é excluído.
No Tipo-II-a, há três incidentes de ataque, que podem ser subdivididos em duas subcategorias de acordo com as formas de vulnerabilidade no problema 1.2, conforme segue:
1. Reflect 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 apresentado em 'Funções para Transferência de Token' (seção 0x1.2.2), cada transferência de token aciona o reflect de uma parte da taxa de transação. No BEVO, as taxas são divididas em duas partes adicionais: burn e charity, 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); // rChairty is deducted from _rTotal
_tFeeTotal = _tFeeTotal.add(tFee);
_tBurnTotal = _tBurnTotal.add(tBurn);
_tCharityTotal = _tCharityTotal.add(tCharity);
_tTotal = _tTotal.sub(tBurn);
}
Observe que a conta charity é excluída, o que significa que o valor enviado para essa conta é queimado, como 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); // since charity account is excluded, the charity part is burned
emit Transfer(sender, currentCharity, tCharity);
}
A partir do trecho de código acima, podemos ver que existem dois lugares para refletir e queimar a parte da charity, fazendo com que a oferta real de tokens se torne inconsistente com totalSupply durante as transferências. À medida que mais tokens são transferidos, o valor de rSupply se tornará menor que a oferta 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 sejam deduzidos da seguinte forma: 1 para a taxa, 1 para o burn e 1 para a charity. Como a parte da charity é tanto refletida quanto queimada, a divisão real é 2 tokens refletidos (1 taxa + 1 charity) e 2 tokens queimados (1 burn + 1 charity). Junto com os 7 tokens restantes a serem transferidos para Bob, um total de 11 tokens está envolvido nesse processo, o que está incorreto.
Mas por que essa inconsistência pode ser explorada para obter lucros? Abaixo, agitaremos nossa varinha mágica matemática para derivar as consequências.
Suponha que tenhamos adquirido anteriormente alguns tokens do pool (ou seja, o 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 a oferta de tokens no pool:

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

Neste momento, se refletirmos os tokens que possuímos por meio da função reflect (que é renomeada como função deliver neste contrato), o rate se torna rate':

Como

Então temos

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

Isso significa que o número de tokens que podemos colher diretamente do pool (via função skim) é até maior do que o que entregamos, tornando-o lucrativo, pois 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 essa 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 is deducted from 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); // However, there is no rTeam deducted from 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, 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 token ocorrem.
Como o par também não é excluído no token, esse token é explorável. Especificamente, um atacante poderia usar explorações semelhantes às do BEVO para colher mais tokens ADU via função skim do par após chamar a função deliver.
No entanto, dado o status on-chain naquele momento, é impossível para o atacante trocar os tokens ADU colhidos por WBNB para obter lucro (devido à declaração require na função tokenFromReflection). Portanto, o atacante precisaria empregar uma estratégia de exploração mais complexa para lucrar, o que não será detalhado 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 de r-space e t-space (observando que a perda de precisão também deve estar presente para obter lucros).
- Problema 2: O par AMM não é excluído.
O problema 1.3 decorre do tratamento inadequado de valores entre r-space e 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 should be minus a r-space value
_tTotal = _tTotal.sub(_value); // _tTotal should be minus a t-space value
// For the semantics of the burn function, _rTotal should also be subtracted from a r-space value.
emit Transfer(_who, address(0), _value);
}
Considerando as constantes essenciais do contrato, o valor no r-space é tipicamente um múltiplo grande do valor no t-space. Portanto, invocar a função burn com um _value que seja da mesma ordem de magnitude que tSupply inflará significativamente o rate.
No entanto, diferentemente 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. Então, como os casos do Tipo-II-b poderiam ser exploráveis?
Tomando o incidente do token SHEEP como exemplo. O valor dos tokens SHEEP detidos pelo atacante pode ser denotado como:

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

Então o Value pode ser expresso ainda mais 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 caem devido à inflação de rate que mencionamos acima. Assim, a razão se ajusta para:

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

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

Na verdade, o atacante tirou vantagem de um problema de perda de precisão no reflection token. 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, o ratio' pode ser maior que o ratio se levarmos esse problema em consideração.
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);
}
Depurando 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, bem menor que 1.
Analisando o trace dentro da função sync 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 Profit se torna um valor positivo.
Finalmente, o atacante pode lucrar realizando um swap reverso.
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 administradores 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, por fim, levou a ataques. Especificamente, no projeto DBALL, identificamos uma série de transações suspeitas por parte do 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 muito às 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 a oferta real de tokens pode divergir de totalSupply, algumas atividades suspeitas vêm à tona.
0x2.2.1 O incidente FDP
A discrepância entre a oferta real de tokens e 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, em o contrato FDP, essa função não tem nada a ver com transferência de propriedade. Em vez disso, ela aumenta _rOwned[newOwner] sem alterar totalSupply. Isso claramente viola os princípios de design do processo normal de emissão 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
As coisas ficam complicadas no caso DBALL. Usando o MetaSleuth para analisar o fluxo de fundos do proprietário do DBALL, observamos um desequilíbrio na entrada e saída de tokens DBALL para esse endereço, com a origem dos fundos para esta transação não sendo registrada.
Ao consultar os estados históricos on-chain, finalmente identificamos que o saldo de 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 correto. No entanto, devido ao contrato especificar 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 na oferta de tokens.
Seria apenas um erro acidental? Nossa investigação sugere que é mais provável um rug pull intencional. As razões estão resumidas a seguir:
-
O proprietário passou apenas 1 token para a função
manualDevBurn, mas dentro de meia hora, uma quantidade de DBALL igual à oferta total foi transferida para um endereço associado por meio de esta transação. -
Esse endereço associado imediatamente realizou um swap no par PancakeSwap e obteve aproximadamente 56 WBNB.
- Analisando os fluxos de fundos desses dois endereços, revela-se que ambos eventualmente transferiram BNB por meio do Tornado.Cash.
0x3 Um Problema Potencial no Cálculo do rate
Além dos incidentes que discutimos anteriormente, também descobrimos que, teoricamente, existe um problema potencial no cálculo de saldo de usuários não excluídos que merece discussão adicional. Esse problema pode surgir durante o cálculo do rate.
Vamos dar uma olhada na função _getCurrentSupply. Nessa função, a declaração if final determina se rSupply é menor que o rate 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 declaraçã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 declaração if se perdeu.
Como rSupply diminuirá devido ao mecanismo do reflection token, o rate diminuirá proporcionalmente. Se, após uma determinada transação, rSupply cair abaixo do rate inicial, o rate atual 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 o rate se torne zero devido à perda de precisão, potencialmente acionando um pânico de divisão por zero.
0x4 Mitigação e Soluções
O mecanismo do reflection token oferece uma forma de aumentar a estabilidade do mercado ao incentivar os investidores a manter seus tokens em vez de negociá-los para receber recompensas adicionais. No entanto, também introduz novos desafios de segurança e riscos potenciais, como a confusão entre valores de r-space e t-space. Portanto, é crucial que desenvolvedores blockchain e investidores obtenham uma melhor compreensão do mecanismo e de seus riscos potenciais e busquem soluções.
A BlockSec fornece serviços e produtos de segurança tanto para os estágios pré-lançamento quanto pós-lançamento. Nossos serviços de auditoria de segurança realizam revisões minuciosas para garantir a segurança e transparência do código. Nosso produto Phalcon oferece capacidades contínuas de monitoramento de segurança e 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.
Leituras Relacionadas
- Como as Blockchains L2 Podem Fazer Melhor para Proteger Seus Usuários
- Top 10 Incidentes de Segurança "Incríveis" em 2023
- Análise Conceitual Completa da Ascensão do Bitcoin com Inscription
Sobre a BlockSec
A BlockSec é uma provedora 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 smart contracts e blockchains EVM, a plataforma Phalcon para desenvolvimento seguro 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 pelo mundo cripto.
Até o momento, a empresa já atendeu mais de 1.000 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 de destaque, 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



