On 21 February 2025, Bybit lost about US$1.5 billion in the largest theft in the history of cryptocurrency. The multisig wallet contract did not fail, and no private key leaked. The three signers each approved, on their own hardware wallet, a different transaction from the one the web interface showed them. The device displayed only a 32-byte hash, and nobody had a way to know that hash meant something else.

This article presents blindsight, an open-source Rust tool I built to close exactly that gap: it recomputes, offline, the hashes the hardware wallet shows, decodes what is about to be signed, and warns when the transaction can take over the wallet.

Concepts

Safe and multisig signatures

Safe (formerly Gnosis Safe) is the most widely used multisig wallet contract on Ethereum. It has a set of owners and a threshold: with 3-of-5, for example, any transaction needs three signatures. Owners do not sign the transaction itself, but a hash of it computed according to the EIP-712 standard, from fields such as to, value, data, operation and nonce.

The three EIP-712 hashes

EIP-712 splits the signature into two parts. The domain hash identifies where the signature is valid: the Safe's address and, from version 1.3.0 onwards, the chain ID. The message hash summarizes the transaction fields. The two are combined into the safeTxHash, which is what actually gets signed. Ledger devices show the domain hash and the message hash; when the signature uses eth_sign, the device can only show the safeTxHash, with no context at all.

Blind signing

Signing a hash without being able to check what it represents is called blind signing. In practice, the owner trusts the web interface that prepared the transaction to say what it does. If that interface is compromised, or the machine it runs on, the signature goes to a different transaction. The hash on the device is the only thing that does not pass through the interface, and it only helps if someone recomputes it independently.

CALL and DELEGATECALL

A Safe transaction has an operation field. With 0 (CALL), the Safe calls another contract normally. With 1 (DELEGATECALL), the target contract's code runs with the Safe's own storage. That code can rewrite the owners, the threshold, the modules and, above all, the implementation pointer stored in slot 0. After that, the attacker needs no further signature. DELEGATECALL has legitimate uses (the official MultiSend contract batches calls this way), but for any unknown target it is the most dangerous operation a multisig can sign.


The incidents

Bybit, February 2025

According to Sygnia's investigation, malicious JavaScript was served to the signers from the S3 bucket behind the Safe{Wallet} interface. The code activated only for specific addresses, swapped the transaction content for hardcoded parameters and was removed about two minutes after execution. The FBI attributed the attack to North Korea.

On-chain, the signed transaction made a DELEGATECALL to an attacker contract, through a function whose selector matches transfer(address,uint256). It looked like a token transfer. The code actually replaced the Safe's implementation, and the attacker emptied the wallet right after. The three owners signed with eth_sign, so their devices showed only the safeTxHash.

WazirX, July 2024

Seven months earlier, WazirX lost about US$230 million to the same pattern: a Safe 1.3.0 with threshold 4, three eth_sign signatures, a DELEGATECALL to an unknown contract, and slot 0 switched from the official implementation to the attacker's address. WazirX's and its custody provider's statements disagree on the root cause, but the signed transaction is on chain and not in dispute.

Both cases are part of blindsight's regression suite, with hashes recomputed independently from an archive node and the Safe Transaction Service.


Threat model

blindsight starts from one premise: the machine and interface that prepared the transaction may be compromised. It runs elsewhere, with no network, and recomputes everything from the raw parameters. A few consequences of that premise drove the design:

  1. Offline by construction. Networking and async crates are banned from the dependency tree via cargo deny, and the test suite runs in CI inside a network namespace with no interfaces. There is no online mode to switch on by mistake.
  2. No data comes from a service. Other tools query the Safe Transaction Service for the transaction or the decoded names. blindsight uses only user input and decodes everything locally, never trusting third-party labels.
  3. No panics on input. unsafe is forbidden, parsers and decoders are fuzzed, and errors are sanitized before printing, so hostile data cannot inject control sequences into the terminal.

How it works

 flags / Transaction Service JSON / execTransaction calldata
                          │
                  strict input parsing ──────── sanitised errors, size limits
                          │
         ┌────────────────┼──────────────────────────┐
         ▼                ▼                          ▼
   EIP-712 hashes    call tree decoding        embedded registry
   (two independent  (MultiSend, nested Safe,  (official Safe contracts per
   implementations)   user ABIs; verified vs    chain, tokens checked on
         │            inferred names)           chain, Permit2)
         │                │                          │
         │                ▼                          │
         │          20 detectors on every node ◄─────┘
         ▼                ▼
    report: verdict · hash checks · findings · hashes (wallet labels) · calls
            text or JSON · exit code 0/1/2/3

Two EIP-712 implementations

The domain hash, message hash and safeTxHash are computed by a hand-written EIP-712 encoder and by the alloy library's, on every run. If they ever disagree, no hash is printed. A wrong hash shown with confidence is worse than no hash at all. Both implementations are tested against 30 real transactions from 8 chains and 5 Safe versions, each confirmed by the Transaction Service, an on-chain event or signature recovery, and an independent Python implementation.

Verified versus inferred names

Calldata becomes a tree: multiSend batches and execTransaction calls to other Safes are decoded recursively. Every function name gets a label: verified, when the target is known to implement that function with the right operation, or inferred from the selector. A selector is just 4 bytes of the signature's hash, and any contract can have a function with the same selector that does something else. The Bybit payload decodes as transfer, labelled unverified, because a selector says nothing about what the code does.

20 detectors

Detectors run on every node of the tree, so wrapping a payload in a batch does not hide it. Each one has a stable ID and a severity that depends on context. The main ones:

  1. BS-DELEGATE-001 / 002: DELEGATECALL to an untrusted contract, or inside a batch.
  2. BS-SEMANTICS-001: the function name is a guess; critical when combined with DELEGATECALL.
  3. BS-OWNERS-001 / BS-THRESHOLD-001: owner set or threshold changes, critical if the new threshold is 1.
  4. BS-MODULE-001, BS-GUARD-001, BS-FALLBACK-001, BS-IMPL-001: enabling modules, changing guards, the fallback handler or the Safe's implementation, all ways around future signatures.
  5. BS-APPROVAL-001 / 002: unlimited approvals, NFT operators and Permit2 allowances.
  6. BS-REFUND-001: gas refund paid by the Safe, a way to drain the wallet through fields that are rarely read.
  7. BS-POISON-001: an address that looks like one in your address book (address poisoning).
  8. BS-REPLAY-001: Safes older than 1.3.0 have no chain ID in their domain, so the signature is valid on every chain.

Each detector has its own documentation page, also available through blindsight detectors explain <ID>.


Hands-on

Release binaries (Linux, macOS and Windows) come with checksums, an SBOM and GitHub build-provenance attestations. Since the tool is meant to run on a trusted machine, verify the download before using it:

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

Or build from source (Rust 1.90 or later):

cargo install --locked blindsight

Input can come in three forms. From the parameters shown in the Safe interface:

blindsight verify --chain-id 1 --safe 0xYourSafe --safe-version 1.4.1 \
  --to 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 \
  --data 0xa9059cbb000000000000000000000000... --nonce 42 \
  --expect-safe-tx-hash 0x...      # the hash your wallet shows

From a Safe Transaction Service JSON, or from the calldata of an already executed transaction, copied from a block explorer:

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

With an address book, addresses get labels and look-alikes are flagged:

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…" }

The Bybit transaction

The repository includes the attack transaction exactly as the signers' wallets received it. The real output (trimmed):

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

The safeTxHash above is exactly what the signers' devices showed. Had any of them pasted the parameters they received into blindsight, on a separate machine, they would have seen the hash matching their device and, right above it, two critical findings: a DELEGATECALL to an unknown contract and a zero-value "transfer" whose name is only a guess.

Hashes are printed with the labels and letter case each device uses (Ledger shows upper case). Exit codes make the flow scriptable: 0 with nothing at or above --fail-on (default high), 1 with findings, 2 for input errors and 3 when an expected hash does not match. With --format json, the report is versioned and machine-readable.


Recommended workflow

  1. Get the transaction parameters from the interface, and the hash shown by your wallet.
  2. Run blindsight on a different machine from the one that prepared the transaction, ideally an offline one, typing or copying the parameters.
  3. Pass the hash from your screen with --expect-safe-tx-hash (or --expect-domain-hash / --expect-message-hash). A mismatch is exit code 3 and a red line at the top.
  4. Read the verdict and every finding. Do not sign a critical or high finding you cannot explain.
  5. Simulate the transaction as well (Tenderly, Foundry): blindsight does not execute anything.

A matching hash proves you are signing the transaction you gave blindsight. The findings tell you what that transaction does. You need both checks.


Comparison with other tools

blindsight builds on projects that exist for the same reason and are worth using. safe-tx-hashes-util, written in Bash, is the community reference that Safe's own help center points to. safe-hash brought it to Rust, and op-txverify, in Go, pioneered the air-gapped workflow through QR codes.

blindsight's differences are narrower: no network code in the binary at all (enforced by cargo deny and a CI job without network), local decoding that never trusts a service's labels, explicit labelling of unverified names, address-poisoning checks and output sanitization. On the other hand, it does not yet handle off-chain messages, which the others support.


Limitations

blindsight does not simulate the transaction or read chain state, so it cannot see balances, current owners or what unknown code does. It flags that a name is a guess and that a target is unverified, but it cannot tell you the code is malicious. The embedded registry can go stale: new chains, helper contract versions and tokens appear as unknown until the tool is updated. Only Safe versions 1.0.0 to 1.5.0 that were officially deployed are supported.

Above all, it only helps if you compare. A matching hash means nothing unless you check it against your device, character by character.


Final thoughts

The Bybit and WazirX attacks did not break cryptography or contracts. They exploited the link between what a person sees and what they sign. Hardware wallets solve the key problem, but not the intent problem: a device that shows only a hash hands all the trust to whoever computed that hash.

blindsight gives that check back to the signer, with a tool that can run on an isolated machine and does not depend on any service that might have been compromised along with the interface. It is one layer of review among others, and it does not replace simulation or reading the code, but it is exactly the layer that was missing in US$1.7 billion of losses.