How a Missing Account Check Cost Wormhole $326M
On February 2, 2022, the Wormhole token bridge on Solana was drained of 120,000 wETH - roughly $326 million at the time. It was the second-largest DeFi hack ever recorded. The attacker did not break any cryptography. They did not find a flaw in the secp256k1 signature scheme. They exploited something far simpler: the program accepted an account it was told was the instruction sysvar without ever verifying that it actually was.
The entire attack came down to a single missing check - an account passed by the user was never validated against the known, on-chain sysvar address. The attacker crafted a fake account, stuffed it with forged instruction data, and the Wormhole program read it without complaint. From there, minting 120,000 wETH against zero collateral was straightforward.
This is a pattern RedPen sees regularly in Solana programs: user-supplied accounts treated as trusted system accounts. If you are writing cross-program logic that touches sysvars, guardian sets, or any reference-only account, read this post carefully. Then read your own account validation code.
What Happened - Timeline
Wormhole is a cross-chain bridge. To move assets from Ethereum to Solana, users lock ETH in an Ethereum contract. A set of off-chain validators called Guardians observe the lock event and collectively sign a Validator Action Approval (VAA) - a message authorizing the Solana-side program to mint wrapped tokens. The Solana program verifies the guardian signatures, creates a signature_set account marking which guardians signed, then post_vaa checks the signature_set for quorum before executing the mint.
On October 20, 2021 - three and a half months before the attack - a deprecated function called `load_instruction_at` was marked unsafe in the Solana SDK. The replacement, `get_instruction_relative`, verifies that the instruction sysvar account is the real one. Wormhole never updated. The vulnerable code sat in production.
On February 2, 2022 at approximately 18:24 UTC, the attacker submitted a transaction calling `verify_signatures` with a fake account in the instruction sysvar slot. The fake account contained fabricated instruction data that made it look like a valid secp256k1 verification had already run. The program read it, marked all guardian slots as signed, and produced a valid-looking signature_set. The attacker then called `post_vaa`, which checked the signature_set, saw quorum, and accepted the forged VAA. Within minutes, `complete_wrapped` minted 120,000 wETH to the attacker's wallet. They bridged it out to Ethereum across three transactions before anyone noticed.
The Vulnerability - Technical Deep Dive
The Solana runtime exposes a sysvar called the Instructions Sysvar. It lets a running instruction inspect other instructions in the same transaction - useful for enforcing that a secp256k1 verify instruction ran before your program instruction.
Wormhole's `verify_signatures` instruction used this sysvar to confirm that secp256k1 signature checks had been performed for each guardian. The account struct for `VerifySignatures` accepted an `instruction_acc` field of type `Info` - a generic, unchecked account reference. The code then called `load_instruction_at` using the data from whatever account the caller passed in. No check that the account's address matched `sysvar::instructions::ID`. No check that the account was owned by the sysvar program. Just raw data reads.
This is the core of the bug. In Solana, any account can be passed to any instruction. The program is responsible for verifying that an account is what it claims to be. The system does not automatically reject an impersonator. When `load_instruction_at` was deprecated in favor of `get_instruction_relative`, the reason was exactly this: the old function did not validate the account address, so an attacker could pass arbitrary data as if it were instruction history.
With a controlled `instruction_acc`, the attacker fabricated an instruction entry that looked exactly like a successful secp256k1 batch verify. The `verify_signatures` logic then iterated the `sig_infos`, matched the (fabricated) addresses against the guardian set, and set `signature_set.signatures[n] = true` for each guardian index. The signature_set account was now fully populated - all guardians appeared to have signed.
// VULNERABLE - instruction_acc is Info<'b> with no address validation
// The caller can pass any account here. load_instruction_at does not
// check that instruction_acc.key == sysvar::instructions::ID
pub struct VerifySignatures<'b> {
pub payer: Mut<Signer<Info<'b>>>,
pub guardian_set: GuardianSet<'b, { AccountState::Initialized }>,
pub signature_set: Mut<Signer<SignatureSet<'b, { AccountState::MaybeInitialized }>>>,
/// BUG: generic Info<'b> - no owner check, no address check
pub instruction_acc: Info<'b>,
}
pub fn verify_signatures(
ctx: &ExecutionContext,
accs: &mut VerifySignatures,
data: VerifySignaturesData,
) -> Result<()> {
// BUG: load_instruction_at reads from whatever account was passed in.
// The deprecated function does NOT verify instruction_acc.key
// matches sysvar::instructions::ID.
let secp_ix = solana_program::sysvar::instructions::load_instruction_at(
data.secp_ix_index as usize,
&accs.instruction_acc.try_borrow_mut_data()?,
)?;
// Attacker-controlled secp_ix now contains fabricated guardian addresses.
// The loop below marks all guardian slots as signed.
for s in &data.signers {
let key = accs.guardian_set.keys[s.guardian_index as usize];
if key != secp_ix.accounts[s.sig_index as usize].pubkey {
return Err(ProgramError::InvalidArgument.into());
}
// Marks guardian as having signed - based on FAKE data
accs.signature_set.signatures[s.guardian_index as usize] = true;
}
Ok(())
}
The Attack Step by Step
Step 1: The attacker created a new account on Solana and populated it with hand-crafted instruction sysvar data. The data was formatted to look like a secp256k1 verify instruction had run for the target VAA hash, with guardian addresses copied from the live guardian set.
Step 2: The attacker called `verify_signatures` on the Wormhole program, passing their fake account as `instruction_acc`. The program called `load_instruction_at(secp_ix_index, &accs.instruction_acc.try_borrow_mut_data()?)` - reading from the attacker-controlled account instead of the real sysvar.
Step 3: The fabricated instruction data passed all the guardian key checks. The loop set `signature_set.signatures[i] = true` for every slot. The signature_set account was persisted on-chain, fully signed.
Step 4: The attacker called `post_vaa` with the poisoned signature_set. The quorum check counted all-true signatures and passed. The VAA was marked as posted.
Step 5: The attacker called `complete_wrapped` with a VAA authorizing a mint of 120,000 wETH to their address. The program executed the mint. 120,000 wETH appeared in the attacker's wallet - collateralized by nothing.
Step 6: Three separate transactions moved the wETH across the bridge to Ethereum. Total haul: approximately $326 million. Total novel cryptography broken: zero.
How It Should Have Been Written
The fix is one line - verify that the account passed as the instruction sysvar is actually the instruction sysvar. The Solana SDK provides `sysvar::instructions::check_id()` and `get_instruction_relative` does this automatically.
In the account struct, `instruction_acc` should have been validated against the known sysvar address before any data was read from it. In modern Anchor programs, this is even cleaner - you can use the `AccountInfo` constraint with a specific address check, or use `UncheckedAccount` with an explicit `require_keys_eq!` guard.
The patched approach also replaces `load_instruction_at` with `get_instruction_relative` which internally verifies the account key against `sysvar::instructions::ID`. No valid instruction data can be fabricated because the sysvar is read-only and controlled entirely by the runtime.
Anchor programs have a structural advantage here: when you type an account as `Sysvar<Instructions>` in your account context, Anchor enforces the address check automatically. You cannot accidentally pass the wrong account - the framework rejects it before your handler runs. For any program that depends on instruction introspection, this is the right pattern.
// FIXED (raw Solana) - explicitly check the account key before reading
use solana_program::sysvar::instructions;
pub struct VerifySignatures<'b> {
pub payer: Mut<Signer<Info<'b>>>,
pub guardian_set: GuardianSet<'b, { AccountState::Initialized }>,
pub signature_set: Mut<Signer<SignatureSet<'b, { AccountState::MaybeInitialized }>>>,
/// Still Info<'b> but we validate the key before use
pub instruction_acc: Info<'b>,
}
pub fn verify_signatures(
ctx: &ExecutionContext,
accs: &mut VerifySignatures,
data: VerifySignaturesData,
) -> Result<()> {
// FIX 1: Verify the account is actually the instructions sysvar
if accs.instruction_acc.key != &instructions::ID {
return Err(ProgramError::InvalidArgument.into());
}
// FIX 2: Use get_instruction_relative instead of the deprecated
// load_instruction_at. This function internally checks the sysvar key,
// providing defense-in-depth.
let secp_ix = instructions::get_instruction_relative(
data.secp_ix_index as i64,
&accs.instruction_acc,
)?;
for s in &data.signers {
let key = accs.guardian_set.keys[s.guardian_index as usize];
if key != secp_ix.accounts[s.sig_index as usize].pubkey {
return Err(ProgramError::InvalidArgument.into());
}
accs.signature_set.signatures[s.guardian_index as usize] = true;
}
Ok(())
}
// ----------------------------------------------------------------
// MODERN ANCHOR EQUIVALENT
// Use typed sysvars - Anchor enforces the address check automatically.
// You cannot pass a fake account; the framework rejects it on entry.
// ----------------------------------------------------------------
use anchor_lang::prelude::*;
#[derive(Accounts)]
pub struct VerifySignaturesAnchor<'info> {
#[account(mut)]
pub payer: Signer<'info>,
pub guardian_set: Account<'info, GuardianSet>,
#[account(mut)]
pub signature_set: Account<'info, SignatureSet>,
// Anchor validates this is the real instructions sysvar.
// No attacker-supplied fake account can pass this check.
pub instruction_sysvar: Sysvar<'info, Instructions>,
}
pub fn verify_signatures_anchor(
ctx: Context<VerifySignaturesAnchor>,
data: VerifySignaturesData,
) -> Result<()> {
// Safe to use - Anchor already verified the sysvar address
let secp_ix = get_instruction_relative(data.secp_ix_index as i64, &ctx.accounts.instruction_sysvar)?;
for s in &data.signers {
let key = ctx.accounts.guardian_set.keys[s.guardian_index as usize];
require_keys_eq!(
key,
secp_ix.accounts[s.sig_index as usize].pubkey,
WormholeError::InvalidSignature
);
ctx.accounts.signature_set.signatures[s.guardian_index as usize] = true;
}
Ok(())
}
What This Means for Your Program
The Wormhole bug is a specific instance of a general class: trusting user-supplied accounts without verification. Every time a user passes an account to your program, you need to verify it is the account you expect. This applies to:
Sysvar accounts (Instructions, Clock, Rent, SlotHashes) - always check the address or use typed Anchor sysvars. Program accounts - verify the owner matches the expected program ID. PDA accounts - verify the seeds and bump produce the address. Configuration accounts - verify they are owned by your program and their discriminator matches.
In practice, the question to ask about every account in your instruction context is: what stops an attacker from passing a different account here? If the answer is nothing, you have a vulnerability. Anchor's account types - `Sysvar<T>`, `Program<T>`, `Account<T>` with discriminator checks - exist specifically to enforce these invariants at the framework level so you do not have to write the checks by hand every time.
Bridge programs, oracle integrations, and any multi-instruction protocol that uses the Instructions Sysvar for CPI or secp256k1 pre-verification are the highest-risk category. If your program calls `load_instruction_at` anywhere, replace it with `get_instruction_relative` today. If you are using raw `AccountInfo` for anything that should be a typed sysvar, add the address check.
RedPen Takeaway
During every audit, RedPen explicitly checks for unvalidated reference-only accounts - accounts that a program reads from but does not own or modify. These are the highest-risk account category because the program implicitly trusts their contents, but nothing in the runtime prevents an attacker from passing a crafted substitute. We check every `Info<'b>` and `AccountInfo` in instruction contexts and trace how the account data is used downstream. If the program reads instruction history, oracle prices, clock data, or any external state from an account, we verify there is a hard address check or a typed Anchor constraint enforcing it before that data influences any state changes or authorization decisions. We also flag deprecated SDK functions during static analysis. `load_instruction_at` was deprecated specifically because it enabled this attack class. Any use of it is a finding. More broadly, we look for patterns where multi-step authorization - 'instruction A must have run before instruction B' - is enforced via user-supplied account data rather than runtime-controlled state. If your protocol uses secp256k1 pre-verification, instruction introspection, or guardian signature sets, those flows get deep manual review on every engagement.
Does your program validate every account it reads from?
RedPen audits Anchor programs for exactly this class of vulnerability. Get your program reviewed before mainnet.