Em 21 de fevereiro de 2025, a Bybit perdeu cerca de US$ 1,5 bilhão no maior roubo da história das criptomoedas. Não houve falha no contrato da carteira multisig, nem chave privada vazada. Os três responsáveis pela assinatura aprovaram, cada um no seu hardware wallet, uma transação diferente daquela que a interface web mostrava. O dispositivo exibia apenas um hash de 32 bytes, e ninguém tinha como saber que aquele hash correspondia a outra coisa.

Este artigo apresenta o blindsight, uma ferramenta open-source em Rust que desenvolvi para fechar exatamente essa lacuna: ela recalcula, offline, os hashes que o hardware wallet mostra, decodifica o que está prestes a ser assinado e avisa quando a transação é capaz de tomar o controle da carteira.

Conceitos

Safe e assinaturas multisig

O Safe (antigo Gnosis Safe) é o contrato de carteira multisig mais usado do Ethereum. Ele tem um conjunto de donos (owners) e um limiar (threshold): com 3 de 5, por exemplo, qualquer transação precisa de três assinaturas. Cada dono não assina a transação em si, mas um hash dela calculado conforme o padrão EIP-712, a partir de campos como to, value, data, operation e nonce.

Os três hashes do EIP-712

O EIP-712 separa a assinatura em duas partes. O domain hash identifica onde a assinatura vale: o endereço do Safe e, a partir da versão 1.3.0, o chain ID. O message hash resume os campos da transação. Os dois são combinados no safeTxHash, que é o valor efetivamente assinado. Dispositivos Ledger mostram o domain hash e o message hash; quando a assinatura usa eth_sign, o dispositivo só consegue mostrar o safeTxHash, sem nenhum contexto.

Blind signing

Assinar um hash sem conseguir verificar o que ele representa é chamado de blind signing. Na prática, o dono confia na interface web que preparou a transação para dizer o que ela faz. Se essa interface for comprometida, ou a máquina onde ela roda, a assinatura vai para outra transação. O hash no dispositivo é a única coisa que não passa pela interface, e só ajuda se alguém o recalcular de forma independente.

CALL e DELEGATECALL

Uma transação Safe tem um campo operation. Com 0 (CALL), o Safe chama outro contrato normalmente. Com 1 (DELEGATECALL), o código do contrato alvo é executado com o storage do próprio Safe. Esse código pode reescrever os donos, o limiar, os módulos e, principalmente, o ponteiro de implementação guardado no slot 0. Depois disso, o atacante não precisa de mais nenhuma assinatura. DELEGATECALL tem usos legítimos (o contrato oficial MultiSend agrupa chamadas dessa forma), mas para qualquer alvo desconhecido é a operação mais perigosa que uma multisig pode assinar.


Os incidentes

Bybit, fevereiro de 2025

Segundo a investigação da Sygnia, JavaScript malicioso foi servido aos signatários a partir do bucket S3 por trás da interface do Safe{Wallet}. O código se ativava apenas para endereços específicos, trocava o conteúdo da transação por parâmetros fixos e foi removido cerca de dois minutos após a execução. O FBI atribuiu o ataque à Coreia do Norte.

On-chain, a transação assinada fazia um DELEGATECALL para um contrato do atacante, por meio de uma função cujo seletor corresponde a transfer(address,uint256). Parecia uma transferência de token. O código, na verdade, substituiu a implementação do Safe, e o atacante esvaziou a carteira em seguida. Os três donos assinaram com eth_sign, então os dispositivos mostraram apenas o safeTxHash.

WazirX, julho de 2024

Sete meses antes, a WazirX perdeu cerca de US$ 230 milhões com o mesmo padrão: um Safe 1.3.0 com limiar 4, três assinaturas eth_sign, DELEGATECALL para um contrato desconhecido e o slot 0 trocado da implementação oficial para o endereço do atacante. As declarações da WazirX e da custodiante divergem sobre a causa raiz, mas a transação assinada está na chain e não está em disputa.

Os dois casos estão no conjunto de testes de regressão do blindsight, com os hashes recalculados de forma independente a partir de um nó de arquivo e do Safe Transaction Service.


Modelo de ameaça

O blindsight parte de uma premissa: a máquina e a interface que prepararam a transação podem estar comprometidas. Ele roda em outro lugar, sem rede, e recalcula tudo a partir dos parâmetros brutos. Algumas consequências dessa premissa guiaram o design:

  1. Offline por construção. Crates de rede e de async são banidas da árvore de dependências via cargo deny, e a suíte de testes roda no CI dentro de um network namespace sem interfaces. Não existe um modo online para ser ativado por engano.
  2. Nenhum dado vem de um serviço. Outras ferramentas consultam o Safe Transaction Service para obter a transação ou os nomes decodificados. O blindsight usa apenas a entrada do usuário e decodifica tudo localmente, sem confiar em rótulos de terceiros.
  3. Sem panics com entrada. unsafe é proibido, parsers e decodificadores passam por fuzzing, e erros são sanitizados antes de serem impressos, para que dados hostis não injetem sequências de controle no terminal.

Como funciona

 flags / JSON do Transaction Service / calldata de execTransaction
                          │
                  parsing estrito da entrada ─────── erros sanitizados, limites de tamanho
                          │
         ┌────────────────┼──────────────────────────┐
         ▼                ▼                          ▼
   hashes EIP-712    decodificação em árvore    registro embarcado
   (duas impl.       (MultiSend, Safe aninhado,  (contratos oficiais do Safe
   independentes)    ABIs do usuário; nomes      por chain, tokens checados
         │           verificados vs inferidos)   on-chain, Permit2)
         │                │                          │
         │                ▼                          │
         │          20 detectores em cada nó ◄───────┘
         ▼                ▼
    relatório: veredito · checagem de hash · achados · hashes · chamadas
               texto ou JSON · exit code 0/1/2/3

Duas implementações de EIP-712

O domain hash, o message hash e o safeTxHash são calculados por um encoder EIP-712 escrito à mão e pelo da biblioteca alloy, em toda execução. Se os dois divergirem, nenhum hash é impresso. Um hash errado mostrado com confiança é pior do que nenhum hash. Ambas as implementações são testadas contra 30 transações reais de 8 chains e 5 versões do Safe, cada uma confirmada pelo Transaction Service, por um evento on-chain ou recuperação de assinatura, e por uma implementação independente em Python.

Nomes verificados versus inferidos

O calldata vira uma árvore: lotes multiSend e chamadas execTransaction para outros Safes são decodificados recursivamente. Cada nome de função recebe um rótulo: verified, quando o alvo é sabidamente um contrato que implementa aquela função com a operação certa, ou inferred from the selector. Um seletor são só 4 bytes do hash da assinatura, e qualquer contrato pode ter uma função com o mesmo seletor e fazer outra coisa. O payload da Bybit decodifica como transfer, marcado como não verificado, porque o seletor não diz nada sobre o que o código faz.

20 detectores

Os detectores rodam em todos os nós da árvore, então embrulhar um payload em um lote não o esconde. Cada um tem ID estável e severidade que depende do contexto. Os principais:

  1. BS-DELEGATE-001 / 002: DELEGATECALL para contrato não confiável, ou dentro de um lote.
  2. BS-SEMANTICS-001: o nome da função é um palpite; crítico quando combinado com DELEGATECALL.
  3. BS-OWNERS-001 / BS-THRESHOLD-001: mudanças no conjunto de donos ou no limiar, críticas se o novo limiar for 1.
  4. BS-MODULE-001, BS-GUARD-001, BS-FALLBACK-001, BS-IMPL-001: habilitar módulos, trocar guards, fallback handler ou a implementação do Safe, todas formas de contornar as assinaturas futuras.
  5. BS-APPROVAL-001 / 002: approvals ilimitados, operadores de NFT e allowances do Permit2.
  6. BS-REFUND-001: reembolso de gas pago pelo Safe, uma forma de drenar a carteira por campos que raramente são lidos.
  7. BS-POISON-001: endereço parecido com um do seu address book (address poisoning).
  8. BS-REPLAY-001: Safes anteriores à 1.3.0 não têm chain ID no domínio, então a assinatura vale em qualquer chain.

Cada detector tem uma página própria de documentação, também disponível com blindsight detectors explain <ID>.


Hands-on

Os binários de release (Linux, macOS e Windows) acompanham checksums, SBOM e atestados de proveniência do GitHub. Como a ferramenta deve rodar em uma máquina confiável, vale verificar o download antes de usá-lo:

gh attestation verify blindsight-x86_64-unknown-linux-gnu.tar.xz --repo brennocm/blindsight
sha256sum -c blindsight-x86_64-unknown-linux-gnu.tar.xz.sha256

Ou compilando a partir do código-fonte (Rust 1.90 ou superior):

cargo install --locked blindsight

A entrada pode vir de três formas. A partir dos parâmetros mostrados pela interface do Safe:

blindsight verify --chain-id 1 --safe 0xSeuSafe --safe-version 1.4.1 \
  --to 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  --data 0xa9059cbb000000000000000000000000... --nonce 42 \
  --expect-safe-tx-hash 0x...      # o hash que sua carteira mostra

A partir de um JSON do Safe Transaction Service, ou do calldata de uma transação já executada, copiado de um block explorer:

blindsight verify --tx tx.json --chain-id 1 --safe-version 1.4.1
blindsight verify --exec-calldata 0x6a761202… --chain-id 1 \
  --safe 0xSeuSafe --safe-version 1.1.1 --nonce 71

Com um address book, os endereços ganham rótulos e endereços parecidos são sinalizados:

blindsight verify --tx tx.json --chain-id 1 --safe-version 1.4.1 \
  --address-book book.json --current-nonce 42
# book.json: { "Treasury": "0x…", "Ops multisig": "0x…" }

A transação da Bybit

O repositório inclui a transação do ataque exatamente como as carteiras dos signatários a receberam. A saída real (resumida):

blindsight verify --tx bybit-tx.json --chain-id 1 --safe-version 1.1.1
CRITICAL: DO NOT SIGN without independent review
Findings: 2 critical, 1 low, 1 info

FINDINGS (4)
  CRITICAL  BS-DELEGATE-001   root
            Delegatecall to a contract that is not an official Safe helper
            This call runs the code at
            0x96221423681A6d52E184D440a8eFCEbB105C7242 with the storage of
            0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4 (this Safe). That code
            can replace the owners, threshold, modules, guard, fallback handler
            and the implementation pointer in storage slot 0, after which the
            attacker no longer needs any signature.
  CRITICAL  BS-SEMANTICS-001  root
            "transfer" is a guess from the selector, not a verified function
  LOW       BS-REPLAY-001     root
            Signature valid on every chain
  INFO      BS-TARGET-001     root
            Unknown target

HASHES: compare with your hardware wallet
  Safe            0x1Db92e2EeBC8E0c075a02BeA49a2935BcD2dFCF4 (this Safe)
  Chain           1 (Ethereum Mainnet)
  Safe version    1.1.1 (no chain id in the domain)
  Domain hash     0xb3ded2bdbff5db1a87f6d551fa256e9f2bd6517a3bb84f4c2ea863fb3a559622
    as on Ledger  0xB3DED2BDBFF5DB1A87F6D551FA256E9F2BD6517A3BB84F4C2EA863FB3A559622
  Message hash    0xea68fc75e93bf7286b1257680ca8e2033bfadd8e1cb8a23f3ca18f1031ecf1e0
    as on Ledger  0xEA68FC75E93BF7286B1257680CA8E2033BFADD8E1CB8A23F3CA18F1031ECF1E0
  safeTxHash      0xb3476d061aeb8fc1d605a873c483a2402d88a68a9cdd1a8b47655dd55ba004f8

CALLS
  root  DELEGATECALL 0x96221423681A6d52E184D440a8eFCEbB105C7242
      transfer(address,uint256)  [UNVERIFIED: name inferred from the selector only]
        to               0xbDd077f651EBe7f7b3cE16fe5F2b025BE2969516
        amount           0

O safeTxHash acima é exatamente o que os dispositivos dos signatários mostraram. Se qualquer um deles tivesse colado os parâmetros recebidos no blindsight, em uma máquina separada, teria visto o hash batendo com o dispositivo e, logo acima, dois achados críticos: um DELEGATECALL para um contrato desconhecido e uma "transferência" de valor zero cujo nome é só um palpite.

Os hashes saem com os rótulos e a caixa usados por cada dispositivo (o Ledger mostra em maiúsculas). Os códigos de saída permitem automatizar o fluxo: 0 sem achados acima de --fail-on (padrão high), 1 com achados, 2 erro de entrada e 3 quando o hash esperado não confere. Com --format json, o relatório é versionado e legível por máquina.


Fluxo recomendado

  1. Obtenha os parâmetros da transação na interface e o hash mostrado pela sua carteira.
  2. Rode o blindsight em uma máquina diferente da que preparou a transação, idealmente offline, digitando ou copiando os parâmetros.
  3. Passe o hash da tela com --expect-safe-tx-hash (ou --expect-domain-hash / --expect-message-hash). Uma divergência gera exit code 3 e uma linha vermelha no topo.
  4. Leia o veredito e cada achado. Não assine um achado crítico ou alto que você não consiga explicar.
  5. Simule a transação também (Tenderly, Foundry): o blindsight não executa nada.

Um hash que confere prova que você está assinando a transação que deu ao blindsight. Os achados dizem o que essa transação faz. As duas verificações são necessárias.


Comparação com outras ferramentas

O blindsight se apoia em projetos que existem pelo mesmo motivo e merecem ser usados. O safe-tx-hashes-util, em Bash, é a referência da comunidade indicada pela própria central de ajuda do Safe. O safe-hash o trouxe para Rust, e o op-txverify, em Go, foi pioneiro no fluxo air-gapped via QR codes.

As diferenças do blindsight são mais estreitas: nenhum código de rede no binário (garantido por cargo deny e por um job de CI sem rede), decodificação local que nunca confia nos rótulos de um serviço, marcação explícita de nomes não verificados, detecção de address poisoning e sanitização da saída. Por outro lado, ele ainda não trata mensagens off-chain, que as outras ferramentas suportam.


Limitações

O blindsight não simula a transação nem lê o estado da chain, então não enxerga saldos, donos atuais ou o que um código desconhecido faz. Ele sinaliza que um nome é palpite e que um alvo não é verificado, mas não consegue afirmar que o código é malicioso. O registro embarcado pode ficar desatualizado: novas chains, versões de contratos auxiliares e tokens aparecem como desconhecidos até a ferramenta ser atualizada. São suportadas apenas as versões do Safe de 1.0.0 a 1.5.0 que foram oficialmente implantadas.

E, acima de tudo, ele só ajuda se você comparar. Um hash que confere não significa nada se não for checado contra o dispositivo, caractere por caractere.


Considerações finais

Os ataques à Bybit e à WazirX não quebraram criptografia nem contratos. Eles exploraram o elo entre o que uma pessoa vê e o que ela assina. Hardware wallets resolvem o problema da chave, mas não o da intenção: um dispositivo que mostra apenas um hash transfere toda a confiança para quem calculou esse hash.

O blindsight devolve essa verificação para o signatário, com uma ferramenta que pode rodar em uma máquina isolada e que não depende de nenhum serviço que possa ter sido comprometido junto com a interface. É uma camada de revisão entre outras, e não substitui simulação nem leitura do código, mas é exatamente a camada que faltou em US$ 1,7 bilhão de perdas.