WEB3DEV

Cover image for Um Exame Detalhado do Nosso Extrator de Layout de Armazenamento
Adriano P. Araujo
Adriano P. Araujo

Posted on

Um Exame Detalhado do Nosso Extrator de Layout de Armazenamento

Compartilhamos como construímos uma ferramenta para recuperar os layouts de armazenamento de contratos compilados pelo solc sem a necessidade do código-fonte.

Discutimos nossa estratégia e pipeline, destacando detalhes importantes sobre execução e verificação de tipos. Também explicamos como a usamos na produção, comparamos sua saída com alternativas e compartilhamos algumas métricas de desempenho.

Por favor, note que, devido às diferenças entre os padrões de bytecode gerados por diferentes compiladores, nossa ferramenta oferece suporte a contratos compilados por outros compiladores (por exemplo, Vyper) de forma mínima. Gostaríamos da ajuda da comunidade para expandir isso; se você acha que está apto para a tarefa, por favor, veja esta issue e sinta-se à vontade para entrar em contato com nossa equipe!

  • Objetivos e Pipeline
  • Execução
  • Verificação de Tipos
  • Produção de Layouts de Armazenamento na Produção
  • Desempenho

Objetivos e Pipeline

Tínhamos três grandes objetivos para este projeto: precisava funcionar sem a necessidade de intervenção humana. Tinha que ser rápido o suficiente para rodar em todos os contratos. Tinha que ser simples o suficiente para ser estendido pelos desenvolvedores sem a necessidade de aprender todo o código-base.

A questão central: como inferimos os tipos de dados a partir do bytecode? Aprendemos sobre os tipos de dados através de como são utilizados no programa em execução. Considere o opcode CREATE: o valor que ele retorna é sempre um endereço. A maioria dos opcodes não é tão direta, mas o princípio ainda se aplica.

Para aplicar esse princípio, optamos por uma abordagem de execução simbólica: executamos o bytecode com símbolos (objetos únicos) no lugar dos valores que existiriam quando o contrato é executado na cadeia. Reunimos informações sobre as operações realizadas nesses símbolos. São essas operações e suas combinações que nos informam sobre os tipos envolvidos.

Optamos por um pipeline de três etapas:

  • Desmontagem: pegamos o bytecode do contrato e criamos uma representação executável de cada opcode que podemos alimentar ao analisador.

  • Execução: executamos a sequência de opcodes usando uma implementação especial da Máquina Virtual Ethereum (EVM). A execução constrói valores em forma de árvores com base nas operações realizadas. Esses valores também são movidos dentro da VM, movimentando-os na pilha e gravando-os e lendo-os em memória e armazenamento.

  • Verificação de Tipos: processamos as árvores de valores para obter informações de tipo. Primeiro, o verificador reconhece construções importantes, como índices de arrays dinâmicos, a partir de padrões de operações e os transforma em representações mais claras. Em seguida, utiliza regras de inferência para descobrir informações de tipo. Por fim, combina essas informações de tipo para fornecer o tipo que faz mais sentido para esse valor.

A desmontagem é bastante direta, então vamos entrar em mais detalhes sobre execução e verificação de tipos.

Execução

Embora a pilha EVM baseada em palavras seja simples de implementar, os desafios na construção de uma "EVM simbólica" residem nos detalhes:

  • Memória e Armazenamento Simbólicos: como nossos locais de memória e chaves de armazenamento são simbólicos, o analisador precisa ter muito cuidado para garantir que leituras e escritas adjacentes sejam tratadas adequadamente sempre que possível.

  • Completude da Execução: em um mundo ideal, o analisador executaria todos os caminhos possíveis através do bytecode. Infelizmente, alguns contratos — por exemplo, o Shardwallet, que inicialmente parecia travar indefinidamente — contêm muitas ramificações para que isso seja prático. Em vez disso, aproveitamos o fato de que o código Solidity correto transmite as mesmas informações de tipo em vários locais. Isso nos permite excluir caminhos potenciais pelo código sem perder muita informação. Existem alguns casos em que esse corte de caminho resultou em perdermos alguns caminhos de código bastante importantes — o CpuFrilessVerifier é um exemplo onde perdemos completamente alguns slots de armazenamento — mas isso pode ser melhorado no futuro!

  • Controle do Uso de Memória: mesmo com limites nas ramificações, é possível que o código use muita memória do sistema. Para evitar isso, temos que manter os valores de cada operação suficientemente pequenos. Limitar a árvore de valores às vezes significa descartar informações, mas é um sacrifício válido para garantir que a análise realmente seja concluída! Enfrentamos esse problema com o Indelible, que criava gravações de memória grandes o suficiente para, às vezes, causar falhas.

  • Dobramento Constante: embora não operemos com valores reais de tempo de execução, os contratos Solidity incorporam informações constantes no bytecode. Avaliar operações como adição sobre essas constantes melhora a precisão de nossa memória EVM simbólica e uso de armazenamento. Isso nos permite reter mais árvores de valores e adquirir informações adicionais de tipo.

Verificação de Tipos

A implementação da verificação de tipos possui seus próprios desafios:

  • Design para Extensibilidade: para atualizar facilmente padrões de detecção de tipos na EVM, criamos regras de inferência para verificação de tipos modulares e autocontidas. Isso permite modificações simples das regras existentes e a adição de novas regras. Um método geral combina evidências dessas regras.
  • Computação Sub-Word: para minimizar os custos de armazenamento on-chain, o compilador Solidity agrupa múltiplos valores em um slot de armazenamento sempre que possível. Nosso verificador de tipos considera isso com suporte integrado para dimensionamento e agrupamento de tipos.
  • Unificação Tradicional: a inferência gera um conjunto de tipos potenciais para um valor. Utilizamos um método padrão de verificação de tipos para mesclar esses tipos, produzindo um tipo único ou disparando um erro. Esse método garante alto desempenho e resultados consistentes.
  • Manter Informações: embora o analisador seja o sistema que efetivamente determina informações de tipo e tipos finais, queríamos garantir que os componentes subsequentes recebessem informações de tipo detalhadas. Para isso, não implementamos quaisquer regras de "fallback" no analisador, e sim fornecemos todas as informações que temos no limite. Para uma visão mais aprofundada da estrutura do analisador, consulte o passeio pelo código no repositório principal.

Produção de Layouts de Armazenamento

Nesta seção, detalhamos como usamos essa poderosa ferramenta em nosso pipeline de produção para disponibilizar dados de contratos não verificados em evm.storage.

Primeiramente, envolvemos o Analisador de Layout de Armazenamento com um componente CLI que processa a saída do analisador e gera um layout de armazenamento compatível com solc:

{  
  "storage" : [/* todas as entradas de slot de armazenamento */], 
  "types" : { /* todas as definições de tipo*/ }
}
Enter fullscreen mode Exit fullscreen mode

Isso nos permite utilizar os layouts de forma integrada na produção, como se viessem de contratos verificados.

Uma ótima maneira de demonstrar o poder do analisador é executá-lo em um contrato verificado e comparar sua saída com o layout solc baseado no código-fonte. Vamos fazer isso para o contrato Vault da Balancer - você pode ver seu layout solc em evm.storage:

Rodando nosso pipeline, esperamos ver 12 slots de armazenamento com 13 variáveis (0x03 está compactado com duas).

{
    "storage":
    [
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000000-D",
            "offset": 0,
            "slot": "0",
            "type": "t_bytes32"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000001",
            "offset": 0,
            "slot": "1",
            "type": "t_mapping(t_struct_t_bytes10_t_uint16_t_bytes20,t_struct_t_bytes32_t_mapping(t_bytes32,t_struct_t_bytes20_t_bytes12_t_bytes32)_t_mapping(t_bytes20,t_bytes32))"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000002",
            "offset": 0,
            "slot": "2",
            "type": "t_mapping(t_bytes20,t_bytes32)"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000003-D",
            "offset": 0,
            "slot": "3",
            "type": "t_bytes1"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000003-0x1",
            "offset": 1,
            "slot": "3",
            "type": "t_address"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000004",
            "offset": 0,
            "slot": "4",
            "type": "t_mapping(t_bytes20,t_mapping(t_bytes20,t_struct_t_bytes1_t_bytes31))"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000005",
            "offset": 0,
            "slot": "5",
            "type": "t_mapping(t_struct_t_bytes10_t_uint16_t_bytes20,t_struct_t_bytes1_t_bytes31)"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000006",
            "offset": 0,
            "slot": "6",
            "type": "t_bytes10"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000007",
            "offset": 0,
            "slot": "7",
            "type": "t_mapping(t_struct_t_bytes10_t_uint16_t_bytes20,t_mapping(t_bytes20,t_struct_t_bytes14_t_bytes14_t_bytes4))"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000008",
            "offset": 0,
            "slot": "8",
            "type": "t_mapping(t_struct_t_bytes10_t_uint16_t_bytes20,t_struct_t_array(t_bytes20)dyn_storage_t_mapping(t_bytes20,t_bytes32))"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x00000000000000000000000000000000000000000000000000000000000009",
            "offset": 0,
            "slot": "9",
            "type": "t_mapping(t_struct_t_bytes10_t_uint16_t_bytes20,t_struct_t_bytes32_t_bytes32_t_mapping(t_bytes32,t_struct_t_uint112_t_bytes18_t_bytes14_t_bytes18))"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x0000000000000000000000000000000000000000000000000000000000000a",
            "offset": 0,
            "slot": "10",
            "type": "t_mapping(t_struct_t_bytes10_t_uint16_t_bytes20,t_mapping(t_bytes20,t_bytes20))"
        },
        {
            "astId": -1,
            "contract": "",
            "label": "sla-0x0000000000000000000000000000000000000000000000000000000000000b",
            "offset": 0,
            "slot": "11",
            "type": "t_mapping(t_bytes20,t_mapping(t_bytes32,t_bytes32))"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

O último slot de armazenamento detectado pelo analisador é 11 (0x0B), o que significa que realmente detectamos 12 slots de armazenamento (começando em 0). Vemos também que o slot 3 tem duas entradas, uma para cada variável compactada.

A paridade com solc é ótima, o que esperaríamos. O mais surpreendente é que nossos layouts às vezes são muito mais fiéis ao uso interno subjacente dos slots de armazenamento! Considere _minimalSwapInfoPoolsBalances:

Apesar de parecer um mapeamento duplamente aninhado simples, na verdade é mais complexo do que a definição "oficial" em Solidity. Ao analisar a saída do nosso analisador, vemos a verdade por trás do que o solc interpreta como dois
bytes32:

// slot de armazenamento 0x7  

t_mapping(  
t_struct_t_bytes10_t_uint16_t_bytes20,t_mapping(  
t_bytes20,t_struct_t_bytes14_t_bytes14_t_bytes4  
)  
)
Enter fullscreen mode Exit fullscreen mode

A chave no primeiro mapeamento dessa variável é um poolId. Aqui está como está estruturado no código:

/**
 * @dev Cria um ID de Pool.
 *
 * Estes são criados de forma determinística, empacotando o endereço do contrato do Pool e sua configuração de especialização
 * no ID. Isso economiza gás, tornando esses dados facilmente recuperáveis de um ID de Pool sem acessos ao armazenamento.
 *
 * Como um único contrato pode registrar vários Pools, um nonce único deve ser fornecido para garantir que os IDs de Pool sejam
 * únicos.
 *
 * Os IDs de Pool têm o seguinte layout:
 * | 20 bytes do endereço do contrato do pool | 2 bytes de configuração de especialização | 10 bytes de nonce |
 * MSB                                                                              LSB
 *
 * 2 bytes para a configuração de especialização é um pouco exagerado: existem apenas três deles, o que significa que dois bits seriam
 * suficientes. No entanto, não há mais nada de interesse para armazenar neste espaço extra.
 */
function _toPoolId(
    address pool,
    PoolSpecialization specialization,
    uint80 nonce
) internal pure returns (bytes32) {
    bytes32 serialized;

    serialized |= bytes32(uint256(nonce));
    serialized |= bytes32(uint256(specialization)) << (10 * 8);
    serialized |= bytes32(uint256(pool)) << (12 * 8);

    return serialized;
}
Enter fullscreen mode Exit fullscreen mode

Nosso analisador identificou t_struct_t_bytes10_t_uint16_t_bytes20, o que corresponde perfeitamente ao que os desenvolvedores identificaram como:

20 bytes de endereço do contrato do pool | 2 bytes de configuração de especialização | 10 bytes de nonce.

Vamos descobrir sobre esse outro bytes32, que é derivado de uma biblioteca chamada BalanceAllocation:

// Um dos objetivos desta biblioteca é armazenar o saldo total do token em um único slot de armazenamento, por isso usamos
// inteiros sem sinal de 112 bits para 'cash' e 'managed'. Por questões de consistência, também impedimos qualquer combinação de 'cash' e
// 'managed' que resulte em um 'total' que não caiba em 112 bits.
//
// Os 32 bits restantes do slot são usados para armazenar o bloco mais recente em que o saldo total foi alterado. Isso
// pode ser usado para implementar oráculos de preço que são resistentes a ataques de 'sanduíche'.

Enter fullscreen mode Exit fullscreen mode

Nosso analisador identificou o valor no segundo mapeamento como t_struct_t_bytes14_t_bytes14_t_bytes4, o que corresponde exatamente aos 224 bits (112 bits + 112 bits) reservados para as partes cash e managed nas notas de desenvolvimento da Balancer, com os 32 bits restantes carregando o bloco mais recente em que o saldo foi alterado.

Também queríamos comparar com descompiladores públicos. A Biblioteca de Contratos do Dedaub é um dos nossos favoritos, conhecido por sua precisão. Aqui está a saída para o mesmo contrato:


// Descompilado por library.dedaub.com
// 24 de setembro de 2023 00:29 UTC
// Compilado usando o compilador de solidity versão 0.7.1

// Estruturas de dados e variáveis inferidas do uso de instruções de armazenamento
uint256 _setAuthorizer; // ARMAZENAMENTO[0x0]
mapping (uint256 => struct_2628) map_1; // ARMAZENAMENTO[0x1]
mapping (uint256 => uint256) _getNextNonce; // ARMAZENAMENTO[0x2]
uint256 stor_3_0_0; // ARMAZENAMENTO[0x3] bytes 0 a 0
uint256 _getAuthorizer; // ARMAZENAMENTO[0x3] bytes 1 a 20
mapping (uint256 => mapping (uint256 => uint256)) _hasApprovedRelayer; // ARMAZENAMENTO[0x4]
mapping (uint256 => uint256) _getPool; // ARMAZENAMENTO[0x5]
uint256 _registerPool; // ARMAZENAMENTO[0x6]
mapping (uint256 => mapping (uint256 => uint256)) map_7; // ARMAZENAMENTO[0x7]
mapping (uint256 => struct_2623) map_8; // ARMAZENAMENTO[0x8]
mapping (uint256 => struct_2624) map_9; // ARMAZENAMENTO[0x9]
mapping (uint256 => mapping (uint256 => uint256)) _getPoolTokenInfo; // ARMAZENAMENTO[0xa]
mapping (uint256 => mapping (uint256 => uint256)) _getInternalBalance; // ARMAZENAMENTO[0xb]

struct struct_2644 { uint256 field0; uint256 field1; };
struct struct_2646 { uint256 field0; uint256 field1; };
struct struct_2624 { uint256 field0; uint256 field1; mapping (uint256 => struct_2644) field2; };
struct struct_2628 { uint256 field0; mapping (uint256 => struct_2646) field1; mapping (uint256 => uint256) field2; };
Enter fullscreen mode Exit fullscreen mode

Eles conseguem identificar corretamente o slot de armazenamento empacotado e a estrutura geral do layout de armazenamento, mas a identificação deles do mapeamento em 0x07 como mapping (uint256 => mapping (uint256 => uint256)) não é tão detalhada quanto a nossa cobertura, como discutimos anteriormente.

Também verificamos o heimdall-rs, outro descompilador popular:

contract DecompiledContract {
  bytes32 public stor_c;
  bytes32 public stor_d;
  apping(bytes => bytes) public stor_map_b;
  mapping(bytes => bytes) public stor_map_e;
  mapping(address => address) public stor_map_a;
  mapping(address => address) public stor_map_f;
  mapping(address => bytes32) public stor_map_g;
  //...
}
Enter fullscreen mode Exit fullscreen mode

Nenhum mapeamento duplamente aninhado foi descoberto e os tipos não fazem muito sentido.

O exemplo fornecido acima não é um benchmark, mas sim um contrato complexo escolhido a dedo de um punhado de contratos que examinamos. Dada a natureza do solc e os padrões de bytecode que ele gera, temos confiança de que as amostras que examinamos representam a precisão da ferramenta. Ainda assim, estamos ansiosos para realizar um benchmark mais abrangente e testar isso (ou ter alguém da comunidade para executá-lo).

Desempenho

O desempenho é muito importante para nós. Nossa meta é fornecer insights em grandes cadeias como Ethereum, BSC, etc., com milhões de contratos implantados. Precisamos que nossas ferramentas de análise sejam extremamente rápidas 🦀. Para isso, avaliamos os tempos de execução em contratos do smart-contract-fiesta da Zellic, um conjunto de códigos de contrato inteligente deduplicados que foram implantados na Ethereum Mainnet (rede principal). Aqui está um histograma dos resultados:

Descobrimos que cerca de 20% dos contratos podem ser analisados em 20ms, e a grande maioria foi analisada em menos de 200ms. Isso inclui um overhead insignificante na comunicação com um servidor gRPC.

Obrigado por ler este post! Por favor, aproveite o evm.storage e confira o repositório. Junte-se ao nosso TG para nos dar feedback. Por fim, queremos agradecer novamente à Reilabs pelo trabalho nesta ferramenta, especialmente ao Ara, que também contribuiu para este post.


Este artigo foi escrito por Tal e traduzido por Adriano P. de Araujo. O original em inglês pode ser encontrado aqui.

Oldest comments (0)