WEB3DEV

Cover image for SharkTeam: As 10 principais ameaças para a segurança de contrato inteligente por vulnerabilidade de atualização de contrato
Isabela Curado Nehme
Isabela Curado Nehme

Posted on

SharkTeam: As 10 principais ameaças para a segurança de contrato inteligente por vulnerabilidade de atualização de contrato

16 de setembro de 2022

Q: As vulnerabilidades de contrato inteligente que mencionamos frequentemente são realmente as violações de segurança mais ameaçadoras e frequentes na prática?

A: De jeito nenhum. Por exemplo, “overflow”, “chamada externa” (external call) e outras vulnerabilidades de segurança de contrato inteligente comumente mencionadas não são as mais frequentes e mais ameaçadoras.

Quais são as 10 principais ameaças à segurança em termos de frequência de ocorrência e dano? Lição 5 [Vulnerabilidade de atualização de contrato].

1. O que é atualização de contrato?

Os contratos inteligentes são por padrão imutáveis depois de implantados e, uma vez criados, não há como alterá-los. Cada contrato inteligente tem um endereço exclusivo para o qual usuários enviam transações para executar o código armazenado no contrato. No entanto, em aplicações práticas, os desenvolvedores geralmente precisam atualizar a lógica do contrato através de recursos técnicos devido às necessidades de atualização de negócios ou atualizações de segurança, para que o modo de desenvolvimento de atualizações de contrato passem a existir. Alguns modos comuns de atualização de contrato são os seguintes.

1.1. Modo de armazenamento herdado (Inherited storage mode)

O modo de armazenamento herdado (Inherited storage mode) resolve o problema de colisão de estado (State Collision Problem) fornecendo uma ordem de armazenamento estrita para as variáveis de estado exigidas pelo contrato Proxy e pelo contrato Logic, e o contrato Proxy delega o contrato Logic para chamar. Portanto, apenas o armazenamento do contrato Proxy está em uso. Um contrato Proxy que herda o contrato de armazenamento público tem acesso a todas as variáveis de estado de seus contratos pais (ou contrato de origem), cada variável de estado ocupando uma posição de memória apropriada de acordo com seu índice.

O processo de inicialização é o seguinte:

(1) Implante o contrato de registo (Registry contract).

(2) Implante a versão inicial (v1) do contrato, certificando-se que herde o contrato “atualizável”.

(3) Registre o endereço da versão inicial ao registro (Registry).

(4) Solicite que o contrato de registro crie uma instância de capacidade de atualização de Proxy (UpgradeabilityProxy).

(5) Chame UpgradeabilityProxy para atualizar a versão inicial do contrato.

O processo de atualização é o seguinte:

(1) Implante uma nova versão do contrato (v2) herdado da versão inicial para garantir que ele preserve a estrutura de armazenamento do corretor e a estrutura de armazenamento na versão inicial do contrato.

(2) Registre a nova versão do contrato no registro (Registry).

(3) Chame a instância UpgradeabilityProxy para atualizar para a nova versão registrada.

Embora o padrão de armazenamento herdado resolva o problema de colisão de armazenamento (Storage Collision Problem) para contratos atualizáveis, essa abordagem também tem suas próprias desvantagens. Atualizações se tornam caras na medida que todas as variáveis de estado declaradas anteriormente precisam ser copiadas para a versão implantada mais nova. Algumas delas podem não ser usadas e acabar ocupando memória de forma desnecessária. Devido ao padrão de armazenamento comum, o contrato Logic se torna fortemente acoplado ao contrato Proxy. Portanto, não é possível usar esses contratos Logic para nenhum outro Proxy que não herde o contrato de armazenamento público.

1.2. Modo de armazenamento desestruturado (Unstructured Storage Mode)

Neste modo, o contrato Proxy é geralmente um contrato mínimo com algumas funções básicas, a maior parte da lógica de negócios é completa no contrato Logic, e o novo contrato Logic mantém a última ordem de variáveis de estado existentes no contrato de implementação. Os contratos Proxy, de forma geral, exigem que o proprietário e as variáveis de implementação mantenham a propriedade e controle da versão. Os contratos Proxy especificam essas propriedades como constantes, que são definidas dentro do código (bytecode) do contrato. O OpenZeppelin usa um modelo de armazenamento desestruturado no seu serviço de contrato escalável.

O processo de inicialização é o seguinte:

(1) Implante a instância OwnedUpgradeabilityProxy.

(2) Implante a versão inicial do contrato (v1).

(3) Chame a instância OwnedUpgradeabilityProxy para atualizar o endereço da versão inicial.

(4) Se o contrato Logic depender do seu constructor (função construtora) para definir algum estado inicial, deve ser definido novamente após ser conectado com o Proxy porque o armazenamento no Proxy não conhece esses valores. A OwnedUpgradeabilityProxy tem a função de upgradeToAndCall (atualizar e chamar), que é usado especialmente para chamar algumas funções no contrato Logic e reiniciar as configurações depois do Proxy estar atualizado para isso.

O processo de atualização é o seguinte:

(1) Implante a nova versão do contrato (v2), certificando-se de que ele herda a estrutura de variável de estado usada na versão anterior.

(2) Chame a instância OwnedUpgradeabilityProxy para atualizar para o endereço da versão do novo contrato.

1.3. Modo de armazenamento permanente (Permanent Storage Mode)

O objetivo desse padrão é minimizar os requisitos de replicação de armazenamento. Neste modelo, um único contrato é mantido como um contrato de armazenamento permanente, portanto, todas as versões lógicas utilizam esse contrato de armazenamento permanente para sua necessidade de armazenamento. Assim, o requisito para replicação de armazenamento é removido no modelo de atualização, que reduz bastante os custos de atualização.

O processo de inicialização é o seguinte:

(1) Implante a instância EternalStorageProxy.

(2) Implante uma versão inicial do contrato (v1).

(3) Chame a instância EternalStorageProxy para atualizar para o endereço da versão inicial.

(4) Se a lógica do contrato depender de seu constructor (função construtora) para definir algum estado inicial, ele deve fazer tudo de novo depois de ser vinculado ao Proxy, porque o armazenamento do Proxy não conhece esses valores. A EternalStorageProxy tem uma função upgradeToAndCall, que é especialmente usada para usar algumas funções do contrato Logic e reiniciar as configurações depois do Proxy estar atualizado para isso.

O processo de atualização é o seguinte:

(1) Implante uma nova versão do contrato (v2), certificando-se de que ele tenha uma estrutura de armazenamento permanente.

(2) Chame a instância EternalStorageProxy para atualizar para a nova versão.

1.4. Modo diamante (Diamond Mode)

Contratos em Solidity tem um limite máximo de 24kB. Portanto, o tamanho de cada contrato não pode nunca exceder 24kB. O modo diamante (Diamond Mode) permite aos desenvolvedores escrever contratos inteligentes sem limite de tamanho. Esse modo também suporta atualizações sem reimplantar a funcionalidade existente, como detalhado no padrão Diamond EIP 2535.

O Diamond é um contrato que delega chamadas para implementar contratos chamados “faces”. O contrato Diamond implementa uma função diamondCut, que fornece capacidades de adicionar/substituir/remover. Além da função diamondCut, um conjunto de funções de lupa é implementado. Essas funções exibem informações sobre o Diamond implementado. O contrato Diamond mantém um mapeamento selectorToFacet, que é basicamente um mapeamento de uma assinatura de função para o endereço do contrato de implementação. Quando o usuário chama uma função, a função fallback do Proxy do Diamond usa a assinatura de função para encontrar o endereço de implementação neste mapa. Depois de encontrar o endereço da assinatura de função, a função fallback simplesmente delega a chamada para o contrato de implementação e retorna a resposta ao usuário.

O agente do Diamond depende do modelo de armazenamento. O Proxy é compartilhado por alguns contratos de implementação chamados “faces”. Idealmente, cada aspecto tem seu próprio armazenamento. No entanto, dependendo dos requisitos de implementação, os “faces” podem compartilhar armazenamento com outros “faces”. DiamondStorage e AppStorage são alguns modelos de armazenamento populares para armazenamento isolado e compartilhado, respectivamente.

O padrão Diamond fornece um jeito elegante de criar aplicativos de contrato inteligente modulares sem nenhuma restrição de tamanho máximo. Como tal, parece ser a escolha perfeita para os grandes aplicativos de contrato inteligente que procuram aumentar no futuro.

1.5. create2

O opcode create é usado para implantar um contrato na blockchain Ethereum. O endereço do contrato é gerado através de um hash do endereço do implantador e o nonce desse endereço.O nonce é um valor escalar igual ao número de transações enviadas do endereço do implantador. Da mesma forma, no caso de uma conta de contrato, o nonce é o número de contratos criados por aquela conta. Os nonces ajudam a manter as transações em ordem (transações de baixo nonce de um endereço são mineradas primeiro) e evitam ataques de reentrância. O nonce é um número incremental que impede o opcode create de gerar endereços duplicados. Desde que nenhuma nova transação ocorra entre o nonce atual e o próximo, é possível saber o endereço do próximo contrato simplesmente fazendo o hash do próximo nonce e do endereço.

O opcode create2 foi adicionado na máquina virtual da Ethereum (Ethereum Virtual Machine) como parte do fork rígido Constantinople, e esse opcode é também usado para implantar contratos inteligentes. O create2 usa algumas entradas controladas pelo usuário para deduzir o endereço do contrato inteligente. Em outras palavras, o opcode fornece um jeito de calcular o endereço de um contrato inteligente antes de implantá-lo para a blockchain. Ao invés de fazer um hash do endereço e o nonce do implantador, esse opcode faz o hash do endereço do implantador, o salt (uma string de 32 bytes fornecida pelo implantador) e o hash do bytecode do contrato. Como todos os parâmetros desse opcode são controlados pelo usuário, o create2 fornece uma maneira de predeterminar o endereço do contrato. Esse método é útil ao deduzir endereços de contrato para otimização de gas e implementar soluções de dimensionamento como state channels (canais de estado). Em termos de capacidade de atualização, o create2 proporciona a habilidade de criar contratos metamórficos que podem ser implementados para o mesmo endereço com o novo bytecode.

A EVM (Máquina virtual da Ethereum) contém um opcode autodestrutivo através do qual os contratos inteligentes podem ser excluídos. A fim de implantar um novo bytecode no endereço original, o endereço deve estar livre porque os contratos inteligentes são imutáveis. Existem várias maneiras de implantar o novo bytecode ao endereço original. Uma forma ineficiente é encontrar os parâmetros do salt, combiná-los com o novo bytecode para gerar o endereço original, o cálculo para encontrar os parâmetros corretos do salt pode ser encontrado na off-chain concluída. No entanto, essa não é uma boa abordagem. Outra opção é implantar um contrato mutável. Como mencionado acima, os contratos mutáveis podem ser reimplantados para o endereço original usando um bytecode diferente.

A construção de um bom padrão de atualização exige uma fábrica de contrato mutável. O objetivo dessa fábrica de contrato mutável é facilitar as atualizações mudando sua implementação sem alterar seu endereço. Ao implantar um contrato, a função de implantação correspondente usa create2 para pré-calcular o endereço do contrato variável e o contrato de implementação é implantado usando o opcode create tradicional. Esse opcode usa o endereço e o nonce para gerar o endereço e implantar o contrato nesse endereço. É necessário que o endereço de implementação não seja zero. Caso contrário, o contrato de implementação não é implantado corretamente e a função deve retornar. Depois que o contrato de implementação for implantado, o estado da fábrica é atualizado para armazenar o contrato de implementação atual.

Vale a pena notar que a implementação de contratos deve ser autodestrutiva, o que também torna os contratos mutáveis autodestrutivos. Antes de reimplantar um contrato mutável, certifique-se de que o contrato mutável se destrói usando o opcode selfdestruct. Devido ao opcode create2, o endereço de um contrato mutável é sempre conhecido antes do tempo. Também, devido a sua habilidade de alterar sua implementação, um contrato mutável pode ser reimplantado com uma implementação diferente a cada momento.

Existem vantagens em usar o create2, mas isso vem com seus próprios riscos. O risco mais importante é que, cada vez que o contrato for reimplantado, seu armazenamento será apagado. Além disso, a implementação de contratos com opcodes de autodestruição pode não ser uma maneira confiável de armazenar fundos. Portanto, os desenvolvedores devem ter cautela antes de adotar esse modelo de atualização de contrato inteligente.

2. Análise de eventos de ataque

2.1. Uranium Finance (Financiamento de urânio)

A Uranium Finance, um projeto de blockchain da Binance Smart Chain, sofreu um ataque durante uma migração de liquidez em 28 de abril de 2021, envolvendo $50 milhões em fundos. O endereço do contrato de ataque: 0x2b528a28451e9853F51616f3B0f6D82Af8bEA6Ae.

Descobrimos o endereço de contrato do projeto no contrato de ataque e analisamos a origem do ataque. O processo do ataque é o seguinte:

(1) Primeiro, verifique o código do contrato de ataque e descubra que o código-fonte deste contrato não é público e verifique seu código-fonte descompilando-o.

(2) Visualize a primeira transação de ataque através por meio do navegador 0x5a504fe72ef7fc76dfeb4d979e533af4e23fe37e90b5516186d5787893c37991, a assinatura correspondente ao método de contrato chamado pelo invasor é 52f18fc3.

(3) Procurando o método de contrato codificado no código descompilado, você pode encontrar o endereço do contrato da parte do projeto atacada por esse contrato, que é o endereço do projeto Uranium: 0xa943ea143cd7e79806d670f4a7cf08f8922a454f

(4) Através da análise, a brecha no contrato do projeto Uranium aparece na função swap do contrato UraniumPair.sol. Essa brecha permitirá que qualquer pessoa transfira os ativos digitais do contrato à vontade e precise pagar apenas um pequeno preço. Pode-se ver que na troca (swap) há uma comparação entre a 8ª potência de 10 e a 6ª potência de 10. Este é um julgamento quase idêntico, o que significa que, enquanto a função swap for executada continuamente de acordo com uma determinada rotina, você pode limpar todos os ativos digitais neste contrato.

Vemos que a escrita no contrato UniswapV2Pair.sol é a mesma, mas é uma comparação de dois números de 10 a 6.

Análise do incidente: o motivo deste incidente deve ser que, quando a parte do projeto atualizou e aprimorou o contrato, eles se esqueceram de alterar a segunda potência de 1000 para a potência de 10000.

2.2. Audius (Áudio)

Em 24 de julho de 2022, hackers transferiram 18 milhões de tokens AUDIO do protocolo de transmissão de música Audius. O invasor lançou dois ataques, o primeiro falhou e o segundo teve sucesso. Analisamos principalmente o segundo ataque. Depois de o invasor ter criado um contrato de ataque, ele lançou um ataque através do contrato de ataque incluindo 4 transações:

txHash1: 0xfefd829e246002a8fd061eede7501bccb6e244a9aacea0ebceaecef5d877a984
txHash2: 0x3c09c6306b67737227edc24c663462d870e7c2bf39e9ab66877a980c900dd5d5
txHash3: 0x4227bca8ed4b8915c7eec0e14ad3748a88c4371d4176e716e8007249b9980dc9
txHash4: 0x82fc23992c7433fffad0e28a1b8d11211dc4377de83e88088d79f24f4a3f28b3

Em txHash1, o processo de transação principal é o seguinte:

(1) Inicialize o contrato de governança (Governance contract) através do contrato Proxy do Audius.

(2) Avalie/aplique a Proposição 84, a proposta apresentada pelo primeiro ataque, avaliada como QuorumNotMet devido à falta de votos.

(3) Consulte o saldo do token AUDIO do contrato Proxy de governança (Governance).

(4) Envie a Proposição 85, que é a mesma da Proposição 84. A função dessa proposta é transferir o AUDIO para o contrato Proxy de governança (Governance) para o contrato de ataque e o valor não pode exceder o saldo do AUDIO do contrato Proxy de governança (Governance).

(5) Inicialize o contrato Staking através do contrato Proxy e defina o endereço de token de governança e o endereço de contrato Proxy como o contrato de ataque.

(6) Inicialize o contrato DelegateManagerV2 através do contrato Proxy e configure ambos o endereço do token de governança e o endereço de governança Proxy como no contrato de ataque.

(7) Use o contrato Proxy para fazer do contrato da fábrica provedora de serviços no contrato DelegateManagerV2 um contrato de ataque.

(8) Os direitos do Proxy prometidos são autorizados para o contrato de ataque por meio do contrato Proxy e o número de tokens autorizados é 1e31.

Através das etapas acima, o invasor adulterou e obteve a maior autoridade do sistema de governança.

Em txHash2, o invasor votou dentro de 3 bloco após enviar a proposta 85.

Em txHash3, o invasor avaliou/executou a proposta 85, depois de um período de votação de 3 blocos e período de espera de 1 bloco.

Em txHash4, a Proposta 85 foi implementada para transferir 18,56 milhões de AUDIO para o contrato de ataque. Depois que o contrato de ataque recebeu os 18 milhões de AUDIO, foi trocado por 704 ETH.

Por fim, o invasor depositou o ETH trocado na plataforma Tornash.

Análise do problema: a razão fundamental pela qual o invasor pode atacar com sucesso é que a função de inicialização é chamada várias vezes por meio do contrato Proxy e a função de inicialização deve ser chamada apenas uma vez. Tomemos como exemplo a função de inicialização no contrato de governança (Governance), o código é o seguinte:

O inicializador no OpenZeppelin é usado aqui e o inicializador não faz nenhum papel por conta da chamada do Proxy.

As duas variáveis de estado do tipo bool initialized (inicializada) e initializing (de inicialização) definidas no initializer (função inicializadora) no Openzeppelin ocupam os primeiros 16 bytes no slot de armazenamento slot0, respectivamente. Os primeiros 8 bytes são initialized e os segundos 8 bytes são initializing.

Como o próprio contrato Proxy define uma variável de estado proxyAdmin de um tipo de endereço, seu valor é 0x80ab62886eacfebca74511823d4699eb88fd097e, que também ocupa o slot de armazenamento slot0. Os primeiros 8 bytes são 0, os segundos 8 bytes são 0x80ab6288, a terceira Seção de 8 palavras é 0x6eacfebca7451182 e os quartos 8 bytes são 0x3d4699eb88fd097e. Portanto, a initialized e a initializing no contrato de implementação e o proxyAdmin no contrato Proxy ocupam o slot de armazenamento slot0 ao mesmo tempo, assim causando um conflito de armazenamento.

A distribuição de dados no slot0 é a seguinte:

Quando a função de inicialização é executada para initializer (ou função inicializadora), a initialized é lida dos primeiros 8 bytes do slot de armazenamento slot0 e seu valor é zero, ou seja, falso; a initializing é lida dos segundos 8 bytes do slot de armazenamento slot0 e seu valor é 0x80ab6288 > 0, que é verdadeiro.

3. Medidas preventivas

Nós compreendemos vários modos de atualizações de contrato e após a revisão de ataques relacionados, quais medidas apropriadas deveriam ser tomadas como desenvolvedores para evitar os ataques relacionados?

(1) O problema real com contratos inteligentes escaláveis são os valores armazenados de migração dos contratos. Um jeito melhor de construir contratos inteligentes escaláveis é a distinção entre armazenamento e lógica em diferentes contratos e manter os dados do contrato em um único contêiner, que apenas aceite chamadas de contratos de lógica. No contrato, a lógica do contrato de lógica é constantemente alterada.

(2) Verifique se o contrato de destino existe antes de chamar a função delegatecall, a Solidity não realizará essa verificação para nós. Ignorar a verificação pode levar a comportamentos inesperados e problemas de segurança.

(3) Considere cuidadosamente a ordem de declaração das variáveis porque haverão problemas como variáveis de condicionamento (packaging) e armazenamento no mesmo slot, afetando o custo de gas, o layout de memória e os resultados da chamada delegada (delegate call).

(4) Considere cuidadosamente o problema de inicialização do contrato. As variáveis de estado podem não ser inicializadas durante a construção e as possíveis condições de corrida devem ser mitigadas durante a inicialização.

(5) Considere nomes de funções no modo Proxy para evitar conflitos de nome da função.

(6) Antes do projeto ser lançado, é necessário entrar em contato com uma equipe de auditoria profissional terceirizada para realizar uma auditoria.

Sobre nós

Nossa visão é melhorar a segurança globalmente. Acreditamos que, ao construir essa barreira de segurança, podemos melhorar significativamente vidas ao redor do mundo. A SharkTeam é composta por membros com muitos anos de experiência em segurança cibernética e blockchain. Membros da equipe tem suas bases em Suzhou, Pequim (Beijing), Nanjing e Vale do Silício (Silicon Valley), competentes em teorias de blockchain subjacentes e contratos inteligentes, e fornecemos serviços abrangentes incluindo modelagem de ameaças, auditoria de contrato inteligente, resposta à emergência, etc. A SharkTeam estabeleceu cooperações estratégicas e de longo prazo com participantes importantes em muitas áreas do ecossistema blockchain, como Huobi Global, OKX, Polygon, Polkadot, ImToken, ChainIDE, etc.

Esse artigo foi escrito por SharkTeam e traduzido por Isabela Curado Nehme. Seu original pode ser lido aqui.

Top comments (0)