Sec3 logo — Solana smart contract security firm
Back to Blog
Security

How Do Cross-Chain Bridges Work? Wormhole (Part 2)

Sec3 Research Team

Following Part 1, in this article we focus on guardian signatures verification in Wormhole on both Solana and Ethereum.

How Are the Guardian Signatures Verified (To Prevent Fake VAAs)?

On Solana, Wormhole uses the verify_signatures function to verify all the signatures in a VAA. Each VAA may contain multiple signatures (at least 2/3 of 19 verified signatures to reach a quorum). Because of the compute limit, it splits the signature verification into multiple steps (i.e., calling verify_signatures multiple times), with each call verifying a subset (e.g., six or seven) of the guardian signatures.

pub fn verify_signatures(
    ctx: &ExecutionContext,
    accs: &mut VerifySignatures,
    data: VerifySignaturesData,
) -> Result<()> {
    accs.guardian_set
        .verify_derivation(ctx.program_id, accs: &(&*accs).into())?;

    let sig_infos: Vec<SigInfo> = data VerifySignaturesData
        .signers [i8; _]
        .iter()
        .enumerate()
        .filter_map(|(i: usize, p: &i8)| {
            if *p == -1 {
                return None;
            }

            Some(SigInfo {
                sig_index: *p as u8,
                signer_index: i as u8,
            })
        })
        .collect();

The input accounts in VerifySignatures are defined below.

pub struct VerifySignatures<'b> {
    /// Payer for account creation
    pub payer: Mut<Signer<Info<'b>>>,

    /// Guardian set of the signatures
    pub guardian_set: GuardianSet<'b, { AccountState::Initialized }>,

    /// Signature Account
    pub signature_set: Mut<Signer<SignatureSet<'b, { AccountState::MaybeInitialized }>>>,

    /// Instruction reflection account (special sysvar)
    pub instruction_acc: Info<'b>,
}

The two PDA accounts guardian_set and signature_set are important. The guardian_set account must have been initialized (AccountState::Initialized) and it stores a set of the verified guardians (including their keys).

The signature_set stores the verified status (true or false) of each guardian signature (signatures: Vec<bool>), the message hash, and the guardian_set_index:

pub struct SignatureSet {
    pub signatures: Vec<bool>,
    pub hash: [u8; 32],
    pub guardian_set_index: u32,
}
pub struct GuardianSetData {
    /// Index representing an incrementing version number for this guardian set.
    pub index: u32,

    /// ETH style public keys
    pub keys: Vec<GuardianPublicKey>,

    /// Timestamp representing the time this guardian became active.
    pub creation_time: u32,

    /// Expiration time when VAAs issued by this set are no longer valid.
    pub expiration_time: u32,
}
pub struct SignatureSetData {
    /// Signatures of validators
    pub signatures: Vec<bool>,

    /// Hash of the data
    pub hash: [u8; 32],

    /// Index of the guardian set
    pub guardian_set_index: u32,
}

The signature_set account is created and initialized in the first call to verify_signatures.

    if !accs.signature_set.is_initialized() {
        accs.signature_set.signatures = vec![false; accs.guardian_set.keys.len()];
        accs.signature_set.guardian_set_index = accs.guardian_set.index;
        accs.signature_set.hash = msg_hash;

        let size: usize = accs.signature_set.size();
        create_account(
            ctx,
            account: accs.signature_set.info(),
            payer: accs.payer.key,
            lamports: Exempt,
            size,
            owner: ctx.program_id,
            seeds: NotSigned,
        )?;
    } else {
        // If the account already existed, check that the parameters match
        if accs.signature_set.guardian_set_index != accs.guardian_set.index {
            return Err(GuardianSetMismatch.into());
        }

        if accs.signature_set.hash != msg_hash {
            return Err(InvalidHash.into());
        }

In the subsequent calls, the same signature_set is validated with its corresponding message hash and guardian_set_index to ensure it cannot be faked (lines 188 and 192).

Once a signature is verified, the corresponding signer_index of signature_set.signatures will be set true.

    // Overwritten content should be zeros except double signs by the signer or harmless replays
    accs.signature_set.signatures[s.signer_index as usize] = true;

How is Each Guardian Signature Verified?

Wormhole (on Solana) uses the Precompiled Secp256k1 SigVerify Program and each call to verify_signatures is prepended with an instruction to the SigVerify program, which verifies an input sequence of signatures.

#1 Secp256k1 SigVerify Precompile: Unknown Instruction

Program                                  Secp256k1 SigVerify Precompile

                07 4e 00 00 8f 00 00 a1 02 20 00 00 a3 00 00 e4
                00 00 a1 02 20 00 00 f8 00 00 39 01 00 a1 02 20
                00 00 4d 01 00 8e 01 00 a1 02 20 00 00 a2 01 00
                e3 01 00 a1 02 20 00 00 f7 01 00 38 02 00 a1 02
                20 00 00 4c 02 00 8d 02 00 a1 02 20 00 00 03 ae

Wormhole uses sysvar_instructions to load the current_instruction corresponding to verify_signatures and it checks that its previous instruction must be a secp verification instruction (i.e., a call to SigVerify).

    let current_instruction: u16 =
        solana_program::sysvar::instructions::load_current_index_checked(instruction_sysvar_account_info: &accs.instruction_acc)?;
    if current_instruction == 0 {
        return Err(InstructionAtWrongIndex.into());
    }
    // The previous ix must be a secp verification instruction
    let secp_ix_index: u8 = (current_instruction - 1) as u8;
    let secp_ix: Instruction = solana_program::sysvar::instructions::load_instruction_at_checked(
        secp_ix_index as usize,
        instruction_sysvar_account_info: &accs.instruction_acc,
    ) Result<Instruction, ProgramError>
    .map_err(op: |_| ProgramError::InvalidAccountData)?;

    // Check that the instruction is actually for the secp program
    if secp_ix.program_id != solana_program::secp256k1_program::id() {
        return Err(InvalidSecpInstruction.into());
    }

The program instruction logs illustrate the flow clearly.

Program Instruction Logs

#1 Secp256k1 SigVerify Precompile Instruction

#2 Wormhole Core Bridge Instruction
  > Program invoked: System Program
    > Program returned success
  > Program invoked: System Program
    > Program returned success
  > Program invoked: System Program
    > Program returned success
  > Program consumed: 38108 of 400000 compute units
  > Program returned success

How Are Signatures Verified on Ethereum?

Different from Solana, Wormhole on Ethereum verifies all signatures in a single transaction by calling the verifyVM function (vm is a VAA byte array).

    /**
     * @dev `verifyVM` serves to validate an arbitrary vm against a valid Guardian set
     *  - it aims to make sure the VM is for a known guardianSet
     *  - it aims to ensure the guardianSet is not expired
     *  - it aims to ensure the VM has reached quorum
     *  - it aims to verify the signatures provided against the guardianSet
     */
    function verifyVM(Structs.VM memory vm) public view returns (bool valid, string memory reason) {

It ensures quorum by checking the signatures length.

        /**
         *  WARNING: This quorum check is critical to assessing whether we have enough Guardian signatures to validate a VM
         *  if making any changes to this, obtain additional peer review. If guardianSet key length is 0 and
         *  vm.signatures length is 0, this could compromise the integrity of both vm and signature verification.
         */
        if (vm.signatures.length < quorum(guardianSet.keys.length)){
            return (false, "no quorum");
        }

It calls verifySignatures (similar to Solana, taking also message hash and guardianSet as input).

        /// @dev Verify the proposed vm.signatures against the guardianSet
        (bool signaturesValid, string memory invalidReason) = verifySignatures(vm.hash, vm.signatures, guardianSet);
        if(!signaturesValid){
            return (false, invalidReason);
        }

The verifySignatures verifies the input signatures one by one by using ecrecover (line 93):

address signatory = ecrecover(message, v, r, s);
    /**
     * @dev verifySignatures serves to validate arbitrary sigatures against an arbitrary guardianSet
     *  - it intentionally does not solve for expectations within guardianSet (you should use verifyVM if you need these protections)
     *  - it intentioanlly does not solve for quorum (you should use verifyVM if you need these protections)
     *  - it intentionally returns true when signatures is an empty set (you should use verifyVM if you need these protections)
     */
    function verifySignatures(bytes32 hash, Structs.Signature[] memory signatures, Structs.GuardianSet memory guardianSet) public
        uint8 lastIndex = 0;
        uint256 guardianCount = guardianSet.keys.length;
        for (uint i = 0; i < signatures.length; i++) {
            Structs.Signature memory sig = signatures[i];

            /// Ensure that provided signature indices are ascending only
            require(i == 0 || sig.guardianIndex > lastIndex, "signature indices must be ascending");
            lastIndex = sig.guardianIndex;

            /// @dev Ensure that the provided signature index is within the
            /// bounds of the guardianSet. This is implicitly checked by the array
            /// index operation below, so this check is technically redundant.
            /// However, reverting explicitly here ensures that a bug is not
            /// introduced accidentally later due to the nontrivial storage
            /// semantics of solidity.
            require(sig.guardianIndex < guardianCount, "guardian index out of bounds");

            /// Check to see if the signer of the signature does not match a specific Guardian key at the provided index
            if(ecrecover(hash, sig.v, sig.r, sig.s) != guardianSet.keys[sig.guardianIndex]){
                return (false, "VM signature invalid");
            }

The ecrecover(message, v, r, s) API computes the key that produced a signature (v, r, s) on message. See here for a technical description of ecrecover. Verification passes if the returned key matches with the corresponding guardian's public key (guardianSet.keys[sig.guardianIndex]).

What if a (Malicious) Guardian Submits Multiple Repeated Signatures in a VAA?

Wormhole prevents this attack surface by ensuring that the signature indices in the VAA must be ascending (on Ethereum).

On Solana, Wormhole maintains a pair (sig_index, signer_index) for each signature, and matches the key of the verified signature with the corresponding guardian public key (line 209).

    // Write sigs of checked addresses into sig_state
    for s: SigInfo in sig_infos {
        if s.signer_index > accs.guardian_set.num_guardians() {
            return Err(ProgramError::InvalidArgument.into());
        }

        if s.sig_index + 1 > sig_len {
            return Err(ProgramError::InvalidArgument.into());
        }

        let key: [u8; 20] = accs.guardian_set.keys[s.signer_index as usize];
        // Check key in ix
        if key != secp_ixs[s.sig_index as usize].address {
            return Err(ProgramError::InvalidArgument.into());
        }

        // Overwritten content should be zeros except double signs by the signer or harmless replays
        accs.signature_set.signatures[s.signer_index as usize] = true;

Even if an attacker were able to control a guardian and place 19 repeated signatures in a VAA, only one guardian's signer_index would be set to true in signature_set.signatures, thus the attack would fail.

Wormhole also checks validity of the guardian_set and integrity of the signature_set.

    if accs.message.is_initialized() {
        return Ok(());
    }

    // Verify any required invariants before we process the instruction.
    check_active(&accs.guardian_set, &accs.clock)?;
    check_valid_sigs(&accs.guardian_set, signatures: &accs.signature_set)?;
    check_integrity(&vaa, signatures: &accs.signature_set)?;

The quorum check is done in the PostVAA instruction.

    // Count the number of signatures currently present.
    let signature_count: usize = accs.signature_set.signatures.iter().filter(|v: &&bool| **v).count();

    // Calculate how many signatures are required to reach consensus. This calculation is in
    // expanded form to ease auditing.
    let required_consensus_count: usize = {
        let len: usize = accs.guardian_set.keys.len();
        // Fixed point number transformation with one decimal to deal with rounding.
        let len: usize = (len * 10) / 3;
        // Multiplication by two to get a 2/3 quorum.
        let len: usize = len * 2;
        // Division to bring number back into range.
        len / 10 + 1
    };

    if signature_count < required_consensus_count {
        return Err(PostVAAConsensusFailed.into());
    }

In the next article, we will continue to discuss the security of cross-chain token mint/burn in Wormhole.


About sec3 (Formerly Soteria)

sec3 is a security research firm that prepares blockchain projects for millions of users. sec3's Launch Audit is a rigorous, researcher-led code examination that investigates and certifies mainnet-grade smart contracts; sec3's continuous auditing software platform, X-ray, integrates with Github to progressively scan pull requests, helping projects fortify code before deployment; and sec3's post-deployment security solution, WatchTower, ensures funds stay safe. sec3 is building technology-based scalable solutions for Web3 projects to ensure protocols stay safe as they scale.

To learn more about sec3, please visit https://www.sec3.dev

Related Posts

Security

IDL Guesser

The Solana ecosystem thrives on innovation, but many Anchor-based programs do not publish up-to-date IDLs, which complicates the analysis of such programs and their transactions. To tackle this, we developed and open-sourced a prototype tool called IDL Guesser. This tool aims to automatically recover instruction definitions, required accounts (including signer/writable flags), and parameter information directly from closed-source Solana program binaries. This blog outlines the approach behind IDL Guesser and discusses potential areas for future improvement.

Read more
Security

All About Anchor Account Size

Smart contracts using Anchor require developers to allocate space for new accounts and specify the account size. Anchor provides guidelines for calculating the size based on the account structure, but many developers use std::mem::size_of instead, as they don't have to manually update the size when making changes to the account structure. Are they equivalent? In this blog post, we conduct a systematic comparison of the results produced by std::mem::size_of and the Anchor space reference.

Read more