Case Study — Wormhole Bridge Exploit (February 2022)

“A 19-validator multisig is only as strong as the verifier on the chain with the weakest check. The Guardians didn’t lie. The contract simply forgot to ask for proof that they had spoken.”

Tags: case-study bridge cross-chain wormhole solana signature vulnerability anti-pattern non-evm Related: Tuan-10-Bridge-Cross-Chain-Security · Tuan-05-Vulnerability-Classes-Part-1 · Tuan-Bonus-Non-EVM-Solana · Case-Poly-Network-2021 · Case-Ronin-Bridge-2022 · Case-Nomad-Bridge-2022 Status: Reference case for on-chain signature-verification-path bugs and account-confusion on Solana.


1. At a glance

FieldValue
DateFebruary 2, 2022 — exploit mint 18:24 UTC; bridge to Ethereum 18:28 UTC
PatchedFebruary 3, 2022 — Solana program upgrade at 00:32 UTC; bridge re-opened 13:29 UTC
ProtocolWormhole “Portal” Token Bridge — Solana ↔ Ethereum lock-and-mint
Asset modelLock-and-mint (ETH locked on Ethereum, wETH minted on Solana)
Trust model19 Guardians (off-chain validators); 13-of-19 multisig over a Verifiable Action Approval (VAA)
Loss120,000 wETH — approximately **2,710)
Attack classSignature-verification bypass via account confusion on the Solana side. Forged SignatureSet account without any real Guardian signatures.
Root functionSecp256k1::verify_signatures using deprecated load_instruction_at instead of load_instruction_at_checked
Fix commite8b91810a9bb35c3c139f86b4d0795432d647305 (wormhole-foundation/wormhole) — added validation that the supplied account equals solana_program::sysvar::instructions::ID
Critical timingPatch was pushed to public GitHub hours before mainnet deployment. The attacker is widely believed to have spotted the diff in the public commit and raced the deployment.
RecoveryJump Crypto (Wormhole’s anchor backer at the time) replenished the bridge with 120,000 ETH within ~16 hours to keep the wrapped-asset solvent.
Attacker Ethereum0x629e7Da20197a5429d30da36E77d06CdF796b71A — laundered via stETH leverage loops; never returned funds [verify]
Attacker Solana fake-sysvar account2tHS1cXX2h1KBEaadprqELJ6sV9wLoaSdX68FqsrrZRd — the counterfeit instruction-sysvar account
Exploit Solana signature2zCz2GgSoSS68eNJENWrYB48dMM1zmH8SZkgYneVDv2G4gRsVfwu5rNXtK5BKFxn7fSqX9BvrBc1rdPAeBEcD6Es [verify]
Cumulative rankingAt the time, the 2nd-largest DeFi exploit in history (after Poly Network). Today it remains in the top 5 by absolute USD value.

2. Background — what Wormhole was and how it worked

2.1 The protocol in one paragraph

Wormhole is a generic cross-chain message-passing protocol whose flagship application is the Portal token bridge. To move ETH from Ethereum to Solana, a user (1) calls transferTokens on the Wormhole core bridge contract on Ethereum, which locks the ETH and emits an event; (2) the Guardian Network — at the time, 19 known validator organizations — observes the event, each signs the canonical event hash with its secp256k1 key, and gossips the signed observation; (3) the user (or a relayer) collects 13-of-19 signatures into a VAA (Verifiable Action Approval) and submits it to the Wormhole core program on Solana, which verifies the signatures and emits a “posted message”; (4) the Portal token program on Solana consumes the posted message and mints 1:1 wrapped wETH to the recipient. The reverse direction burns wETH on Solana and unlocks ETH on Ethereum.

2.2 The two-program structure on Solana

flowchart LR
  E[Ethereum<br/>core bridge contract] -->|emit LogMessagePublished| G[Guardian Network<br/>19 validators]
  G -->|13 secp256k1 signatures| VAA[Signed VAA]
  VAA -->|tx 1: verify_signatures| Core[Solana<br/>Wormhole CORE program]
  VAA -->|tx 2: post_vaa| Core
  Core -->|writes| SS[(SignatureSet account)]
  Core -->|writes| PM[(PostedMessage account)]
  PM -->|tx 3: complete_wrapped| Portal[Solana<br/>Wormhole PORTAL program]
  Portal -->|mint wETH| User[User]

The flow on the destination chain is split into three transactions for two practical reasons:

  1. Solana transactions have a hard 1232-byte size limit. Thirteen 65-byte signatures plus the VAA body do not fit alongside the mint logic.
  2. Solana’s secp256k1 signature verification is implemented as a precompile, not callable from inside a BPF program directly. To verify a signature, you put a Secp256k1Program instruction next to your program’s instruction in the same transaction, and your program reads the result via the Sysvar::Instructions account — Solana’s introspection sysvar that lets a program see sibling instructions in the same transaction.

This split is the seed of the bug. Step 1 builds a SignatureSet PDA holding “which Guardians’ signatures verified”. Step 2 reads that SignatureSet and writes the PostedMessage. Step 3 consumes the PostedMessage to mint. If step 1 can be tricked into writing an attacker-controlled SignatureSet that claims all Guardians signed, steps 2 and 3 dutifully follow, because by then the cryptographic check is already “done”.

2.3 The Guardian set as a multisig

Auditor framing — by the numbers:

PropertyValue
Set size19
Threshold13 (≈ 2/3 + 1)
IdentityPublic list of 19 organizations (Jump, Certus One, Chorus One, Everstake, Staked, etc.) [verify]
SlashingNone — reputation-only
RotationSelf-update via a special VAA signed by the existing set

Compromise modes (these are not what happened, but the auditor must enumerate them):

  • Collusion: 13 of 19 Guardians sign a fake VAA.
  • Key compromise: 13 keys stolen.
  • Software uniformity: a single bug in the Guardian client makes all 19 sign the same bad observation.
  • Verifier bug on any supported chain: a contract bug lets a forged VAA pass without any Guardian actually signing. ← this is what happened.

The verifier-bug mode is the most insidious because set diversity offers zero protection against it. The 19 Guardians could all have been geographically distributed monks meditating in Faraday cages; the on-chain contract still would have minted.


3. The vulnerability

3.1 What Solana’s Sysvar::Instructions is supposed to be

Solana programs cannot dynamically call cryptographic precompiles. Instead, the precompile (e.g., Secp256k1Program) is invoked as a separate top-level instruction inside the same transaction, and the result is communicated to the BPF program by letting the BPF program read the transaction’s own instruction list through a magic account called Sysvar1nstructions1111111111111111111111111 — the instructions sysvar.

Critically: this is just an account. From the runtime’s perspective, “is this the real sysvar?” is a property of the account’s public key, not its data. A Solana program is given a list of accounts the user supplies; the program is responsible for asserting that the right account was supplied at the right index. The runtime will not do this for you.

The standard pattern for “did the user invoke Secp256k1 verification earlier in this transaction?” is:

let ix = solana_program::sysvar::instructions::load_instruction_at_checked(
    secp_ix_index,
    &instruction_sysvar_account_info,
)?;
require!(ix.program_id == SECP256K1_PROGRAM_ID, ...);
// inspect ix.data to find which message+pubkey was verified

The _checked variant also verifies that instruction_sysvar_account_info.key == solana_program::sysvar::instructions::ID. Without that check, the function will happily deserialize any account’s data as if it were the instructions sysvar.

3.2 The vulnerable code

In the Wormhole core program’s signature verification path, the relevant call was — in essence — this:

// VULNERABLE (pre-patch). Wormhole core, verify_signatures handler.
pub fn verify_signatures(
    accs: &VerifySignatures,
    data: VerifySignaturesData,
) -> Result<()> {
    // Read the "secp_ix_index"-th instruction from what the user TOLD US
    // is the instructions sysvar.
    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
    )?;
 
    // ... interpret secp_ix as a Secp256k1Program invocation, build a
    // SignatureSet from "which signatures it verified" ...
    require!(secp_ix.program_id == SECP256K1_PROGRAM_ID, ...);
 
    // The SignatureSet account is written with all 19 entries flipped to
    // "verified" if the precompile data says so.
    let mut sig_set = SignatureSet::from(...);
    for (i, sig) in sigs.iter().enumerate() {
        sig_set.signatures[i] = true;
    }
    sig_set.save(...)?;
    Ok(())
}

The bug is on the first line: load_instruction_at (deprecated since solana-program 1.8.0) does not check that accs.instruction_acc.key == sysvar::instructions::ID. It reads the raw data of whatever account the user passed in the instruction_acc slot and parses it as the well-known sysvar layout.

3.3 The patch

The fix landed in commit e8b91810. It is a two-line change [verify]:

// PATCHED.
pub fn verify_signatures(
    accs: &VerifySignatures,
    data: VerifySignaturesData,
) -> Result<()> {
    // (1) explicit account-identity assertion
    require!(
        *accs.instruction_acc.key == solana_program::sysvar::instructions::ID,
        WormholeError::InvalidSysvar
    );
 
    // (2) and/or use the checked variant which does the same internally
    let secp_ix = solana_program::sysvar::instructions::load_instruction_at_checked(
        data.secp_ix_index as usize,
        &accs.instruction_acc,
    )?;
    // ... rest unchanged
}

Either of those two changes alone is sufficient. The total fix is fewer than a dozen lines. The total loss the fix would have prevented was $325 million. This is the unit economics of bridge auditing: one missing account-identity check ≈ a treasury.

3.4 Why this is “account confusion”

In EVM, every state-changing call has a singular address(this) — your contract — and the runtime guarantees msg.sender, the call data, and the contract’s storage are not impersonable. Solana is different. All program state lives in user-supplied accounts, and the runtime cannot tell you “the account named X you wanted is the account at index 4 of this instruction” — you must check the public key.

The class of bugs where a program trusts the contents of an account it has not first verified by public key or by owner program-id is called account confusion (also sometimes “type confusion across accounts”). It is the Solana analogue of EVM’s delegatecall storage collisions: a misalignment between the program’s mental model of the world and what the runtime is actually willing to enforce.

Three sub-classes:

  1. Missing key check — Wormhole’s bug. The account is supposed to be a specific singleton (sysvar) but no check confirms it.
  2. Missing owner check — the account is supposed to be owned by program X (so only program X could have created it) but no check confirms it. Attacker hands in an account they own.
  3. Missing discriminator / type check — Anchor’s #[account] adds a leading 8-byte discriminator so deserializing the wrong account type fails. Without Anchor, you have to check the layout manually.

The Wormhole bug is class 1: a sysvar pubkey check that was skipped because the deprecated helper didn’t include it. The lesson is identical for all three classes: on Solana, you do not have a contract — you have a function that operates on whatever accounts the caller supplies. Every account read is a trust decision.

3.5 What the attacker actually did, step by step

  1. Create the fake sysvar. The attacker created a normal Solana account at address 2tHS1cXX2h1KBEaadprqELJ6sV9wLoaSdX68FqsrrZRd and wrote into its data the byte layout that the instructions-sysvar uses: a count of instructions, plus a serialized Secp256k1Program invocation whose data field claims that 13 well-formed signatures over the attacker’s chosen VAA payload have been verified against the 19 Guardian public keys.

    This is possible because a sysvar account is just bytes. There is nothing magical preventing another account from holding identical-looking bytes. The only thing that makes the real sysvar special is that its key is Sysvar1nstructions1111111111111111111111111. The attacker simply doesn’t have that key.

  2. Build a custom VAA. The VAA payload was a TransferWithPayload for 120,000 wETH to a Solana account the attacker controlled, claiming origin from Ethereum.

  3. Submit verify_signatures with the fake sysvar in the instruction_acc slot. The vulnerable load_instruction_at reads the fake bytes, sees what looks like a successful secp256k1 verification of the VAA hash, and writes a SignatureSet PDA flagging all sigs as verified. No real Guardian signed anything. This is the breach.

  4. Submit post_vaa with the (now-blessed) SignatureSet. The core program writes a PostedMessage account treating the VAA as fully-verified.

  5. Submit complete_wrapped_with_payload to the Portal token program with the PostedMessage. Portal mints 120,000 wETH on Solana to the attacker.

  6. Bridge back. Within four minutes (18:28 UTC), the attacker bridged 93,750 wETH back to Ethereum via Wormhole. This converted Solana-side fake wETH into Ethereum-side real ETH released from the Ethereum-side lock contract.

    This is the killer move: because the Solana side falsely believed real Guardian signatures existed, the symmetric “burn-on-Solana, unlock-on-Ethereum” flow produced a real unlock on Ethereum. The Ethereum lock contract had no idea anything was wrong — from its viewpoint, a normal user had simply chosen to send their wrapped tokens back home.

  7. Launder. The 93,750 ETH was staked as stETH, used as collateral to borrow DAI, the DAI swapped to ETH, more stETH bought — a leveraged-staking loop, holding rather than mixing. Funds were never returned despite a public $10M whitehat-return bounty offered by Wormhole.


4. Why the timing matters — the public-patch race

The patch hit Wormhole’s public GitHub at roughly 09:00 UTC, February 2, 2022 [verify]. The exploit ran at 18:24 UTC the same day. The bridge had been deployed for hours with the bug visible in a public diff.

Lesson for security operations: commits to security-critical verifier paths must be coordinated:

  • Either deploy before merging, with a private branch and a coordinator who pushes deploy + merge atomically.
  • Or merge and publish behind a feature flag or staged rollout that doesn’t disclose the unfixed mainnet binary.
  • Or accept the disclosure window and make the merge message describe a benign refactor, with the security-relevant patch landing in an opaque follow-up [debatable].

This bug was the canonical example for the policy now seen across major bridge teams: no public commits to verifier code on the production branch until the production binary is upgraded. Tuan-10-Bridge-Cross-Chain-Security §7.2 references this as the “operational lesson” of Wormhole.


5. The attack flow — diagram

sequenceDiagram
  autonumber
  participant A as Attacker
  participant FS as Fake Sysvar<br/>(attacker-owned account<br/>2tHS1cXX...)
  participant CORE as Wormhole CORE<br/>Solana program
  participant SS as SignatureSet PDA
  participant PORTAL as Wormhole PORTAL<br/>Solana program
  participant ETH as Ethereum<br/>Lock contract

  A->>FS: write bytes mimicking Sysvar:Instructions<br/>(claims secp256k1 verified 13 Guardian sigs)
  A->>CORE: verify_signatures(VAA, instruction_acc = FS)
  Note over CORE: load_instruction_at(FS)<br/>**no key check**<br/>parses FS bytes as sysvar
  CORE->>SS: write {all 13 sigs verified=true}
  A->>CORE: post_vaa(VAA, sig_set = SS)
  CORE->>CORE: write PostedMessage<br/>(treats VAA as fully verified)
  A->>PORTAL: complete_wrapped_with_payload(PostedMessage)
  PORTAL->>A: mint 120,000 wETH on Solana
  A->>PORTAL: transferTokens(120k wETH, dest=Ethereum)
  PORTAL->>CORE: emit Burn observation
  Note over CORE: Real Guardians sign the Burn observation<br/>(this part is honest)
  CORE-->>ETH: signed VAA for 93,750 ETH unlock
  ETH->>A: unlock 93,750 ETH
  Note over A,ETH: Ethereum lock contract sees a normal<br/>burn-and-unlock — has no way to know<br/>the Solana mint was fraudulent.

The critical line is step (3): the runtime cannot tell that the account labeled instruction_acc is not the real instructions sysvar, because the program never asks it to.


6. Reproduction lab — Foundry PoC of the “signature check that defaults true” pattern

6.1 Why we don’t reproduce the Solana program

The on-chain reproduction would require:

  • A local Solana validator (solana-test-validator),
  • Anchor or raw Rust BPF compilation of a vulnerable program mirroring Wormhole’s structure,
  • A Rust client to craft the malicious sysvar account.

That’s a worthwhile exercise after Tuan-Bonus-Non-EVM-Solana (a full Anchor PoC is planned there). For this case study, the auditor lesson — “a signature-verification path can silently default to ‘verified’ if its inputs are not constrained” — generalizes cleanly to EVM. We’ll reproduce that pattern in Foundry.

The pattern: a contract that asks “did the message pass signature verification?” via an external, attacker-influenceable channel, without binding the channel’s identity. In EVM this often looks like:

  • “Did you call verify() on the registry first?” with the registry being a parameter the attacker chooses.
  • “Is the verifier whitelist non-empty?” with no check that the whitelisted verifier is genuine.
  • “Does this calldata, when parsed as a (bool, bytes) tuple, claim valid=true?” with no signature actually checked.

We’ll build the third — the most direct EVM analogue of Wormhole’s “fake sysvar”.

6.2 The vulnerable contract

// SPDX-License-Identifier: MIT
// src/VulnVAABridge.sol
pragma solidity ^0.8.20;
 
/// @title VulnVAABridge
/// @notice A toy bridge whose `redeem` accepts a "VAA" and queries an
///         external verifier contract for signature validity. The bug:
///         the caller supplies the verifier address, and the verifier
///         is trusted to tell the truth. This mirrors Wormhole's Solana
///         bug where the program trusted whatever account was supplied
///         in the `instruction_acc` slot to be the real sysvar.
contract VulnVAABridge {
    address public immutable wToken;          // the wrapped token to mint
    mapping(bytes32 => bool) public consumed; // VAA-hash anti-replay
 
    constructor(address _wToken) { wToken = _wToken; }
 
    /// @notice Redeem a VAA to mint wrapped tokens.
    /// @param verifier  Address of the signature-verifier registry.
    ///                  ★ BUG: caller-supplied, no allowlist ★
    /// @param vaa       The VAA blob.
    /// @param sigs      The packed Guardian signatures.
    function redeem(
        address verifier,
        bytes calldata vaa,
        bytes calldata sigs
    ) external {
        bytes32 vaaHash = keccak256(vaa);
        require(!consumed[vaaHash], "replay");
 
        // ★ The fatal trust step ★
        // We delegate the entire signature-verification decision to a
        // contract the *caller* hands us. If the caller hands a contract
        // they own that always returns true, we mint.
        bool ok = ISignatureVerifier(verifier).verifyVAA(vaaHash, sigs);
        require(ok, "bad sigs");
 
        consumed[vaaHash] = true;
 
        // Decode recipient + amount from the VAA payload.
        (address recipient, uint256 amount) = abi.decode(vaa, (address, uint256));
        IWrappedToken(wToken).mint(recipient, amount);
    }
}
 
interface ISignatureVerifier {
    function verifyVAA(bytes32 vaaHash, bytes calldata sigs) external view returns (bool);
}
 
interface IWrappedToken {
    function mint(address to, uint256 amount) external;
}

The structural twin of Wormhole’s bug: verifier is the “instruction sysvar” — the source of truth for whether signatures passed — and the caller picks which one we trust.

6.3 The attacker’s fake verifier (the “fake sysvar”)

// SPDX-License-Identifier: MIT
// src/EvilVerifier.sol
pragma solidity ^0.8.20;
 
contract EvilVerifier {
    // Always reports "valid signatures". This is the EVM analogue of
    // an attacker-crafted account whose bytes claim secp256k1 verified.
    function verifyVAA(bytes32, bytes calldata) external pure returns (bool) {
        return true;
    }
}

That’s it. Two lines of logic. Nothing in the calling contract prevents the bridge from accepting this verifier.

6.4 The exploit test

// SPDX-License-Identifier: MIT
// test/VulnVAABridge.t.sol
pragma solidity ^0.8.20;
 
import "forge-std/Test.sol";
import {VulnVAABridge} from "../src/VulnVAABridge.sol";
import {EvilVerifier} from "../src/EvilVerifier.sol";
 
contract WrappedToken {
    string  public constant name = "wETH";
    string  public constant symbol = "wETH";
    uint8   public constant decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    address public bridge;
 
    constructor(address _bridge) { bridge = _bridge; }
    modifier onlyBridge() { require(msg.sender == bridge); _; }
 
    function mint(address to, uint256 amount) external onlyBridge {
        totalSupply += amount;
        balanceOf[to] += amount;
    }
}
 
contract VulnVAABridgeTest is Test {
    VulnVAABridge bridge;
    WrappedToken  wEth;
    EvilVerifier  evil;
    address       attacker = address(0xBEEF);
 
    function setUp() public {
        // Deploy a wToken bound to a not-yet-known bridge: we trick the
        // chicken-and-egg by predicting the bridge address.
        bytes32 salt = bytes32(uint256(1));
        address predictedBridge = vm.computeCreate2Address(
            salt,
            keccak256(type(VulnVAABridge).creationCode),
            address(this)
        );
        wEth = new WrappedToken(predictedBridge);
        bridge = new VulnVAABridge{salt: salt}(address(wEth)); // matches prediction
        evil = new EvilVerifier();
    }
 
    function test_exploit_fakeVerifier_mints_120k_wETH() public {
        // The "VAA" payload: (recipient = attacker, amount = 120,000 ETH).
        // In real Wormhole this would be a fully-formed VAA struct with
        // header + signatures + body; the body decode here is intentionally
        // simplified to match VulnVAABridge.redeem's decode.
        uint256 stolen = 120_000 ether;
        bytes memory vaa = abi.encode(attacker, stolen);
 
        // No real Guardian signed. Attacker hands the bridge an EvilVerifier
        // that returns true unconditionally.
        vm.prank(attacker);
        bridge.redeem(address(evil), vaa, bytes(""));
 
        // Treasury is drained from nothing.
        assertEq(wEth.balanceOf(attacker), stolen);
        assertEq(wEth.totalSupply(), stolen);
 
        emit log_named_decimal_uint("attacker minted wETH", stolen, 18);
    }
}

Run:

forge test --match-test test_exploit_fakeVerifier -vvv

Expected output:

Running 1 test for test/VulnVAABridge.t.sol:VulnVAABridgeTest
[PASS] test_exploit_fakeVerifier_mints_120k_wETH() (gas: ~95k)
Logs:
  attacker minted wETH: 120000.000000000000000000

6.5 The patched contract — three minimal fixes

// src/PatchedVAABridge.sol  (excerpt)
contract PatchedVAABridge {
    address public immutable wToken;
    address public immutable TRUSTED_VERIFIER; // ★ fix #1: pin verifier
    mapping(bytes32 => bool) public consumed;
 
    constructor(address _wToken, address _trustedVerifier) {
        wToken = _wToken;
        TRUSTED_VERIFIER = _trustedVerifier;
    }
 
    function redeem(bytes calldata vaa, bytes calldata sigs) external {
        // ★ fix #2: caller can no longer choose the verifier
        bool ok = ISignatureVerifier(TRUSTED_VERIFIER).verifyVAA(
            keccak256(vaa), sigs
        );
        require(ok, "bad sigs");
 
        bytes32 vaaHash = keccak256(vaa);
        require(!consumed[vaaHash], "replay");
        consumed[vaaHash] = true;
 
        (address recipient, uint256 amount) = abi.decode(vaa, (address, uint256));
        IWrappedToken(wToken).mint(recipient, amount);
    }
}

Three orthogonal hardenings:

  1. Pin the verifier address at construction, immutable, not a parameter. The verifier is a singleton, like the Solana sysvar. Treat it like one.
  2. Even better, inline the verification — don’t delegate the “are sigs valid” question to a separate address at all. Embed ecrecover (or SignatureChecker for AA support) directly. Fewer trust boundaries = fewer mistakes.
  3. Add allowlist of Guardian addresses inside the verifier, and require N-of-M recovered signers to be in the allowlist. The Wormhole on-chain code does have this — it just never reached it, because the prior gate let an unsigned message through.

In Solana terms, the equivalent patches are:

  1. require!(*accs.instruction_acc.key == sysvar::instructions::ID, ...),
  2. Use load_instruction_at_checked which embeds (1),
  3. Use Anchor’s #[account(address = sysvar::instructions::ID)] constraint which encodes the same as a declarative attribute.

6.6 Why this pattern keeps appearing in audits

The auditor’s “fake-verifier” / “default-true” check class shows up in many shapes:

Real-world shapeWhy it’s the same bug
Caller-supplied oracle address used to price an assetDefaults to whatever the attacker says
Caller-supplied token address whose decimals() is readAttacker returns 0 or 36, breaks accounting
if (signers.length > 0) approved = true without checking who the signers areLength is a property of attacker calldata
ERC-1271 isValidSignature on a caller-supplied accountAttacker deploys an always-0x1626ba7e contract
Cross-chain message handler accepts originContract as a parameter rather than reading it from a trusted sourceThe “origin” can be set to whatever address holds privilege
mapping[bytes32(0)] = true initialization + uninitialized proof produces bytes32(0) (Nomad 2022 — same family)Default-value collision

The common abstraction: a security decision (was this signed? was this allowed? was this priced fairly?) whose “yes” path can be reached without the underlying primitive actually running over inputs you’ve bound to identity. Wormhole’s verifier didn’t run secp256k1. Nomad’s didn’t validate the root. Default-true logic doesn’t ask the right question.

The auditor habit: when you see “we trust X to attest to Y”, ask three questions:

  1. What is X, exactly? A specific account / contract / pubkey? Pinned how?
  2. Who controls X’s response? Is it impersonable, swappable, or default-valued?
  3. What happens if X’s response is the maximally-permissive constant? If “everything passes,” what gets minted, transferred, or unlocked?

If the answer to (3) is anything that would harm the protocol, the path is unsafe regardless of how “real” X is supposed to be.


7. Aftermath

7.1 Timeline (UTC, Feb 2–3, 2022)

TimeEvent
~09:00 Feb 2Patch commit e8b91810 pushed to public wormhole-foundation/wormhole repo [verify exact time]
18:24 Feb 2Attacker submits verify_signatures + post_vaa + complete_wrapped_with_payload; mints 120,000 wETH on Solana
18:28 Feb 2Attacker initiates burn-and-unlock to Ethereum (93,750 ETH released; 26,250 wETH remained on Solana)
19:07 Feb 2Wormhole contributors notice supply discrepancy during routine check
19:10–19:20War room formed with Jump Crypto, Neodyme, and external researchers
19:33 Feb 2Guardians alerted to pause message relaying
19:40 Feb 2Token transfers paused (six Guardian nodes stop relaying ⇒ below threshold)
22:07 Feb 2Neodyme prototype for exploit-based program upgrade completed
00:32 Feb 3Patched Solana program deployed via governance upgrade
03:42 Feb 3On-chain safeguard added: blacklist of fraudulent VAAs
05:53 Feb 3Second governance upgrade
13:08 Feb 3Jump Crypto deposits 120,000 ETH into the Ethereum lock contract to restore solvency
13:29 Feb 3Bridge fully back online (~16 hours of downtime)

7.2 Jump Crypto’s bailout

Jump Crypto (then Wormhole’s primary financial backer and operator of one Guardian) wired 120,000 ETH into the Ethereum-side lock contract. This made the wrapped-asset solvency invariant locked(ETH on mainnet) == sum(wETH on all destinations) hold again, so wETH holders on Solana were never personally underwater.

Two things worth flagging for the auditor’s notebook:

  • This socialized the loss to Jump’s balance sheet, not the protocol’s users. That was a corporate decision, not a protocol property. A bridge without a deep-pocketed backer would not have this outcome. In your trust-model section, do not credit a “backer will bail us out” as if it were a cryptographic guarantee.
  • The Jump bailout occurred under specific legal and reputational conditions in early 2022. Post-FTX, post-Multichain, the precedent that bridge backers will bail out exploits has weakened. Do not assume future bridges will be similarly rescued.

7.3 Bounties offered

  • $10M whitehat-return bounty — to the attacker, no questions asked, for returning the funds. Not accepted.
  • $10M arrest-and-conviction bounty — to anyone with information leading to the attacker’s arrest. As of mid-2026 the attacker has not been publicly identified [verify].
  • $3.5M ongoing bug bounty — established post-incident on Immunefi, then one of the largest standing bounties in DeFi.

7.4 Post-mortem and rebuild

Wormhole continued operating. Subsequent changes [verify completeness]:

  • Migration from the deprecated solana-program APIs across the codebase.
  • Adoption of Anchor account-constraint attributes (declarative #[account(address = ...)] rather than imperative checks) where applicable.
  • Hardened deployment process: production binary upgrade before public merge.
  • Wormhole eventually transitioned its governance and operations away from being so closely tied to Jump (the protocol was later spun off into the Wormhole Foundation).
  • Expansion of Guardian set diversity and audit coverage [verify].

The protocol is still in operation. The attacker still holds the funds.


8. Lessons — what to internalize

8.1 Technical lessons

  1. Every “trusted account” / “trusted call” boundary on a non-EVM chain needs an explicit identity check. On Solana this means require!(*account.key == EXPECTED_KEY, ...) or — in Anchor — #[account(address = EXPECTED_KEY)]. The runtime will not check this for you, ever.

  2. Deprecated functions are not safe. Deprecated means “removed defensive logic that you must now add yourself, or migrate to the _checked variant”. The compiler will not warn you about a missed defensive check, only about the deprecation. Treat deprecation flags as security-grade.

  3. Signature verification is a path, not a function. The cryptographic primitive (secp256k1) was correct. The check that the primitive ran on the right inputs against the right account is what failed. Auditing means tracing every step from “user delivered bytes” to “protocol decided yes”: every transformation, every account read, every parameter parse, every default value.

  4. The cost of one missing check = the entire treasury. There is no per-user isolation in a bridge’s destination-side mint. One bypass mints everything. Bridges differ from DEXes in this respect: in a DEX, a single price-curve bug typically loses a pool, not the whole protocol. In a bridge, a single verification bug loses the whole bridge.

  5. Cross-chain message verification correctness is a multi-chain concern. Wormhole’s Ethereum side was fine. Wormhole’s Solana side was the breach. The treasury is bounded by the weakest verifier across all supported chains. Adding a chain adds a new verifier whose correctness rolls into your bridge’s safety.

  6. Solana ≠ EVM, even when the design looks identical. Wormhole’s Ethereum and Solana modules implemented “the same” signature-verification logic. Different runtime semantics turned identical-intent code into one-safe-one-fatal. Cross-chain protocols MUST be audited per-chain by people who know that chain’s runtime, not by EVM auditors translating their mental model.

8.2 Operational lessons

  1. Coordinated disclosure for verifier-code patches. Public commits to verifier paths before production deployment is disclosure. Either deploy first, or maintain a private branch for security-grade patches. The “race condition” between public diff and production binary is measured in minutes, not days, by sophisticated attackers.

  2. Monitor wrapped-asset solvency on-chain. A 120,000-wETH discrepancy between Ethereum-locked and Solana-minted should have triggered an automated alert. It was caught 43 minutes after the mint by manual review during “a routine check”. For nine-figure TVL this is too slow. Modern bridges run continuous on-chain solvency monitoring with Forta/Hypernative-style alerting and automatic pause triggers.

  3. Pause authority must exist and must work. Wormhole’s “pause” was Guardians declining to relay — a soft, social pause that took ~76 minutes to take effect. A native on-chain pause role with low-quorum activation is faster, cleaner, and now standard.

  4. “Trust me, the backer will refill” is not a security property of the protocol. Jump’s 120k ETH covered the user loss. Future bridges may not have such a backer. The on-chain protocol must be designed to survive without off-chain bailout.

8.3 Auditor lessons — questions to add to your checklist

  • Every signature-verification function: is the account (or contract in EVM) holding the verifier identity bound by pubkey/address, or is it caller-supplied?
  • Every “external precompile” call on Solana: is Sysvar::Instructions (or any sysvar) loaded via the _checked variant or guarded by an explicit key == assert?
  • Every Anchor account: does it have an address, owner, seeds, or signer constraint? If none, why?
  • Every cross-chain message handler: is the originChain + originAddress + nonce + payload all bound into the signed digest, and is the signature actually checked against the current Guardian / DVN / committee set (not a stale one)?
  • Every “did we verify earlier?” pattern (e.g., nonReentrant’s _status, verified[hash] mapping): is the write of that flag controlled by code that cannot be reached without the actual verification happening?
  • Default-value safety: does the protocol behave correctly if all mappings return their zero value? Wormhole’s bug is in this family — a SignatureSet filled by attacker bytes parsed as the sysvar layout.
  • Public-repository discipline: are security-grade patches landing in public before mainnet upgrade?

9. What you would have caught — auditing a Wormhole-style bridge

If you were on the audit team in late 2021 reviewing this codebase, here’s where to focus and what to write up.

9.1 Scope-decision in three minutes

A Wormhole audit involves:

  • Solana programs: Core (signature verification, message posting, Guardian rotation) + Portal (lock/burn/mint).
  • Ethereum contracts: Core (VAA verification, Guardian set) + Portal (lock/burn/mint).
  • Per-chain deployments: every other supported chain (BSC, Avalanche, Polygon, Fantom, etc.) has its own Core + Portal — each adds a verifier surface.

For the audit budget, the highest-leverage focus is the verifier path on every supported chain. The Ethereum verifier was relatively battle-tested by 2022 (basic ecrecover over a known Guardian set). The Solana verifier was newer, ran on a different runtime, and was the path with the lowest auditor-attention per dollar of exposure.

9.2 Audit playbook — Solana program

  1. Enumerate all instruction handlers on the Core program. For each, list every account argument and the constraints (if any) on each account.
  2. Map account arguments → constraints. Every account that isn’t a fresh PDA created by this instruction is a trust input. Every trust input needs either:
    • A pubkey check (*acc.key == EXPECTED),
    • An owner check (acc.owner == EXPECTED_PROGRAM),
    • An Anchor declarative constraint (#[account(address = ...)], #[account(owner = ...)], #[account(seeds = ..., bump)]),
    • A discriminator check (Anchor’s #[account] macro handles this).
  3. Search for deprecated APIs. Run grep -rn "load_instruction_at\b" (without _checked). Run grep -rn "deprecated" over the dependency tree. Anything with a deprecation warning in the verifier path is high-priority.
  4. Trace each signature-verification flow end to end. For Wormhole-shaped code:
    • Where is the secp256k1 precompile call? (In which transaction? Adjacent to which BPF instruction?)
    • How does the BPF program learn that the precompile succeeded? (Via the instructions sysvar.)
    • What account is supplied to read the sysvar? Is it constrained?
    • What does the BPF program do with the precompile’s claimed result? (Writes a SignatureSet.)
    • Who reads that SignatureSet? Is its identity (account pubkey, PDA seeds) verified before reading?
  5. Apply the “if attacker controls this account’s data, what happens?” test to every account in every handler. If the answer for any account is “we mint tokens” or “we treat the message as verified,” you have a finding.

A diligent audit team running this checklist on the pre-exploit Wormhole code would have written something like:

Finding W-01 [Critical] — verify_signatures uses deprecated load_instruction_at without verifying the instruction_acc account. The function loads instructions from the account supplied in the instruction_acc slot without checking that the account’s public key equals solana_program::sysvar::instructions::ID. An attacker can supply a counterfeit account whose data mimics the sysvar instructions layout and falsely claim that a Secp256k1Program verification of the VAA hash succeeded. This produces a SignatureSet PDA marking all Guardian signatures as verified, bypassing the entire 13-of-19 trust model and allowing mint of arbitrary wrapped tokens. Recommendation: replace load_instruction_at with load_instruction_at_checked, and additionally add require!(*accs.instruction_acc.key == sysvar::instructions::ID, ...) defensively.

That finding, in late 2021, would have prevented $325M of loss. Total writing time: 15 minutes. Total impact: greater than most auditors’ lifetime billings.

9.3 Audit playbook — Ethereum side (the part that wasn’t broken)

Don’t neglect it — the next exploit might be here:

  1. Verify Guardian-set updates use proper VAA verification (no admin shortcut).
  2. Verify Guardian rotation handles in-flight VAAs signed by the old set.
  3. Verify the lock contract’s solvency invariant is on-chain-observable (anyone can check IERC20(weth).balanceOf(bridge) >= totalLocked).
  4. Verify replay protection: chain id + emitter chain + emitter address + sequence number, all in the signed digest.
  5. Verify pause role exists, is multi-sig, and is documented.

9.4 What a senior auditor would have flagged at design level

Above the code, a design-level review of Wormhole’s pre-exploit architecture had three flags worth noting even without the specific bug:

  • The verifier path is split across three transactions on Solana. Each tx is a separate trust step. Multi-step protocols are harder to reason about than atomic ones. The auditor should flag: “the security of post_vaa depends on the integrity of the SignatureSet written by verify_signatures, which is a separate transaction — the audit must trace that integrity end-to-end.”
  • Account-confusion is the dominant Solana bug class and Wormhole has many trusted-account slots (Guardian set account, sysvar, fee collector). Each is a potential confusion target.
  • The Guardian set is a 13-of-19 trust assumption, but the per-chain verifier is a 1-of-1 trust assumption. Any one chain’s verifier bug bypasses the 13-of-19. Set size is a floor on safety, not a guarantee. The auditor should flag this as a structural property in the trust-model section regardless of whether a specific bug is found.

10. References

Official sources

Technical post-mortems

Forensics & laundering

samczsun analysis

Cross-course references


11. Self-test

  1. Why did Wormhole’s 13-of-19 Guardian threshold not protect against this attack?

    AThe Guardian threshold is enforced by the destination-chain verifier contract. If the verifier accepts a forged claim that 13 signatures verified — without actually checking the signatures — the threshold is meaningless. The 19-of-13 is a property of the protocol's *intent*; what matters is the protocol's *implementation* on every chain.
  2. What is the difference between load_instruction_at and load_instruction_at_checked in solana_program::sysvar::instructions?

    AThe `_checked` variant verifies that the supplied account's public key equals the sysvar's known address (`Sysvar1nstructions1111111111111111111111111`). The non-checked variant trusts the caller's claim that the supplied account *is* the sysvar and parses its data accordingly. The deprecated non-checked variant is what Wormhole used.
  3. In EVM terms, what is the rough analogue of “account confusion”?

    AClosest analogues: (a) `delegatecall` storage collisions, where the called contract's expectations about storage layout don't match the caller's; (b) a contract trusting a caller-supplied address that is supposed to be a specific singleton (e.g., the canonical price oracle) without pinning it as an `immutable` in the constructor; (c) ERC-1271 acceptance from a caller-supplied account that always returns the magic value.
  4. Why was Jump Crypto’s 120k-ETH refill not a protocol-level guarantee, even though it kept users whole?

    ABecause the refill was a corporate balance-sheet decision by an external entity, not an automatic protocol mechanism. Future hacks may not be backstopped. Auditors should never credit "the backer will pay" as a security property of a protocol's design.
  5. The patch was less than a dozen lines. What is the audit-cost-vs-blast-radius takeaway?

    AVerifier-code per-line risk is roughly proportional to the bridge's TVL — not proportional to the line's complexity. In a bridge, a one-line missing assertion can equal the entire treasury. Auditor attention must be allocated by *blast radius*, not by *line count*.
  6. What is the auditor’s checklist item that, if applied to pre-exploit Wormhole, would have caught this?

    A"For every account that the program does not itself create as a fresh PDA: is its identity (pubkey or owner) explicitly constrained? If not, what happens when an attacker substitutes an attacker-owned account with attacker-controlled data?"
  7. Why is reproducing this bug exactly in Foundry not possible, and what is the right substitute exercise?

    AThe bug lives in Solana's account model and its sysvar instructions mechanism, which has no direct EVM analogue. The right substitute is the generalized "default-true signature verification path" — a contract that accepts a caller-supplied verifier address and trusts whatever it returns. This captures the same auditor lesson: the verifier-identity binding is the safety property, not the verifier's own internal correctness.
  8. Name two other 2022 bridge exploits and the trust-model lesson each provides, contrasted with Wormhole’s.

    A**Ronin (March 2022, $625M)**: validator-key compromise via spear-phishing — trust-model lesson: set diversity matters more than set size; 5-of-9 with 4 controlled by one org ≈ 2-of-9. **Nomad (August 2022, $190M)**: zero-root mapping initialization made any proof valid — trust-model lesson: default values are part of the threat model; check that queried keys are well-formed before trusting mapping returns. Wormhole sits between: not a key compromise, not a default-value bug, but a *verifier-path identity-binding* bug — the verifier ran on attacker-controlled inputs that were never bound to a trusted source.

Last updated: 2026-05-16. Flagged [verify] items in this document should be cross-checked against the on-chain explorers and the linked primary sources before this is used in any formal audit report.