CVE-2022-24738 (Go)

Evmos' claims module can incorrectly trust IBC transfer metadata from a counterparty chain when processing receive callbacks. An attacker who controls or connects a chain that does not enforce the same Ethereum key ownership/signature guarantees can craft an ICS-20 packet whose sender is a bech32 address on the counterparty chain and whose receiver is an Evmos address with the same underlying account bytes but a different human-readable prefix. If the claims callback normalizes the sender bytes and treats this packet as a valid attestation, it may migrate, merge, delete, or mark Evmos claims records as completed and may release claimable funds to an address whose ownership was not proven on Evmos. This allows unclaimed user funds to be drained through an unauthenticated IBC path.

Weakness:
CWE-287: name: Improper Authentication
description: When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.

Repository: https://github.com/tharsis/evmos

Vulnerable code:
file_path: x/claims/keeper/ibc_callbacks.go
snippet: func (k Keeper) OnRecvPacket(
	ctx sdk.Context,
	packet channeltypes.Packet,
	ack exported.Acknowledgement,
) exported.Acknowledgement {
	params := k.GetParams(ctx)

	// short circuit in case claim is not active (no-op)
	if !params.IsClaimsActive(ctx.BlockTime()) {
		return ack
	}

	// unmarshal packet data to obtain the sender and recipient
	var data transfertypes.FungibleTokenPacketData
	if err := transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil {
		err = sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "cannot unmarshal ICS-20 transfer packet data")
		return channeltypes.NewErrorAcknowledgement(err.Error())
	}

	// validate the sender bech32 address from the counterparty chain
	bech32Prefix := strings.Split(data.Sender, "1")[0]
	if bech32Prefix == data.Sender {
		return channeltypes.NewErrorAcknowledgement(
			sdkerrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid sender: %s", data.Sender).Error(),
		)
	}

	senderBz, err := sdk.GetFromBech32(data.Sender, bech32Prefix)
	if err != nil {
		return channeltypes.NewErrorAcknowledgement(
			sdkerrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid sender %s, %s", data.Sender, err.Error()).Error(),
		)
	}

	// change the bech32 human readable prefix (HRP) of the sender to `evmos1`
	sender := sdk.AccAddress(senderBz)

	// obtain the evmos recipient address
	recipient, err := sdk.AccAddressFromBech32(data.Receiver)
	if err != nil {
		return channeltypes.NewErrorAcknowledgement(
			sdkerrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid receiver address %s", err.Error()).Error(),
		)
	}

	senderClaimsRecord, senderRecordFound := k.GetClaimsRecord(ctx, sender)
	recipientClaimsRecord, recipientRecordFound := k.GetClaimsRecord(ctx, recipient)

	// handle the 4 cases for the recipient and sender claim records

	switch {
	case senderRecordFound && recipientRecordFound:
		// 1. Both sender and recipient have a claims record
		// Merge sender's record with the recipient's record and
		// claim actions that have been completed by one or the other
		recipientClaimsRecord, err = k.MergeClaimsRecords(ctx, recipient, senderClaimsRecord, recipientClaimsRecord, params)
		if err != nil {
			return channeltypes.NewErrorAcknowledgement(err.Error())
		}

		// update the recipient's record with the new merged one, while deleting the
		// sender's record
		k.SetClaimsRecord(ctx, recipient, recipientClaimsRecord)
		k.DeleteClaimsRecord(ctx, sender)
	case senderRecordFound && !recipientRecordFound:
		// 2. Only the sender has a claims record.
		// Migrate the sender record to the recipient address
		k.SetClaimsRecord(ctx, recipient, senderClaimsRecord)
		k.DeleteClaimsRecord(ctx, sender)

		// claim IBC action
		_, err = k.ClaimCoinsForAction(ctx, recipient, senderClaimsRecord, types.ActionIBCTransfer, params)
	case !senderRecordFound && recipientRecordFound:
		// 3. Only the recipient has a claims record.
		// Only claim IBC transfer action
		_, err = k.ClaimCoinsForAction(ctx, recipient, recipientClaimsRecord, types.ActionIBCTransfer, params)
	case !senderRecordFound && !recipientRecordFound:
		// 4. Neither the sender or recipient have a claims record.
		// Perform a no-op by returning the  original success acknowledgement
		return ack
	}

	if err != nil {
		return channeltypes.NewErrorAcknowledgement(err.Error())
	}

	// return the original success acknowledgement
	return ack
}

Fixed code:
file_path: x/claims/keeper/ibc_callbacks.go
snippet: import (
	"strings"

	sdk "github.com/cosmos/cosmos-sdk/types"
	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
	transfertypes "github.com/cosmos/ibc-go/v3/modules/apps/transfer/types"
	channeltypes "github.com/cosmos/ibc-go/v3/modules/core/04-channel/types"
	"github.com/cosmos/ibc-go/v3/modules/core/exported"

	evmos "github.com/tharsis/evmos/v2/types"
	"github.com/tharsis/evmos/v2/x/claims/types"
)

file_path: x/claims/keeper/ibc_callbacks.go
snippet: func (k Keeper) OnRecvPacket(
	ctx sdk.Context,
	packet channeltypes.Packet,
	ack exported.Acknowledgement,
) exported.Acknowledgement {
	params := k.GetParams(ctx)

	// short (no-op) circuit by returning original ACK in case the claim is not active
	if !params.IsClaimsActive(ctx.BlockTime()) {
		return ack
	}

	// unmarshal packet data to obtain the sender and recipient
	var data transfertypes.FungibleTokenPacketData
	if err := transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil {
		err = sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "cannot unmarshal ICS-20 transfer packet data")
		return channeltypes.NewErrorAcknowledgement(err.Error())
	}

	// validate the sender bech32 address from the counterparty chain
	bech32Prefix := strings.Split(data.Sender, "1")[0]
	if bech32Prefix == data.Sender {
		return channeltypes.NewErrorAcknowledgement(
			sdkerrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid sender: %s", data.Sender).Error(),
		)
	}

	senderBz, err := sdk.GetFromBech32(data.Sender, bech32Prefix)
	if err != nil {
		return channeltypes.NewErrorAcknowledgement(
			sdkerrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid sender %s, %s", data.Sender, err.Error()).Error(),
		)
	}

	// change the bech32 human readable prefix (HRP) of the sender to `evmos1`
	sender := sdk.AccAddress(senderBz)

	// obtain the evmos recipient address
	recipient, err := sdk.AccAddressFromBech32(data.Receiver)
	if err != nil {
		return channeltypes.NewErrorAcknowledgement(
			sdkerrors.Wrapf(sdkerrors.ErrInvalidAddress, "invalid receiver address %s", err.Error()).Error(),
		)
	}

	senderClaimsRecord, senderRecordFound := k.GetClaimsRecord(ctx, sender)

	// NOTE: we know that the connected chains from the authorized IBC channels
	// don't support ethereum keys (i.e `ethsecp256k1`). Thus, so we return an error,
	// unless the destination channel from a connection to a chain that is EVM-compatible
	// or supports ethereum keys (eg: Cronos, Injective).
	if sender.Equals(recipient) && !params.IsEVMChannel(packet.DestinationChannel) {
		switch {
		// case 1: secp256k1 key from sender/recipient has no claimed actions -> error ACK to prevent funds from getting stuck
		case senderRecordFound && !senderClaimsRecord.HasClaimedAny():
			return channeltypes.NewErrorAcknowledgement(
				sdkerrors.Wrapf(
					evmos.ErrKeyTypeNotSupported, "receiver address %s is not a valid ethereum address", data.Receiver,
				).Error(),
			)
		default:
			// case 2: sender/recipient has funds stuck -> error acknowledgement to prevent more transferred tokens from
			// getting stuck while we implement IBC withdrawals
			return channeltypes.NewErrorAcknowledgement(
				sdkerrors.Wrapf(
					evmos.ErrKeyTypeNotSupported,
					"reverted transfer to unsupported address %s to prevent more funds from getting stuck",
					data.Receiver,
				).Error(),
			)
		}
	}

	// return original ACK in case the destination channel is not authorized
	if !params.IsAuthorizedChannel(packet.DestinationChannel) {
		return ack
	}

	recipientClaimsRecord, recipientRecordFound := k.GetClaimsRecord(ctx, recipient)

	// handle the 4 cases for the recipient and sender claim records

	switch {
	case senderRecordFound && recipientRecordFound:
		// 1. Both sender and recipient have a claims record
		// Merge sender's record with the recipient's record and
		// claim actions that have been completed by one or the other
		recipientClaimsRecord, err = k.MergeClaimsRecords(ctx, recipient, senderClaimsRecord, recipientClaimsRecord, params)
		if err != nil {
			return channeltypes.NewErrorAcknowledgement(err.Error())
		}

		// update the recipient's record with the new merged one, while deleting the
		// sender's record
		k.SetClaimsRecord(ctx, recipient, recipientClaimsRecord)
		k.DeleteClaimsRecord(ctx, sender)
	case senderRecordFound && !recipientRecordFound:
		// 2. Only the sender has a claims record.
		// Migrate the sender record to the recipient address
		k.SetClaimsRecord(ctx, recipient, senderClaimsRecord)
		k.DeleteClaimsRecord(ctx, sender)

		// claim IBC action
		_, err = k.ClaimCoinsForAction(ctx, recipient, senderClaimsRecord, types.ActionIBCTransfer, params)
	case !senderRecordFound && recipientRecordFound:
		// 3. Only the recipient has a claims record.
		// Only claim IBC transfer action
		_, err = k.ClaimCoinsForAction(ctx, recipient, recipientClaimsRecord, types.ActionIBCTransfer, params)
	case !senderRecordFound && !recipientRecordFound:
		// 4. Neither the sender or recipient have a claims record.
		// Perform a no-op by returning the  original success acknowledgement
		return ack
	}

	if err != nil {
		return channeltypes.NewErrorAcknowledgement(err.Error())
	}

	// return the original success acknowledgement
	return ack
}

file_path: types/errors.go
snippet: package types

import (
	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)

// RootCodespace is the codespace for all errors defined in this package
const RootCodespace = "evmos"

// root error codes for Evmos
const (
	codeKeyTypeNotSupported = iota + 2
)

// errors
var (
	ErrKeyTypeNotSupported = sdkerrors.Register(RootCodespace, codeKeyTypeNotSupported, "key type 'secp256k1' not supported")
)

file_path: x/claims/types/claim_record.go
snippet: func (cr ClaimsRecord) HasClaimedAny() bool {
	for _, completed := range cr.ActionsCompleted {
		if completed {
			return true
		}
	}
	return false
}