Beware of scams impersonating Jump Trading Group. We only communicate through our official accounts.
- Firedancer
- Thinking
- Connect
Preventing Airdrop Theft on Stride: an IBC integration vulnerability
Neeraja Jayakumar
May 15 2023 _ 6 min read
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:
- Create a malicious IBC client
- Create an IBC channel to the Stride IBC transfer module using the malicious client
- Craft and send a malicious IBC transfer using the addresses of unclaimed
ClaimRecord
s as the sender field. Use theClaimMetadata
memo field to trigger autopilot and update the airdrop address to an attacker-controlled Stride account. - Steal the airdrop by sending a
MsgClaimFreeAmount
to thex/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
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.