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:
- 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.
- 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.
- 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:
- BS-DELEGATE-001 / 002: DELEGATECALL para contrato não confiável, ou dentro de um lote.
- BS-SEMANTICS-001: o nome da função é um palpite; crítico quando combinado com DELEGATECALL.
- BS-OWNERS-001 / BS-THRESHOLD-001: mudanças no conjunto de donos ou no limiar, críticas se o novo limiar for 1.
- 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.
- BS-APPROVAL-001 / 002: approvals ilimitados, operadores de NFT e allowances do Permit2.
- BS-REFUND-001: reembolso de gas pago pelo Safe, uma forma de drenar a carteira por campos que raramente são lidos.
- BS-POISON-001: endereço parecido com um do seu address book (address poisoning).
- 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
- Obtenha os parâmetros da transação na interface e o hash mostrado pela sua carteira.
- Rode o blindsight em uma máquina diferente da que preparou a transação, idealmente offline, digitando ou copiando os parâmetros.
- 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.
- Leia o veredito e cada achado. Não assine um achado crítico ou alto que você não consiga explicar.
- 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.