TL;DR EIP-4626 cofres tokenizados são suscetíveis a ataques de inflação se a taxa de câmbio dos ativos depositados e das ações ERC20 cunhadas não for tratada graciosamente. Neste artigo, nós dissecamos o ataque e discutimos as soluções
Esboço
0. Introdução
1. Dissecando o ataque de inflação
2. Soluções
3. Sumário
0.Introdução
A emissão de um token ERC20 para representar as ações subjacentes dos fundos depositados pelos usuários em um cofre tem sido uma prática comum no espaço DeFi, e EIP-4626 é a proposta de padronização de cofres deste gênero.
Entretanto, há uma discussão em torno do risco da proposta: clique . Em resumo, um invasor com fundos suficientes pode fazer um ataque sandwich aos depositantes por meio de um :
- o invasor deposita no cofre para cunhar ações
- o invasor também transfere os tokens ERC20 diretamente para o cofre
- os depósitos dos depositantes espiões
desde que o total de ativos do invasor ultrapasse o valor do depósito dos depositantes, os depositantes receberão 0 ações para a taxa de câmbio entre os ativos depositados e a representação ERC20 do cofre é enganada.
Como os depositantes recebem 0 ações, o invasor pode retirar todos os ativos do cofre, pois o invasor é o único com ações desde o primeiro passo.
Na minha opnião, é brilhante inventar um ataque original, prestando atenção às nuances do desenho inteligente do contrato e um grande grito ao autor do post!
Assim, este artigo não pretende tirar o crédito do autor, mas sim servir como outra fonte de compreensão do ataque
Vamos mergulhar no ataque com a seleção do editor de melodic death metal: Eluveitie - Inis Mona
1. Dissecação do Ataque de Inflação
Os passos do ataque já estão demonstrados acima, mas vamos fazer a abordagem matemática aqui para dissecar o ataque.
Em primeiro lugar, dê uma olhada no cálculo entre os bens depositados e a representação ERC20 do cofre:
// modified from https://ethereum-magicians.org/t/address-eip-4626-inflation-attacks-with-virtual-shares-and-assets/12677
function _convertToShares(uint256 assets) returns (uint256 shares) {
uint256 totalSupply = totalSupply();
return supply == 0
? assets.mulDiv(10**decimals(), 10**_asset.decimals())
: assets.mulDiv(totalSupply, totalAssets());
}
- quando é o primeiro depósito, o
totalSupplyl()
de ERC20 do cofre é0
e nós simplesmente escalamos para cima ou para baixo o número de ativos até casas decimais de ERC20 do cofre commulDiv()
- caso contrário, dividindo primeiro o valor dos
ativos
de entradaastotalAssets()
, conhecemos a porção de ativos em comparação com o total de ativos existentes no cofre; depois, multiplicandoativos/totalAssets()
pelototalSupply
, obtemos uma quantidade justa de ações com base na porção de ativos de entrada na função - usar mulDiv() é garantir que não haja perda de precisão multiplicando primeiro e depois dividindo
Em seguida, precisaremos assumir alguns números de modo que o ataque possa funcionar: digamos que os bens depositados no cofre são WETH, cujas casas decimais são 18. O ERC20 como as ações do cofre também tem 18 decimais, por exemplo, um ETH-Vault cujas casas decimais são 18.
Primeiro depósito
Agora, há um primeiro depositante que quer depositar 1 (1 * 1e18 wei)
WETH e o tx é espiado pelo invasor. Aqui está a interrupção:
| totalSupply() | totalAssets()
---------------------------------------------------------
original state | 0 | 0
---------------------------------------------------------
(depois) Passo 1 | 1 | 1
---------------------------------------------------------
(depois) Passo 2 | 1 | 1e18 + 1
---------------------------------------------------------
(depois) Passo 3 | 1 | 2 * 1e18 + 1
- O invasor faz o front-runner do depósito e deposita
1 wei
WETH e recebe 1 ação: já que ototalSupply
é 0, ações =1 * 10***18 / 10***18 = 1
- O invasor também transfere
1 * 1e18 wei
WETH, fazendo com que o saldo detotalAssets()
do WETH no cofre se torne1e18 + 1 wei
- os depósitos
1e18 wei
WETH do depositante espião. Entretanto, o depositante recebe 0 ações:1e18 * 1 (totalSupply) / (1e18 + 1) = 1e18 / (1e18 + 1) = 0
. Como o depositante recebe0
ações,totalSupply()
permanece em1
- o invasor ainda tem
1
ação já cunhada e assim a retirada dessa1
ação retira tudo no cofre, incluindo o1e18 wei
WETH do depositante
É claro que a razão pela qual 1e1 / (1e18 + 1)
é arredondado para 0 é que a divisão é arredondada, mas também vale a pena notar que as divisões em Solidity são sempre arredondadas para zero, não arredondadas para baixo.
Depósito Aleatório
O exemplo anterior demonstra o cenário de um invasor sandwich que ataca o primeiro depositante. O ataque, entretanto, pode ocorrer sempre que os recursos do invasor são abundantes:
| totalSupply() | totalAssets()
--------------------------------------------------------------
estado original | 1e17 | 1e17
--------------------------------------------------------------
(depois) Passo 1 | 1e17 + 1 | 1e17 + 1
--------------------------------------------------------------
(depois) Passo 2 | 1e17 + 1 | 1e36 + 1e17 + 1
--------------------------------------------------------------
(depois) Passo 3 | 1e17 + 1 | 1e36 + 1e18 + 1e17 + 1
- originalmente, havia
.1
WETH (1e17 wei
) no cofre e1e17 wei
ações de ERC20 foram cunhadas - o invasor vê um depositante querendo depositar
1 (1 * 1e18 wei)
WETH para o cofre - assim, o invasor novamente cunha
1 wei
ERC20 com1 wei
WETH; no entanto, desta vez o invasor tem que enviar um1e36 wei
WETH extra para o cofre - embora depositando
1 (1 * 1e18 wei)
WETH, o depositante ainda acaba com0
ações neste cenário:ações = 1e18 * (1e17 + 1) / (1e36 + 1e17 + 1) = (1e35 + 1e18) / (1e36 + 1e17 + 1) = 0
Realisticamente, é improvável que um invasor queira arriscar fundos de 10¹⁸ mais vezes do que o lucro potencial.
Com este exemplo, sabemos que mesmo que um cofre seja sempre suscetível a um ataque, a probabilidade de ele diminuir devido ao acúmulo de fundos no cofre.
Taxa de Câmbio
Como já caminhamos pelos passos de diferentes cenários de ataque, vamos introduzir o último termo no ataque: taxa de câmbio.
A taxa de câmbio entre ações e ativos depositados descreve quantas unidades de ações se pode obter depositando determinadas quantidades de ativos.
| totalSupply() | totalAssets()
---------------------------------------------------------
(depois) Passo 1 | 1 | 1
---------------------------------------------------------
(depois) Passo 2 | 1 | 1e18 + 1
---------------------------------------------------------
(depois) Passo 3 | 1 | 2 * 1e18 + 1
Em nosso primeiro cenário, as taxas de câmbio em cada etapa são
- Passo 1: 1 ação por ativo (WETH)
- Passo 2: 1 ação por
1e18 + 1
WETH, o que significa que a taxa de câmbio inflaciona com a transferência extra do invasor - Passo 3: como a taxa de câmbio é 1 ação por
1e18 + 1
WETH no Passo 2, o depositante que deposita1e18 wei
WETH é ainda menos de 1 ação e, portanto, o depositante recebe0
ações. E a taxa de câmbio inflaciona novamente para 1 ação por2 * 1e18 + 1
WETH
Com estas explicações, podemos ver porque o ataque é chamado de Ataque de Inflação: inflar a taxa de câmbio entre ações e ativos.]
Um resumo rápido
Antes de entrar na próxima seção, vamos resumir brevemente a causa raiz do ataque: a taxa de câmbio é manipulada por transferências extras que não passam pelo deposit()
regular do cofre.
Se olharmos novamente para a função _convertToShares()
, podemos traduzir a manipulação da taxa de câmbio como o dividendo, que é o depósito do depositante, é feito menor do que o divisor manipulado pelo invasor.
// modificado de https://ethereum-magicians.org/t/address-eip-4626-inflation-attacks-with-virtual-shares-and-assets/12677
function _convertToShares(uint256 assets) returns (uint256 shares) {
uint256 totalSupply = totalSupply();
return supply == 0
? assets.mulDiv(10**decimals(), 10**_asset.decimals())
: assets.mulDiv(totalSupply, totalAssets());
}
Portanto, é possível simplesmente tornar o dividendo maior do que o divisor?
2. Soluções
No post original, duas soluções parecem elegíveis e práticas: adicionar casas decimais extras e manter a contabilidade interna.
Manter um balanço interno é bastante simples: as transferências diretas do ERC20 para o cofre não serão contadas como parte do totalAssets()
e podem parar radicalmente o ataque.
Esta abordagem, entretanto, levanta várias questões do autor, tais como "O que fazer com os fundos extras"? "Os cofres devem ser capazes de receber doações", etc.
Assim, vou me aprofundar apenas sobre a abordagem de adição de decimais extras.
Decimais extras
Esta solução já é sugerida na questão da última seção:
é possível simplesmente tornar o dividendo maior do que o divisor?
E a resposta é Sim! Ao** dar casas decimais extras às ações ERC20 para torná-las maiores do que as dos ativos depositados**, podemos intencionalmente tornar o dividendo maior do que o divisor.
A razão pela qual as casas decimais não foram levantadas antes é que assumimos que as casas decimais das ações fossem 18
, o mesmo que WETH. Se observarmos a fórmula assets.mulDiv(10***decimals(), 10**_asset.decimals())
, podemos ver que quando os decimals()
e _asset.decimals()
são os mesmos, eles se cancelam um ao outro.
No entanto, se dizemos que as casas decimais das ações são agora 36 (o que está somando 18
casas decimais extras às 18
originais):
assets.mulDiv(10**36, 10**18) = assets.mul(10**18)
as ações de retorno são então aumentadas em 10**18 e vamos ver como isso pode afetar o ataque no primeiro depósito.
| totalSupply() | totalAssets()
---------------------------------------------------------
(depois) Passo 1 | 1e18 | 1
---------------------------------------------------------
(depois) Passo 2 | 1e18 | 1e18 + 1
---------------------------------------------------------
(depois) Passo 3 | ~2e18 | 2 * 1e18 + 1
- O invasor deposita
1 wei
WETH e recebe1e18
ações:1 * 10**36 / 10**18 = 10**18 = 1e18
. Podemos ver que a taxa de câmbio desta vez não é mais1
ação por WETH, mas1e18
ações por WETH - O invasor transfere
1e18 wei
WETH etotalAssets()
ainda se torna(1e18 + 1) wei
. A taxa de câmbio é agora 1 ação por(1e18 + 1) / 1e18
WETH - O depositário espião deposita
1e18 wei
WETH. Desta vez, o depositante recebe ações**1e18 wei
: `1e18 * 1e18 / (1e18 + 1) = **1e36 / (1e18 + 1) = 999.999.999.999.999.999.999 wei`; vamos arredondar para 1e18 para facilitar a demonstração - Como o depositante não recebe mais
0
, mas ações1e18
em vez disso, o invasor que resgatar ações1e18
não resultará na retirada de todos os ativos no cofre
A razão pela qual o depositante recebe ~1e18
ações desta vez no Passo 3 é porque, com as casas decimais extras, a taxa de câmbio se torna mais precisa: ao invés da quantidade original de 1 ação por 1e18 + 1
WETH que arredonda para baixo qualquer quantidade inferior a **1e18 + 1**
, 1 ação por (1e18 + 1) / 1e18
WETH significa que apenas a quantidade WETH inferior a **(1e18 + 1) / 1e18 ~ 1**
será arredondada para 0.
Podemos ver que simplesmente aumentando as casas decimais do token das ações, o ataque é invalidado, de forma efetiva - o ataque ainda pode acontecer enquanto o custo aumenta.
Neste caso, o atacante só pode executar o ataque transferindo 1e36 wei
WETH para o cofre de modo que as ações do depósito 1e18 wei
WETH do depositante tornam-se 0 novamente: ações = 1e18 * 1e18 / (1e37 + 1e18) = 1e36 / (1e36 + 1e18) = 0
| totalSupply() | totalAssets()
---------------------------------------------------------
(depois) Passo 1 | 1e18 | 1
---------------------------------------------------------
(depois) Passo 2 | 1e18 | 1e36 + 1e18
---------------------------------------------------------
(depois) Passo 3 | 1e18 | 1e36 + 2 * 1e18
Quanto ao cenário de depósito aleatório, agora é necessário um invasor **1e54**
para realizar o ataque: ações = 1e18 * (1e35 + 1e18) / (1e54 + 1e17 + 1) = (1e53 + 1e36) / (1e54 + 1e17 + 1) = 0
.
E a conclusão ainda é: possível, mas improvável.
| totalSupply() | totalAssets()
--------------------------------------------------------------
estado original | 1e35 | 1e17
--------------------------------------------------------------
(depois) Passo 1 | 1e35 + 1e18 | 1e17 + 1
--------------------------------------------------------------
(depois) Passo 2 | 1e35 + 1e18 | 1e54 + 1e17 + 1
--------------------------------------------------------------
(depois) Passo 3 | 1e35 + 1e18 | 1e54 + 1e18 + 1e17 + 1
Voilá! É tudo o que temos que fazer para esta solução. E quanto aos seus contras?
Contras
Primeiro, os decimais extras adicionados para a prevenção do ataque podem ser menos intuitivos quando os usuários inspecionam os estados contratuais no Etherscan.
Em segundo lugar, haverá algum trabalho extra para os engenheiros do front-end para lidar com os decimais extras.
Mas estes dois são os únicos em que posso pensar, e também não são nada sério em comparação com o benefício.
Comentário Adicional
Alguns podem se perguntar se é possível reverter de alguma forma direta as transferências de tokens ERC20 para o cofre.
A resposta é NÃO porque o ERC20 não tem uma função tokensReceived()
como ERC-777: a função thetokensReceived()
implementada pelo chamador de uma transferência será sempre evocada e assim podemos inserir nossa lógica desejada (reversão) em tokensReceived()
se o bem depositado no cofre for um token ERC-777.
Além disso, se estamos falando de Éter ao invés de WETH como os bens depositados no cofre, então podemos utilizar a função de receive()
especializada para Ether e optar por sempre reverter as transferências nele.
3. Sumário
A causa principal do ataque é que a taxa de câmbio é manipulada para ser mais cara do que o valor do depósito do depositante pode pagar.
Embora o ataque aconteça mais facilmente no primeiro depósito, ainda é teoricamente possível, mas gradualmente se torna impraticável à medida que o cofre acumula fundos suficientes para lutar contra ele.
Ao aumentar as casas decimais da representação do ERC20 das ações do cofre, o custo do ataque aumenta de acordo, e assim podemos prevenir o ataque efetivamente com o mínimo de efeitos colaterais!
Por último, deixe qualquer comentário abaixo se você quiser discutir ou encontrar algum erro! Até o próximo 😊
Artigo escrito por Shao . A versão original pode ser encontrada aqui. Traduzido e adaptado por Dimitris Calixto.
Latest comments (0)