Disclaimer: This article discusses a vulnerability disclosure related to Wormhole. Marco Hextor is publishing this information as part of his company’s security research work and is not affiliated with, endorsed by, or sponsored by Wormhole. This research was conducted independently and facilitated through the Immunefi bug bounty platform. A bug bounty reward was received for the responsible disclosure of this vulnerability.

Target: Wormhole
Reported on: 15 January 2024
Status: Resolved / Paid
Reward: $50,000 (USDC)

Enter Wormhole

Wormhole relies on a set of Guardians that observe cross-chain messages and sign their payloads so they can traverse chains. The packets containing cross-chain data and the Guardians’ signatures attesting to that data are called Verified Action Approvals (VAAs).

For VAAs to be valid, they must be signed by a quorum of 2/3 of the current Guardian set. Being a total of 19 Guardians, this means that at least 13 Guardians (rounded up) must sign VAAs before their payloads are considered valid to be executed on the destination chain.

The Guardians are grouped into Guardian sets that may change over time as a result of governance operations. When I found this vulnerability, the Guardian set index was 2, counting from zero. Each Guardian set also has an expiration time, where zero generally means non-expired. The zero expiration time is used for the current Guardian set and a different value is eventually set to define an expiration when setting a new Guardian set.

As an interoperability protocol, there are lots of moving parts and nuance we are ignoring for now. But this is all you need to know to keep exploring this vulnerability with me.

The Wormchain Vulnerability

The Wormchain is a blockchain built using the Cosmos SDK and CosmWasm, serving as a gateway connecting the IBC-powered Cosmos ecosystem to the Ethereum, Solana, and many other chains.

And, straight to the point, here’s the Wormchain code to decide if a Guardian set is expired or not as part of their VAA verification logic:

[... SNIP ...]
if 0 < guardianSet.ExpirationTime && guardianSet.ExpirationTime < uint64(ctx.BlockTime().Unix()) {
    return 0, nil, types.ErrGuardianSetExpired
}
[... SNIP ...]

// Source: https://github.com/wormhole-foundation/wormhole/blob/v2.23.32/wormchain/x/wormhole/keeper/vaa.go#L36C1-L38C3

Cool, so if the Guardian set that signed the VAA has an expiration time greater than zero, their expiration time must be less than the current block time uint64 representation (i.e., uint64(ctx.BlockTime().Unix())). Makes sense, right? This ensures the current Guardian set doesn’t need a set expiration time, while any past Guardian sets can be expired by having their ExpirationTime set. But…

$ ./wormchaind q wormhole show-guardian-set 0
GuardianSet:
  expirationTime: "0"
  index: 0
  keys:
  - WMw65cCXshPOPIGXnhuflXB0aqU=
$ ./wormchaind q wormhole show-guardian-set 1
GuardianSet:
  expirationTime: "0"
  index: 1
  keys:
  - WMw65cCXshPOPIGXnhuflXB0aqU=

That’s what the Wormchain node shows when querying the first two Guardian sets.

Do you see what I saw?

The first two Guardian sets, both with the same key WMw65cCXshPOPIGXnhuflXB0aqU= (let’s call it genesis key), were never expired. In combination with the Wormchain’s VAA verification logic, this means that instead of 13 keys you just need ONE specific key to pass any VAAs on Wormchain. This breaks the Guardian security model for Wormchain, which largely depends on a 13 of 19 quorum of reputable entities. This aligns with the “Critical – Governance manipulation” impact; an impact category that existed in their BBP at the time.

What was more interesting to discover is that the Wormhole team had already caught variants of this vulnerability on at least one other chain:

[... SNIP ...]
// IMPORTANT - this is a fix for mainnet wormhole
// The initial guardian set was never expired so we block it here.
if guardian_set.index == 0 && guardian_set.creation_time == 1628099186 {
    return Err(PostVAAGuardianSetExpired.into());
}
if guardian_set.expiration_time != 0
    && (guardian_set.expiration_time as i64) < clock.unix_timestamp
{
    return Err(PostVAAGuardianSetExpired.into());
}
[... SNIP ...]

// Source: https://github.com/wormhole-foundation/wormhole/blob/5fa8379b175f5d0353b825fae8efdb7ff3116466/solana/bridge/program/src/api/post_vaa.rs#L162C1-L171C6

An issue that had already been caught elsewhere. And yet, this one was there waiting for me.

Wait. But I don’t have the key, do I? Well, nothing that some good old social engineering won’t solve… Nah, I’m joking. Social engineering is out of scope and I don’t like prison food. I just wrote a PoC and reported it as “Critical – Governance manipulation”.

The team confirmed the vulnerability and implemented a fix within 48 hours.

It is worth noting that the specific key in question had reportedly been destroyed prior to the report. While this mitigates the immediate risk, from an adversarial perspective, relying on operational key destruction rather than protocol-level enforcement introduces unnecessary uncertainty. The fix removes this ambiguity entirely.

The Fix

The immediate fix was:

-	if 0 < guardianSet.ExpirationTime && guardianSet.ExpirationTime < uint64(ctx.BlockTime().Unix()) {
+	latestGuardianSetIndex := k.GetLatestGuardianSetIndex(ctx)
+
+	if guardianSet.Index != latestGuardianSetIndex && guardianSet.ExpirationTime < uint64(ctx.BlockTime().Unix()) {
        return 0, nil, types.ErrGuardianSetExpired
    }

// Source: https://github.com/wormhole-foundation/wormhole/pull/3714/files

The architectural flaw is now architecturally closed.