VYPR
High severityNVD Advisory· Published Jun 6, 2024· Updated Aug 2, 2024

Evmos's contract balance not updating correctly after interchain transaction

CVE-2024-37153

Description

Evmos is the Ethereum Virtual Machine (EVM) Hub on the Cosmos Network. There is an issue with how to liquid stake using Safe which itself is a contract. The bug only appears when there is a local state change together with an ICS20 transfer in the same function and uses the contract's balance, that is using the contract address as the sender parameter in an ICS20 transfer using the ICS20 precompile. This is in essence the "infinite money glitch" allowing contracts to double the supply of Evmos after each transaction.The issue has been patched in versions >=V18.1.0.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Evmos V18.0 and earlier contain an infinite money glitch allowing contracts to double the Evmos supply via improper ICS20 transfer state handling.

Root

Cause CVE-2024-37153 is a vulnerability in Evmos, the EVM Hub on the Cosmos Network, affecting liquid staking flows that involve Safe contracts. The bug occurs when a contract's local state change and an ICS20 transfer are performed in the same function, and the contract uses its own address as the sender parameter in the ICS20 precompile call. This leads to a state inconsistency where the contract's balance is not properly updated after the interchain transaction [1][4].

Exploitation

A malicious or exploited Safe contract can trigger this by performing an ICS20 transfer with the contract address as the sender while simultaneously executing a local state change. The vulnerability is specific to the ICS20 precompile and does not require special permissions beyond what a standard contract would have access to [1][2].

Impact

This bug effectively acts as an "infinite money glitch," allowing an attacker to double the supply of Evmos with each transaction. Since the contract balance is not decremented correctly, the funds remain available for repeated transfers, leading to uncontrolled token minting [1][4].

Mitigation

The issue has been patched in Evmos version 18.1.0 and later. Users are strongly advised to upgrade their nodes and contracts to the latest patched version. No known workarounds exist for unpatched versions [1][2][4].

AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/evmos/evmos/v18Go
< 18.1.018.1.0
github.com/evmos/evmos/v17Go
< 18.1.018.1.0
github.com/evmos/evmos/v16Go
< 18.1.018.1.0
github.com/evmos/evmos/v15Go
< 18.1.018.1.0
github.com/evmos/evmos/v14Go
< 18.1.018.1.0
github.com/evmos/evmos/v13Go
< 18.1.018.1.0
github.com/evmos/evmos/v12Go
< 18.1.018.1.0
github.com/evmos/evmos/v11Go
< 18.1.018.1.0
github.com/evmos/evmos/v10Go
< 18.1.018.1.0
github.com/evmos/evmos/v9Go
< 18.1.018.1.0
github.com/evmos/evmos/v8Go
< 18.1.018.1.0
github.com/evmos/evmos/v7Go
< 18.1.018.1.0
github.com/evmos/evmos/v6Go
< 18.1.018.1.0

Affected products

15

Patches

1
478b7a62e7af

Merge pull request from GHSA-xgr7-jgq3-mhmc

https://github.com/evmos/evmosVladislav VaradinovMay 31, 2024via ghsa
9 files changed · +310 12
  • precompiles/ics20/ics20.go+4 0 modified
    @@ -17,6 +17,7 @@ import (
     	"github.com/evmos/evmos/v18/precompiles/authorization"
     	cmn "github.com/evmos/evmos/v18/precompiles/common"
     	transferkeeper "github.com/evmos/evmos/v18/x/ibc/transfer/keeper"
    +	stakingkeeper "github.com/evmos/evmos/v18/x/staking/keeper"
     )
     
     var _ vm.PrecompiledContract = &Precompile{}
    @@ -28,13 +29,15 @@ var f embed.FS
     
     type Precompile struct {
     	cmn.Precompile
    +	stakingKeeper  stakingkeeper.Keeper
     	transferKeeper transferkeeper.Keeper
     	channelKeeper  channelkeeper.Keeper
     }
     
     // NewPrecompile creates a new ICS-20 Precompile instance as a
     // PrecompiledContract interface.
     func NewPrecompile(
    +	stakingKeeper stakingkeeper.Keeper,
     	transferKeeper transferkeeper.Keeper,
     	channelKeeper channelkeeper.Keeper,
     	authzKeeper authzkeeper.Keeper,
    @@ -59,6 +62,7 @@ func NewPrecompile(
     		},
     		transferKeeper: transferKeeper,
     		channelKeeper:  channelKeeper,
    +		stakingKeeper:  stakingKeeper,
     	}, nil
     }
     
    
  • precompiles/ics20/tx.go+16 8 modified
    @@ -4,12 +4,16 @@
     package ics20
     
     import (
    +	"fmt"
    +
     	errorsmod "cosmossdk.io/errors"
    +
     	sdk "github.com/cosmos/cosmos-sdk/types"
     	channeltypes "github.com/cosmos/ibc-go/v7/modules/core/04-channel/types"
     	"github.com/ethereum/go-ethereum/accounts/abi"
     	"github.com/ethereum/go-ethereum/common"
     	"github.com/ethereum/go-ethereum/core/vm"
    +	"github.com/evmos/evmos/v18/x/evm/statedb"
     )
     
     const (
    @@ -37,14 +41,12 @@ func (p Precompile) Transfer(
     		return nil, errorsmod.Wrapf(channeltypes.ErrChannelNotFound, "port ID (%s) channel ID (%s)", msg.SourcePort, msg.SourceChannel)
     	}
     
    -	// The provided sender address should always be equal to the origin address.
    -	// In case the contract caller address is the same as the sender address provided,
    -	// update the sender address to be equal to the origin address.
    -	// Otherwise, if the provided delegator address is different from the origin address,
    -	// return an error because is a forbidden operation
    -	sender, err = CheckOriginAndSender(contract, origin, sender)
    -	if err != nil {
    -		return nil, err
    +	// isCallerSender is true when the contract caller is the same as the sender
    +	isCallerSender := contract.CallerAddress == sender
    +
    +	// If the contract caller is not the same as the sender, the sender must be the origin
    +	if !isCallerSender && origin != sender {
    +		return nil, fmt.Errorf(ErrDifferentOriginFromSender, origin.String(), sender.String())
     	}
     
     	// no need to have authorization when the contract caller is the same as origin (owner of funds)
    @@ -78,5 +80,11 @@ func (p Precompile) Transfer(
     		return nil, err
     	}
     
    +	// NOTE: This ensures that the changes in the bank keeper are correctly mirrored to the EVM stateDB.
    +	// This prevents the stateDB from overwriting the changed balance in the bank keeper when committing the EVM state.
    +	if isCallerSender && msg.Token.Denom == p.stakingKeeper.BondDenom(ctx) {
    +		stateDB.(*statedb.StateDB).SubBalance(contract.CallerAddress, msg.Token.Amount.BigInt())
    +	}
    +
     	return method.Outputs.Pack(res.Sequence)
     }
    
  • precompiles/ics20/types.go+1 1 modified
    @@ -374,7 +374,7 @@ func convertToAllocation(allocs []transfertypes.Allocation) []cmn.ICS20Allocatio
     // CheckOriginAndSender ensures the correct sender is being used.
     func CheckOriginAndSender(contract *vm.Contract, origin common.Address, sender common.Address) (common.Address, error) {
     	if contract.CallerAddress == sender {
    -		return origin, nil
    +		return sender, nil
     	} else if origin != sender {
     		return common.Address{}, fmt.Errorf(ErrDifferentOriginFromSender, origin.String(), sender.String())
     	}
    
  • precompiles/ics20/utils_test.go+1 1 modified
    @@ -273,7 +273,7 @@ func (s *PrecompileTestSuite) NewTestChainWithValSet(coord *ibctesting.Coordinat
     	s.app.FeeMarketKeeper.SetBlockGasWanted(s.ctx, 0)
     	s.app.FeeMarketKeeper.SetTransientBlockGasWanted(s.ctx, 0)
     
    -	precompile, err := ics20.NewPrecompile(s.app.TransferKeeper, s.app.IBCKeeper.ChannelKeeper, s.app.AuthzKeeper)
    +	precompile, err := ics20.NewPrecompile(s.app.StakingKeeper, s.app.TransferKeeper, s.app.IBCKeeper.ChannelKeeper, s.app.AuthzKeeper)
     	s.Require().NoError(err)
     	s.precompile = precompile
     
    
  • tests/nix_tests/hardhat/contracts/ICS20FromContract.sol+62 0 added
    @@ -0,0 +1,62 @@
    +// SPDX-License-Identifier: LGPL-3.0-only
    +pragma solidity >=0.8.18;
    +
    +import "./evmos/ics20/ICS20I.sol";
    +import "./evmos/common/Types.sol";
    +
    +
    +contract ICS20FromContract {
    +    int64 public counter;
    +
    +    function balanceOfContract() public view returns (uint256) {
    +        return address(this).balance;
    +    }
    +
    +    function deposit() public payable {}
    +
    +    function transfer(
    +        string memory sourcePort,
    +        string memory sourceChannel,
    +        string memory denom,
    +        uint256 amount,
    +        string memory receiver
    +    ) external {
    +        counter += 1;
    +        Height memory timeoutHeight =  Height(100, 100);
    +        ICS20_CONTRACT.transfer(
    +            sourcePort,
    +            sourceChannel,
    +            denom,
    +            amount,
    +            address(this),
    +            receiver,
    +            timeoutHeight,
    +            0,
    +            ""
    +        );
    +        counter -= 1;
    +    }
    +
    +    function transferFromEOA(
    +        string memory sourcePort,
    +        string memory sourceChannel,
    +        string memory denom,
    +        uint256 amount,
    +        string memory receiver
    +    ) external {
    +        counter += 1;
    +        Height memory timeoutHeight =  Height(100, 100);
    +        ICS20_CONTRACT.transfer(
    +            sourcePort,
    +            sourceChannel,
    +            denom,
    +            amount,
    +            msg.sender,
    +            receiver,
    +            timeoutHeight,
    +            0,
    +            ""
    +        );
    +        counter -= 1;
    +    }
    +}
    
  • tests/nix_tests/test_ics20_precompile.py+208 0 added
    @@ -0,0 +1,208 @@
    +import re
    +
    +import pytest
    +from .ibc_utils import EVMOS_IBC_DENOM, assert_ready, get_balance, prepare_network
    +from .network import Evmos
    +from .utils import (
    +    ADDRS,
    +    CONTRACTS,
    +    KEYS,
    +    deploy_contract,
    +    get_precompile_contract,
    +    send_transaction,
    +    wait_for_fn,
    +)
    +
    +
    +@pytest.fixture(scope="module", params=["evmos", "evmos-rocksdb"])
    +def ibc(request, tmp_path_factory):
    +    """
    +    Prepares the network.
    +    """
    +    name = "ibc-precompile"
    +    evmos_build = request.param
    +    path = tmp_path_factory.mktemp(name)
    +    network = prepare_network(path, name, [evmos_build, "chainmain"])
    +    yield from network
    +
    +
    +def test_ibc_transfer_from_contract(ibc):
    +    """Test ibc transfer from contract"""
    +    assert_ready(ibc)
    +
    +    evmos: Evmos = ibc.chains["evmos"]
    +    w3 = evmos.w3
    +
    +    dst_addr = ibc.chains["chainmain"].cosmos_cli().address("signer2")
    +    amt = 1000000000000000000
    +    src_denom = "aevmos"
    +    gas_limit = 200_000
    +
    +    pc = get_precompile_contract(ibc.chains["evmos"].w3, "ICS20I")
    +    evmos_gas_price = ibc.chains["evmos"].w3.eth.gas_price
    +
    +    src_adr = ibc.chains["evmos"].cosmos_cli().address("signer2")
    +
    +    # Deployment of contracts and initial checks
    +    eth_contract, tx_receipt = deploy_contract(w3, CONTRACTS["ICS20FromContract"])
    +    assert tx_receipt.status == 1
    +
    +    counter = eth_contract.functions.counter().call()
    +    assert counter == 0
    +
    +    # Approve the contract to spend the src_denom
    +    approve_tx = pc.functions.approve(
    +        eth_contract.address, [["transfer", "channel-0", [[src_denom, amt]], []]]
    +    ).build_transaction(
    +        {
    +            "from": ADDRS["signer2"],
    +            "gasPrice": evmos_gas_price,
    +            "gas": gas_limit,
    +        }
    +    )
    +    tx_receipt = send_transaction(ibc.chains["evmos"].w3, approve_tx, KEYS["signer2"])
    +    assert tx_receipt.status == 1
    +
    +    def check_allowance_set():
    +        new_allowance = pc.functions.allowance(
    +            eth_contract.address, ADDRS["signer2"]
    +        ).call()
    +        return new_allowance != []
    +
    +    wait_for_fn("allowance has changed", check_allowance_set)
    +
    +    src_amount_evmos_prev = get_balance(ibc.chains["evmos"], src_adr, src_denom)
    +    # Deposit into the contract
    +    deposit_tx = eth_contract.functions.deposit().build_transaction(
    +        {
    +            "from": ADDRS["signer2"],
    +            "value": amt,
    +            "gas": gas_limit,
    +            "gasPrice": evmos_gas_price,
    +        }
    +    )
    +    deposit_receipt = send_transaction(
    +        ibc.chains["evmos"].w3, deposit_tx, KEYS["signer2"]
    +    )
    +    assert deposit_receipt.status == 1
    +    fees = deposit_receipt.gasUsed * evmos_gas_price
    +
    +    def check_contract_balance():
    +        new_contract_balance = eth_contract.functions.balanceOfContract().call()
    +        return new_contract_balance > 0
    +
    +    wait_for_fn("contract balance change", check_contract_balance)
    +
    +    # Calling the actual transfer function on the custom contract
    +    send_tx = eth_contract.functions.transfer(
    +        "transfer", "channel-0", src_denom, amt, dst_addr
    +    ).build_transaction(
    +        {
    +            "from": ADDRS["signer2"],
    +            "gasPrice": evmos_gas_price,
    +            "gas": gas_limit,
    +        }
    +    )
    +    receipt = send_transaction(ibc.chains["evmos"].w3, send_tx, KEYS["signer2"])
    +    assert receipt.status == 1
    +    fees += receipt.gasUsed * evmos_gas_price
    +
    +    final_dest_balance = 0
    +
    +    def check_dest_balance():
    +        nonlocal final_dest_balance
    +        final_dest_balance = get_balance(
    +            ibc.chains["chainmain"], dst_addr, EVMOS_IBC_DENOM
    +        )
    +        return final_dest_balance > 0
    +
    +    # check balance of destination
    +    wait_for_fn("destination balance change", check_dest_balance)
    +    assert final_dest_balance == amt
    +
    +    # check balance of contract
    +    final_contract_balance = eth_contract.functions.balanceOfContract().call()
    +    assert final_contract_balance == 0
    +
    +    src_amount_evmos = get_balance(ibc.chains["evmos"], src_adr, src_denom)
    +    assert src_amount_evmos == src_amount_evmos_prev - amt - fees
    +
    +    # check counter of contract
    +    counter_after = eth_contract.functions.counter().call()
    +    assert counter_after == 0
    +
    +
    +def test_ibc_transfer_from_eoa_through_contract(ibc):
    +    """Test ibc transfer from contract"""
    +    assert_ready(ibc)
    +
    +    evmos: Evmos = ibc.chains["evmos"]
    +    w3 = evmos.w3
    +
    +    amt = 1000000000000000000
    +    src_denom = "aevmos"
    +    gas_limit = 200_000
    +    evmos_gas_price = ibc.chains["evmos"].w3.eth.gas_price
    +
    +    dst_addr = ibc.chains["chainmain"].cosmos_cli().address("signer2")
    +    src_adr = ibc.chains["evmos"].cosmos_cli().address("signer2")
    +
    +    # Deployment of contracts and initial checks
    +    eth_contract, tx_receipt = deploy_contract(w3, CONTRACTS["ICS20FromContract"])
    +    assert tx_receipt.status == 1
    +
    +    counter = eth_contract.functions.counter().call()
    +    assert counter == 0
    +
    +    pc = get_precompile_contract(ibc.chains["evmos"].w3, "ICS20I")
    +    # Approve the contract to spend the src_denom
    +    approve_tx = pc.functions.approve(
    +        eth_contract.address, [["transfer", "channel-0", [[src_denom, amt]], []]]
    +    ).build_transaction(
    +        {
    +            "from": ADDRS["signer2"],
    +            "gasPrice": evmos_gas_price,
    +            "gas": gas_limit,
    +        }
    +    )
    +    tx_receipt = send_transaction(ibc.chains["evmos"].w3, approve_tx, KEYS["signer2"])
    +    assert tx_receipt.status == 1
    +
    +    def check_allowance_set():
    +        new_allowance = pc.functions.allowance(
    +            eth_contract.address, ADDRS["signer2"]
    +        ).call()
    +        return new_allowance != []
    +
    +    wait_for_fn("allowance has changed", check_allowance_set)
    +
    +    src_starting_balance = get_balance(ibc.chains["evmos"], src_adr, "aevmos")
    +    dest_starting_balance = get_balance(
    +        ibc.chains["chainmain"], dst_addr, EVMOS_IBC_DENOM
    +    )
    +    # Calling the actual transfer function on the custom contract
    +    send_tx = eth_contract.functions.transferFromEOA(
    +        "transfer", "channel-0", src_denom, amt, dst_addr
    +    ).build_transaction(
    +        {"from": ADDRS["signer2"], "gasPrice": evmos_gas_price, "gas": gas_limit}
    +    )
    +    receipt = send_transaction(ibc.chains["evmos"].w3, send_tx, KEYS["signer2"])
    +    assert receipt.status == 1
    +    fees = receipt.gasUsed * evmos_gas_price
    +
    +    final_dest_balance = dest_starting_balance
    +    def check_dest_balance():
    +        nonlocal final_dest_balance
    +        final_dest_balance = get_balance(
    +            ibc.chains["chainmain"], dst_addr, EVMOS_IBC_DENOM
    +        )
    +        return final_dest_balance > dest_starting_balance
    +
    +    wait_for_fn("destination balance change", check_dest_balance)
    +    assert final_dest_balance == dest_starting_balance + amt
    +
    +    src_final_amount_evmos = get_balance(ibc.chains["evmos"], src_adr, src_denom)
    +    assert src_final_amount_evmos == src_starting_balance - amt - fees
    +
    +    counter_after = eth_contract.functions.counter().call()
    +    assert counter_after == 0
    
  • tests/nix_tests/test_precompiles.py+1 1 modified
    @@ -67,7 +67,7 @@ def test_ibc_transfer(ibc):
     
         assert receipt.status == 1
         # check gas used
    -    assert receipt.gasUsed == 48184
    +    assert receipt.gasUsed == 49307
     
         # check gas estimation is accurate
         assert receipt.gasUsed == gas_estimation
    
  • tests/nix_tests/utils.py+16 0 modified
    @@ -2,6 +2,7 @@
     import configparser
     import json
     import os
    +import requests
     import socket
     import subprocess
     import sys
    @@ -15,6 +16,7 @@
     from dotenv import load_dotenv
     from eth_account import Account
     from hexbytes import HexBytes
    +from pystarport import ports
     from pystarport.cluster import SUPERVISOR_CONFIG_FILE
     from web3 import Web3
     from web3._utils.transactions import fill_nonce, fill_transaction_defaults
    @@ -40,6 +42,7 @@
         "TestChainID": "ChainID.sol",
         "Mars": "Mars.sol",
         "StateContract": "StateContract.sol",
    +    "ICS20FromContract": "ICS20FromContract.sol",
         "ICS20I": "evmos/ics20/ICS20I.sol",
         "DistributionI": "evmos/distribution/DistributionI.sol",
         "StakingI": "evmos/staking/StakingI.sol",
    @@ -539,3 +542,16 @@ def erc20_balance(w3, erc20_contract_addr, addr):
         info = json.loads(CONTRACTS["IERC20"].read_text())
         contract = w3.eth.contract(erc20_contract_addr, abi=info["abi"])
         return contract.functions.balanceOf(addr).call()
    +
    +
    +def debug_trace_tx(evmos, tx_hash: str):
    +    url = f"http://127.0.0.1:{ports.evmrpc_port(evmos.base_port(0))}"
    +    params = {
    +        "method": "debug_traceTransaction",
    +        "params": [tx_hash, {"tracer": "callTracer"}],
    +        "id": 1,
    +        "jsonrpc": "2.0",
    +    }
    +    rsp = requests.post(url, json=params)
    +    assert rsp.status_code == 200
    +    return rsp.json()["result"]
    
  • x/evm/keeper/precompiles.go+1 1 modified
    @@ -70,7 +70,7 @@ func AvailablePrecompiles(
     		panic(fmt.Errorf("failed to instantiate distribution precompile: %w", err))
     	}
     
    -	ibcTransferPrecompile, err := ics20precompile.NewPrecompile(transferKeeper, channelKeeper, authzKeeper)
    +	ibcTransferPrecompile, err := ics20precompile.NewPrecompile(stakingKeeper, transferKeeper, channelKeeper, authzKeeper)
     	if err != nil {
     		panic(fmt.Errorf("failed to instantiate ICS20 precompile: %w", err))
     	}
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.