The Wormhole Bridge Exploit: Forged Signatures and $320 Million

On February 2, 2022, an attacker minted 120,000 wETH on Solana without depositing anything on Ethereum. The entire signature verification mechanism that was supposed to prevent this was bypassed by passing a fake account where Wormhole’s code expected a trusted system account. The stolen tokens were worth over $320 million.

The root cause was a deprecated function that, unlike its replacement, didn’t verify that the account it was reading from was actually the account it claimed to be.

How Wormhole’s Verification Was Supposed to Work

Wormhole moves assets between blockchains through a network of 19 guardian validators. To mint tokens on a destination chain, a user needs a signed VAA (Verified Action Approval)—a message signed by at least 13 of 19 guardians attesting that the corresponding funds were locked on the source chain.

On Solana, verifying guardian signatures required two steps in sequence. First, Solana’s native Secp256k1Program would run ECDSA signature verification. Second, Wormhole’s own verify_signatures instruction would confirm that the Secp256k1Program had actually run in the same transaction by inspecting the transaction’s instruction history through the Instructions sysvar—a special Solana system account that records all instructions executed within a transaction.

// Step 1: Solana's native signature verification (must run first)
Secp256k1Program::verify_signatures(guardian_signatures, message_hash);

// Step 2: Wormhole checks that Step 1 actually ran
pub fn verify_signatures(ctx: Context, data: SignatureData) {
    let secp_ix = load_instruction_at(
        data.secp_instruction_index,
        &ctx.accounts.instruction_sysvar  // Should be the real Instructions sysvar
    );

    require!(secp_ix.program_id == SECP256K1_PROGRAM_ID);
    // Extract verified signatures and store them...
}

This design is sound in principle. The problem was in the implementation of Step 2.

The Vulnerable Function

load_instruction_at was deprecated in Solana SDK 1.8.0. Its replacement, load_instruction_at_checked, was introduced specifically to fix a security property: the old version did not verify that the account passed as instruction_sysvar was actually the real Instructions sysvar.

Wormhole was using Solana SDK 1.7.0 and the unchecked variant. The deprecation notice in the SDK read:

#[deprecated(
    since = "1.8.0",
    note = "Unsafe function, use load_instruction_at_checked instead"
)]
pub fn load_instruction_at(index: usize, account: &AccountInfo) -> Result<Instruction>

An attacker who knew this could provide any account in place of the real Instructions sysvar. Wormhole would read from it, find instruction data claiming the Secp256k1 verification had occurred, and proceed as though it had.

The Exploit

The attacker created a fake account containing fabricated instruction history. This fake account claimed that a valid Secp256k1 signature check had occurred at instruction index 0. In reality, no such check had run.

// What the attacker's transaction looked like:

// 1. No Secp256k1 verification — never called
// 2. Call verify_signatures with the fake account
verify_signatures(
    Context {
        instruction_sysvar: ATTACKER_FAKE_ACCOUNT,  // Not the real sysvar
        ...
    },
    SignatureData {
        secp_instruction_index: 0,
        // Fake account claims index 0 was valid Secp256k1
    }
);
// Returns: a SignatureSet marked as verified

// 3. Call complete_wrapped with a malicious VAA
complete_wrapped(
    vaa: VAA { amount: 120_000 ETH, recipient: ATTACKER },
    signature_set: FAKE_SIGNATURE_SET
);
// Result: 120,000 wETH minted to attacker

The fix requires one line:

// Vulnerable: reads any account without checking its address
let secp_ix = load_instruction_at(index, &ctx.accounts.instruction_sysvar);

// Fixed: verifies the account is the real Instructions sysvar
let secp_ix = load_instruction_at_checked(index, &ctx.accounts.instruction_sysvar)?;

Or equivalently with an explicit address check:

require!(
    ctx.accounts.instruction_sysvar.key() == sysvar::instructions::ID,
    ErrorCode::InvalidSysvar
);

Modern Anchor-based Solana programs handle this automatically with account constraints:

#[derive(Accounts)]
pub struct VerifySignatures<'info> {
    #[account(address = sysvar::instructions::ID)]
    pub instruction_sysvar: AccountInfo<'info>,
}

After the Attack

Within 24 hours, Jump Crypto—Wormhole’s parent company—deposited 120,000 ETH to make the bridge whole. Users were not left with losses. The bailout cost Jump approximately $320 million.

In February 2023, a year after the exploit, Jump Crypto and Oasis (an unrelated DeFi protocol) executed a counter-operation. They obtained a court order from the UK High Court authorizing use of an Oasis upgrade mechanism to reach into the attacker’s MakerDAO vault and extract approximately $140 million. The funds were transferred to Jump.

This recovery was notable for two reasons: it demonstrated that a determined legal and technical effort can recover DeFi proceeds, and it demonstrated that “immutable” DeFi infrastructure can sometimes be modified through legal pressure and protocol upgrade mechanisms that weren’t supposed to exist. The Oasis team acknowledged they had an admin key that enabled the intervention—something users of Oasis had not been told.

The attacker’s identity has not been publicly confirmed. They initially ignored a $10 million bug bounty offer that Wormhole embedded in an on-chain message.

The Pattern: Unvalidated System Accounts

This class of vulnerability—trusting a caller-provided account to be what it claims—appears repeatedly in Solana programs. System accounts like the Instructions sysvar, the Clock sysvar, and the Rent sysvar all have fixed, known addresses. A program that reads from any of these without verifying the address can be fed attacker-controlled data.

The Wormhole attack is the most expensive example of this failure, but the pattern is not unique to Wormhole. Any Solana program that reads a sysvar without validating the account address is susceptible to the same class of attack.

The general rule: if a program account has a known, fixed address, always verify it. Treat caller-supplied accounts as untrusted input until proven otherwise.


References