Feb 22, 2025

Solana Multisig Security

What can teams do if their multisig signers are compromised? We explore Solana's transaction signing model and present a procedure for safe signing in the presence of malicious signers on Solana.

Heading image of Solana Multisig Security

The Bybit hack raises an interesting question: what can teams do if their signers are compromised?

Solana Signatures

We first need to understand how Solana signatures work. There are two ways to sign a Solana transaction.

Recent Blockhash

The most straightforward is with a "recent blockhash". From the docs:

During transaction processing, Solana Validators will check if each transaction's recent blockhash is recorded within the most recent 151 stored hashes (aka "max processing age"). If the transaction's recent blockhash is older than this max processing age, the transaction is not processed.

The actual constant is defined here.

// The maximum age of a blockhash that will be accepted by the leader
pub const MAX_PROCESSING_AGE: usize = MAX_RECENT_BLOCKHASHES / 2;

For those curious, the logic starts here and is quite straightforward to follow, ending in a is_hash_index_valid check.

fn is_hash_index_valid(last_hash_index: u64, max_age: usize, hash_index: u64) -> bool {
    last_hash_index - hash_index <= max_age as u64
}

One important consequence is that any signed transaction has a natural expiration of around a few minutes.

Since slots (aka the time period a validator can produce a block) are configured to last about 400ms, but may fluctuate between 400ms and 600ms, a given blockhash can only be used by transactions for about 60 to 90 seconds before it will be considered expired by the runtime.

This means an attacker must use a malicious signed transaction within a short timeframe.

Durable Nonce

The second type of signature is a durable nonce. These were created to solve the very feature (or problem) mentioned above: short expiration time.

durable nonces provide an opportunity to create and sign a transaction that can be submitted at any point in the future, and much more. This opens up a wide range of use cases that are otherwise not possible or too difficult to implement

If we examine the code for recent blockhash validation, we can also see the handling for durable nonces.

    let recent_blockhash = tx.message().recent_blockhash();
    if let Some(hash_info) = hash_queue.get_hash_info_if_valid(recent_blockhash, max_age) {
        Ok(CheckedTransactionDetails {
            nonce: None,
            lamports_per_signature: hash_info.lamports_per_signature(),
        })
    } else if let Some((nonce, previous_lamports_per_signature)) = self
        .check_load_and_advance_message_nonce_account(
            tx.message(),
            next_durable_nonce,
            next_lamports_per_signature,
        )
    {
        Ok(CheckedTransactionDetails {
            nonce: Some(nonce),
            lamports_per_signature: previous_lamports_per_signature,
        })
    } else {
        error_counters.blockhash_not_found += 1;
        Err(TransactionError::BlockhashNotFound)
    }

The documentation does a good job of explaining how they work.

Durable Transaction Nonces, which are 32-byte in length (usually represented as base58 encoded strings), are used in place of recent blockhashes to make every transaction unique (to avoid double-spending) while removing the mortality on the unexecuted transaction.

Durable nonces are created and managed by the system program. They don't have a fixed PDA, so each account can have multiple associated nonces.

After a durable nonce is used, it'll be "advanced" to preventing replay attacks. The new nonce is calculated based on the current blockhash, and cannot be predicted in advance.

    let hash_queue = self.blockhash_queue.read().unwrap();
    let last_blockhash = hash_queue.last_hash();
    let next_durable_nonce = DurableNonce::from_blockhash(&last_blockhash);

This has an important consequence for our threat model. Unlike recent blockhash transactions, durable nonce transactions can be saved and reused.

Threat Model

Let's consider a simplified form of the original question.

  1. We have a N/M multisig
  2. Signers are unable to see what they're signing, both with respect to content and quantity of signatures. This is roughly equivalent to blind signing transactions.
  3. We can accurately query chain state.

Can we safely sign transactions?

One observation is that this problem is very hard to solve with durable nonces. By signing durable nonce transactions, an attacker could collect signatures and replay them at some indeterminite future point.

Luckily, durable nonces require an onchain account. It's possible to use a getProgramAccounts call to validate if your signer has an associated durable nonce.

const connection = new Connection(clusterApiUrl('testnet'));
const nonceAccounts = await connection.getProgramAccounts(
  // The system program owns all nonce accounts.
  SYSTEM_PROGRAM_ADDRESS,
  {
    filters: [
      {
        // Nonce accounts are exactly 80 bytes long
        dataSize: 80,
      },
      {
        // The authority's 32-byte public key is written
        // into bytes 8-40 of the nonce's account data.
        memcmp: {
          bytes: AUTHORITY_PUBLIC_KEY.toBase58(),
          offset: 8,
        },
      },
    ],
  }
);

Without durable nonces, the problem becomes much easier to solve. After waiting enough time, there'll be a point where all previously signed transactions will be expired. If we see no unexpected transactions, that means we're safe.

We can then use the following procedure.

  1. Ensure every signer has no durable nonce accounts.
  2. The first signer signs and submits the transaction.
  3. Wait two minutes for all recent blockhashes to expire.
  4. Observe recent transactions associated with the signer to ensure nothing unexpected is submitted.
  5. Repeat steps 2 to 4 for each signer

Beyond

Solana's signature model is unique. What can protocols do if they're deploying on blockchains without these unique properties? The most important constraint is observability. There must be a way you can see what you're signing, either while signing or implicitly after the fact.

For example, pcaversaccio wrote a tool to validate Safe transaction hashes. As the space matures, we hope more open source tooling will come to light.