• Building
    • Firedancer
    • the Pit
    • Cyclone
  • Thinking
  • Connect
  • Building
    • Firedancer
    • the Pit
    • Cyclone
    • Collaborations
  • Thinking
  • About
Terms of Use_Privacy Policy_Disclaimers_

Preventing Airdrop Theft on Stride: an IBC integration vulnerability

Neeraja Jayakumar
Neeraja Jayakumar
Security

May 15 2023 _ 6 min read

Preventing Airdrop Theft on Stride: an IBC integration vulnerability

This blog post describes a vulnerability we discovered in Stride, a Cosmos chain for liquid staking across the Cosmos ecosystem. The issue could have allowed an attacker to steal all unclaimed airdrops on Stride. At the time of discovery, more than 1.6M STRD (equivalent to roughly $4M) were at risk. We reported the vulnerability privately to the Stride contributors and the issue is now fixed. Thanks to this effort no malicious exploitation took place.

Jump Crypto aims to boost security assurance across the crypto ecosystem through ongoing research and coordinated disclosure to identify and patch vulnerabilities across various projects. This announcement is yet another example of how we continue these efforts.

Airdrops on Stride

Stride regularly performs large airdrops of its native STRD token to incentivize network activity and decentralize governance across a wide group of parties. The code for allocating and claiming the airdrop is implemented in the x/claim module. The airdrop allocations are defined through the LoadAllocationData function, which loads an allocations file with addresses and airdrop allocations. For most airdrops, the loaded addresses describe users on other Cosmos chains such as Osmosis or Juno, so the code first converts them into Stride addresses using the utils.ConvertAddressToStrideAddress function.

For each account in an airdrop, the module creates a ClaimRecord with the airdrop identifier for the particular airdrop, the converted address, and the number of tokens allocated to the user. Once a ClaimRecord is created, the user with the corresponding Stride address can claim their airdrop by sending a MsgClaimFreeAmount to the chain.

However, this implementation did not work during a recent EVMOS airdrop because the utils.ConvertAddressToStrideAddress function mapped Evmos addresses to inaccessible Stride addresses. This was because EVMOS addresses are derived using coin type 60 and Stride addresses are derived using coin type 118.

To allow affected users to still claim the airdrop, the team added functionality to update the destination address of an unclaimed ClaimRecord through a cross-chain IBC message from the corresponding EVMOS account. This update mechanism is implemented as part of the x/autopilot module. x/autopilot intercepts incoming IBC ICS-20 transfers and tries to extract Stride-specific instructions out of their memo or receiver fields (the receiver field doubles as the memo field in IBC versions before v5):

func (im IBCModule) OnRecvPacket(
	ctx sdk.Context,
	packet channeltypes.Packet,
	relayer sdk.AccAddress,
) ibcexported.Acknowledgement {
	
	// NOTE: acknowledgment will be written synchronously during IBC handler execution.
	var data transfertypes.FungibleTokenPacketData
	if err := transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil {
		return channeltypes.NewErrorAcknowledgement(err)
	}

	[..]
	// ibc-go v5 has a Memo field that can store forwarding info
	// For older version of ibc-go, the data must be stored in the receiver field
	var metadata string
	if data.Memo != "" { // ibc-go v5+
		metadata = data.Memo
	} else { // before ibc-go v5
		metadata = data.Receiver
	}

	[..]

	// parse out any forwarding info
	packetForwardMetadata, err := types.ParsePacketMetadata(metadata)
	if err != nil {
		return channeltypes.NewErrorAcknowledgement(err)
	}

	// If the parsed metadata is nil, that means there is no forwarding logic
	// Pass the packet down to the next middleware
	if packetForwardMetadata == nil {
		return im.app.OnRecvPacket(ctx, packet, relayer)
	}

	// Modify the packet data by replacing the JSON metadata field with a receiver address
	// to allow the packet to continue down the stack
	newData := data
	newData.Receiver = packetForwardMetadata.Receiver
	bz, err := transfertypes.ModuleCdc.MarshalJSON(&newData)
	if err != nil {
		return channeltypes.NewErrorAcknowledgement(err)
	}
	newPacket := packet
	newPacket.Data = bz

	// Pass the new packet down the middleware stack first
	ack := im.app.OnRecvPacket(ctx, newPacket, relayer)
	if !ack.Success() {
		return ack
	}

	autopilotParams := im.keeper.GetParams(ctx)

	// If the transfer was successful, then route to the corresponding module, if applicable
	switch routingInfo := packetForwardMetadata.RoutingInfo.(type) {
	case types.StakeibcPacketMetadata:
		[...]

	case types.ClaimPacketMetadata:
		// If claim routing is inactive (but the packet had routing info in the memo) return an ack error
		[..]
		im.keeper.Logger(ctx).Info(fmt.Sprintf("Forwaring packet from %s to claim", newData.Sender))

		if err := im.keeper.TryUpdateAirdropClaim(ctx, newData, routingInfo); err != nil {
			im.keeper.Logger(ctx).Error(fmt.Sprintf("Error updating airdrop claim from autopilot for %s: %s", newData.Sender, err.Error()))
			return channeltypes.NewErrorAcknowledgement(err)
		}

		return ack

	default:
		return channeltypes.NewErrorAcknowledgement(errorsmod.Wrapf(types.ErrUnsupportedAutopilotRoute, "%T", routingInfo))
	}
}

If the included metadata indicates that the incoming transfer is an airdrop claim, the module invokes the TryUpdateAirdropClaim function:

func (k Keeper) TryUpdateAirdropClaim(
	ctx sdk.Context,
	data transfertypes.FungibleTokenPacketData,
	packetMetadata types.ClaimPacketMetadata,
) error {
	[..]

	// grab relevant addresses
	senderStrideAddress := utils.ConvertAddressToStrideAddress(data.Sender)
	if senderStrideAddress == "" {
		return errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, fmt.Sprintf("invalid sender address (%s)", data.Sender))
	}
	newStrideAddress := packetMetadata.StrideAddress

	// update the airdrop
	airdropId := packetMetadata.AirdropId
	k.Logger(ctx).Info(fmt.Sprintf("updating airdrop address %s (orig %s) to %s for airdrop %s",
		senderStrideAddress, data.Sender, newStrideAddress, airdropId))

	return k.claimKeeper.UpdateAirdropAddress(ctx, senderStrideAddress, newStrideAddress, airdropId)
}

The function converts the sender address of the IBC packet to a Stride address named senderStrideAddress and extracts the airdropId and the new airdrop address newStrideAddress out of the packet metadata. It then calls UpdateAirdropAddress to update an open ClaimRecord that matches the combination of senderStrideAddress and airdropId to the new address. With the ClaimRecord now updated, the newStrideAddress will now be able to claim the airdrop.

The important thing to note is that this update mechanism is only protected by the sender address specified inside the IBC packet. Stride does not perform any other validation that ensures the update to the airdrop is triggered by the real recipient.

To understand why this is a serious vulnerability, we need to take a closer look at IBC, the Inter-Blockchain-Communication protocol.

IBC Security

IBC is a light client-based mechanism for cross-chain communication. Similar to classic network protocols, the core IBC module abstracts many low-level details and makes it easy for developers to build their own integrations on top.

Connecting one IBC-enabled chain (chain A) to another IBC-enabled chain (chain B) looks somewhat like this:

Created solo machine client on IBC enabled chain [Client ID = 06-solomachine-6]
Created tendermint client on solo machine [Client ID = 07-tendermint-M48f]
Initialized connection on IBC enabled chain [Connection ID = connection-4]
Initialized connection on solo machine [Connection ID = connection-Kinb]
Confirmed connection on IBC enabled chain [Connection ID = connection-4]
Confirmed connection on solo machine [Connection ID = connection-Kinb]
Initialized channel on IBC enabled chain [Channel ID = channel-0]
Initialized channel on solo machine [Channel ID = channel-wwl6]
Confirmed channel on IBC enabled chain [Channel ID = channel-0]
Confirmed channel on solo machine [Channel ID = channel-wwl6]
Connection established!

In the first step, an IBC light client of chain A is created on chain B and vice-versa. IBC clients are uniquely identified by their client-id and they are used to track and verify the remote chain’s state. Once the clients are created, they can be connected through a connection, which is initiated through a four-way handshake. This creates a ConnectionEnd on chain A with the light client of chain B on A and another on chain B with the light client of chain A on B. Connections are persistent once created and are cryptographically protected by the two light clients.

Communication over a connection is additionally divided into different channels. A channel is identified by the underlying connection and a source and destination port. Each port identifies a module on the respective chains that are being connected via IBC. A ChannelEnd associated with a Connection is created on both of the chains and is identified through the channel-id. Data can now be transferred between the two chains through the established channel.

It is important to keep in mind that IBC is a permissionless protocol by default. This means that anyone can connect any two IBC-enabled chains, without requiring prior authorization or approval. In practice, IBC supports a standard for so-called Solo Machines, clients that don’t represent a blockchain but a single host or machine. As the IBC packet content is fully controlled by the sender (normally the source module on the source chain), modules that perform privileged operations based on incoming IBC packets always need to verify that the messages originate from a trusted channel.

The Vulnerability

However, in the case of Stride, the channel check was missing in the x/autopilot module. The code assumed that an ICS-20 IBC packet with a certain sender address could only be sent by someone with control over this address. This is correct if we only consider the transfer module on trustworthy partner chains like EVMOS, but an attacker can simply send fully controlled IBC packet data to stride using a malicious IBC client under their control.

Exploiting this vulnerability is relatively simple:

  1. Create a malicious IBC client
  2. Create an IBC channel to the Stride IBC transfer module using the malicious client
  3. Craft and send a malicious IBC transfer using the addresses of unclaimed ClaimRecords as the sender field. Use the ClaimMetadata memo field to trigger autopilot and update the airdrop address to an attacker-controlled Stride account.
  4. Steal the airdrop by sending a MsgClaimFreeAmount to the x/claim module

Fixing the Bug

After receiving our timely report, the Stride contributors quickly pulled out all funds from the Airdrop distributor wallets to ensurethat no funds were at risk. The longer-term fix implemented ensures that the IBC airdrop address update packet arrives via the right trusted IBC channel.

Conclusion

The strong support for cross-chain communication via IBC is a unique advantage for the Cosmos ecosystem. While IBC is built on solid cryptographic primitives, securely integrating with it requires a good understanding of the underlying trust model.

Developers who build on top of IBC and security engineers reviewing IBC integrations should carefully review the attack surface exposed to malicious IBC clients or channels.

We would like to thank the Stride contributors for their professional handling of this issue and their quick response.

Share

Stay up to date with the latest from Jump_

More articles

SAFU: Creating a Standard for Whitehats
SAFU: Creating a Standard for Whitehats

Whitehats and DeFi protocols need a shared understanding of security policy. We propose the SAFU - Simple Arrangement for Funding Upload - as a versatile and credible way to let whitehats know what to...

Oct 24 2022 _ 17 min

Share

Disclaimer

The information on this website and on the Brick by Brick podcast or Ship Show Twitter spaces is provided for informational, educational, and entertainment purposes only.  This information is not intended to be and does not constitute financial advice, investment advice, trading advice, or any other type of advice.  You should not make any decision – financial, investment, trading or otherwise – based on any of the information presented here without undertaking your own due diligence and consulting with a financial adviser.  Trading, including that of digital assets or cryptocurrency, has potential rewards as well as potential risks involved. Trading may not be suitable for all individuals. Recordings of podcast episodes or Twitter spaces events may be used in the future.

Building_
Terms of Use_Privacy Policy_Disclaimers_

© 2024 Jump Crypto. All Rights Reserved.

Jump Crypto does not operate any business lines that accept funds from external investors. Any person, company, or app purporting to accept external investor funds on behalf of Jump Crypto is fraudulent.