Quick Analysis of the Wormhole attack

Summary

An anonymous attacker used a verification problem in the Wormhole program and 80000 wETH were pulled out of the Wormhole contract. The problem was the usage of instruction load_instruction_at in function verify_signatures of the Wormhole program. After changing the signature of a malicious message, the attacker was able to transferred from Solana tokens which were identical to legitimate tokens through the Wormhole bridge to Ethereum.

What happened

Wormhole Bridge is a bridge between blockchains, it allows for transferring assets from one blockchain to another. More precisely, it is a token bridge and a NFT bridge. Tokens are created in each chain, for example, on Ethereum they are ERC20 and on Solana they are SPL tokens. In addition, a smart contract (or program on Solana) manage each token on each chain. On Solana, the Wormhole program is deployed here. The BPF bytecode is available but also the source code is written in Rust and open-source.

Above that, Guardians manage transactions between each blockchain. Before transferring the token to another chain, They check that minted tokens were correctly generated by verifying their signature on secp256k1 curve.

In Solana, the instruction_sysvar account contains all instructions of the message of the transaction that is being processed. This allows program instructions to reference other instructions in the same transaction (https://docs.solana.com/developing/runtime-facilities/sysvars#instructions).

For Wormbridge, the verify_signatures function is called priorly to get the signed signature_set for the function post_vaa. Basically, the wormhole program obtains the set of signatures from the prior instruction via the instruction sysvar program (in which its address is inputted by the user).

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>,
}

However, the verify_signatures function used the load_instruction_at function which outputs an instruction that is derived from the input data (which is the data of the instruction sysvar account). This function does not check if the input sysvar program account is the real sysvar account. Basically, the instruction sysvar program was never checked.

let secp_ix = solana_program::sysvar::instructions::load_instruction_at(
    secp_ix_index as usize,
    &accs.instruction_acc.try_borrow_mut_data()?,
)

Thus, the attacker created a fake instruction sysvar account with fake data (https://solscan.io/account/2tHS1cXX2h1KBEaadprqELJ6sV9wLoaSdX68FqsrrZRd); therefore the signatures were spoofed with previously valid transferred tokens (https://solscan.io/tx/5fKWY7XyW6PTzjviTDvCTpsqgfoGAAqUs1mC6w4DZm25Ppw7fX7aWDmrnkknewyZ81qMSix3c18ZuvjoZUF34tpa). Thus, all signatures in the signature_set are marked as true which means it has all valid signatures.

for s 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 = 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;
}

Once a signature_set is created, the function post_vaa will check if it has enough number of signatures to reach the consensus to post a Validator Action Approval (VAA). Now the attacker has a valid VAA and can trigger an unauthorized mint to his own account.

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

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

We want to emphasize that it is very important to verify the validity of unmodified, reference-only accounts in Solana (https://docs.solana.com/developing/programming-model/accounts#verifying-validity-of-unmodified-reference-only-accounts). It is because a malicious user could create accounts with arbitrary data and then pass these accounts to the program in place of valid accounts. This attack is an example.

Conclusion

The attack on Wormhole is the second-largest reported hack after Poly Network (https://research.kudelskisecurity.com/2021/08/12/the-poly-network-hack-explained/). The attacker was able to steal crypto-assets worth $324 million because of just a missing check. This is again a costly lesson for all blockchain developers, especially for Solana program developers.

Timeline

  • 2021.10.20 06:01: Solana commit to deprecate load_instruction_at.
  • 2022.01.13 14:29: Wormhole commit to update to Solana to 1.9.4.
  • 2022.02.02 17:31: Pull request of the Solana update commit.
  • 2022.02.02 18:24: Transaction to mint 120000 wormhole ETH on Solana.
  • 2022.02.02 18:28: Transaction to pull out 80000 wETH from wormhole smart contract.

Previous analysis

Our analysis tried to summarize and give a bit of context of the previous analysis reported during the first hours of the hack:

Post written by: Tuyet Duong and Sylvain Pelissier

Leave a Reply