提升Cosmos模糊测试:智能与随机融合之道

本文探讨了在Cosmos SDK中结合智能模糊测试与高层模拟测试的方法,通过自定义随机源实现更高效的漏洞挖掘,并成功发现并修复了三个小问题。

提升Cosmos模糊测试的状态

Cosmos是一个支持用Go(或其他语言)创建区块链的平台。其参考实现Cosmos SDK广泛利用强模糊测试,遵循两种方法:对低级代码进行智能模糊测试,对高级模拟进行随机模糊测试。

在这篇博客文章中,我们解释了这些方法之间的差异,并展示了我们如何在高层模拟框架之上添加智能模糊测试。作为额外收获,我们的智能模糊测试集成帮助我们识别并修复了Cosmos SDK中的三个小问题。

底层测试

第一种Cosmos代码模糊测试方法利用知名智能模糊测试工具如AFL、go-fuzz或Go原生模糊测试来测试代码的特定部分。这些工具依赖于源代码插装来提取有用信息,以指导模糊测试活动。这对于高效探索程序的输入空间至关重要。

在Cosmos SDK中对Go函数进行低级模糊测试非常简单。首先,我们选择一个合适的目标函数,通常是无状态代码,例如测试规范化币的解析:

1
2
3
4
5
func FuzzTypesParseCoin(f *testing.F) {
    f.Fuzz(func(t *testing.T, data []byte) {
        _, _ = types.ParseCoinNormalized(string(data))
    })
}

图1:测试规范化币解析的小型模糊测试

智能模糊测试工具可以快速在无状态代码中发现问题;然而,很明显,仅应用于低级代码的局限性将无助于发现cosmos-sdk执行中更复杂和有趣的问题。

向上移动!

如果我们想捕获更多有趣的错误,我们需要超越Cosmos SDK中的低级模糊测试。幸运的是,已经有一种高级测试方法:这种方法自上而下工作,而不是自下而上。具体来说,cosmos-sdk提供了Cosmos区块链模拟器,一个高级的端到端交易模糊测试器,用于发现Cosmos应用程序中的问题。

该工具允许执行随机操作交易,从随机创世状态或预定义状态开始。要使此工具工作,应用程序开发人员必须实现几个重要函数,这些函数将生成随机创世状态和交易。幸运的是,这对所有cosmos-sdk功能都已完全实现。

例如,为了测试x/nft模块中的MsgSend操作,开发人员定义了SimulateMsgSend函数来生成随机NFT转移:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// SimulateMsgSend生成具有随机值的MsgSend。
func SimulateMsgSend(
        cdc *codec.ProtoCodec,
        ak nft.AccountKeeper,
        bk nft.BankKeeper,
        k keeper.Keeper,
) simtypes.Operation {
        return func(
                r *rand.Rand, app *baseapp.BaseApp, ctx sdk.Context, accs []simtypes.Account, chainID string,
        ) (simtypes.OperationMsg, []simtypes.FutureOperation, error) {
                sender, _ := simtypes.RandomAcc(r, accs)
                receiver, _ := simtypes.RandomAcc(r, accs)
                

图2:x/nft模块中SimulateMsgSend函数的头部

虽然模拟器可以产生交易序列的端到端执行,但与使用智能模糊测试工具如go-fuzz有一个重要区别。当调用模拟器时,它只会使用单一的随机源来产生值。这个源在模拟开始时配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func SimulateFromSeed(
        tb testing.TB,
        w io.Writer,
        app *baseapp.BaseApp,
        appStateFn simulation.AppStateFn,
        randAccFn simulation.RandomAccountFn,
        ops WeightedOperations,
        blockedAddrs map[string]bool,
        config simulation.Config,
        cdc codec.JSONCodec,
) (stopEarly bool, exportedParams Params, err error) {
        // 如果我们必须提前结束,不要os.Exit,以便我们可以运行清理代码。
        testingMode, _, b := getTestingMode(tb)

        fmt.Fprintf(w, "Starting SimulateFromSeed with randomness created with seed %d\n", int(config.Seed))
        r := rand.New(rand.NewSource(config.Seed))
        params := RandomParams(r)
        

图3:SimulateFromSeed函数的头部

由于模拟模式只会循环通过一系列纯随机交易,因此是纯随机测试(也称为随机模糊测试)。

为什么我们不两者兼得?

事实证明,有一种简单的方法可以结合这些方法,允许原生Go模糊测试引擎随机探索cosmos-sdk创世、交易生成和区块创建。第一步是创建一个调用模拟器的模糊测试。我们基于同一文件中的单元测试编写了这段代码:

 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 FuzzFullAppSimulation(f *testing.F) {
    f.Fuzz(func(t *testing.T, input [] byte) {
       
       config.ChainID = SimAppChainID

       appOptions := make(simtestutil.AppOptionsMap, 0)
       appOptions[flags.FlagHome] = DefaultNodeHome
       appOptions[server.FlagInvCheckPeriod] = simcli.FlagPeriodValue
        db := dbm.NewMemDB()
       logger := log.NewNopLogger()

       app := NewSimApp(logger, db, nil, true, appOptions, interBlockCacheOpt(), baseapp.SetChainID(SimAppChainID))
       require.Equal(t, "SimApp", app.Name())

       // 运行随机模拟
       _,_, err := simulation.SimulateFromSeed(
               t,
               os.Stdout,
               app.BaseApp,
               simtestutil.AppStateFn(app.AppCodec(), app.SimulationManager(), app.DefaultGenesis()),
               simtypes.RandomAccounts,
               simtestutil.SimulationOperations(app, app.AppCodec(), config),
               BlockedAddresses(),
               config,
               app.AppCodec(),
       )

       if err != nil {
               panic(err)
       }
    })

图4:运行cosmos-sdk完整模拟的Go模糊测试模板

我们仍然需要一种方法让模糊测试器控制可能的输入。一种简单的方法是让智能模糊测试器直接控制随机值生成器的种子:

1
2
3
4
func FuzzFullAppSimulation(f *testing.F) {
    f.Fuzz(func(t *testing.T, input [] byte) {
       config.Seed = IntFromBytes(input)
       

图5:接收单个种子作为输入的模糊测试

1
2
3
4
5
6
7
8
func SimulateFromSeed(
        
        config simulation.Config,
         
) (stopEarly bool, exportedParams Params, err error) {
        
        r := rand.New(rand.NewSource(config.Seed))
        

图6:在SimulateFromSeed中修改的行以从模糊测试加载种子

然而,这有一个重要缺陷:直接更改种子将使模糊测试器对输入的控制非常有限,因此它们的智能突变将非常无效。相反,我们需要允许模糊测试器更好地控制来自随机数生成器的输入,但无需重构每个模块的每个模拟函数。😱

逆势而上

Go标准库已经提供了各种通用函数和数据结构。在这个意义上,Go是“自带电池”的。特别是,它在math/rand模块中提供了一个随机数生成器:

 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
// Rand是随机数的源。
type Rand struct {
    src Source
    s64 Source64 // 如果src是source64,则非nil

    // readVal包含最近Read调用期间用于字节生成的63位整数的余数。
    // 它被保存,以便下一个Read调用可以从上一个结束的地方开始。
    readVal int64
    // readPos表示readVal的低位字节仍然有效的数量。
    readPos int8
}

 
// Seed使用提供的种子值将生成器初始化为确定性状态。
// Seed不应与任何其他Rand方法并发调用。
func (r *Rand) Seed(seed int64) {
    if lk, ok := r.src.(*lockedSource); ok {
        lk.seedPos(seed, &r.readPos)
        return
    }

    r.src.Seed(seed)
    r.readPos = 0
}

// Int63返回一个非负伪随机63位整数作为int64。
func (r *Rand) Int63() int64 { return r.src.Int63() }

// Uint32返回一个伪随机32位值作为uint32。
func (r *Rand) Uint32() uint32 { return uint32(r.Int63() >> 31) }

图7:Rand数据结构和部分实现代码

然而,我们不能轻易提供此的替代实现,因为Rand被声明为类型而不是接口。但我们仍然可以提供其随机源(Source/Source64)的自定义实现:

1
2
3
4
5
6
7
// Source64是一个Source,也可以直接生成在范围[0, 1<<64)内均匀分布的伪随机uint64值。
// 如果Rand r的基础Source s实现Source64,
// 那么r.Uint64返回对s.Uint64的一次调用的结果,而不是对s.Int63进行两次调用。
type Source64 interface {
    Source
    Uint64() uint64
}

图8:Source64数据类型

让我们用一个新的源替换默认源,该源使用来自模糊测试器的输入(例如,int64数组)作为确定性随机源(arraySource):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type arraySource struct {
    pos int
    arr []int64
    src *rand.Rand
}
// Uint64返回一个非负伪随机64位整数作为uint64。
func (rng *arraySource) Uint64() uint64 {
    if (rng.pos >= len(rng.arr)) {
        return rng.src.Uint64()
    }
      val := rng.arr[rng.pos]
    rng.pos = rng.pos + 1
    if val < 0 {
        return uint64(-val)
    }

    return uint64(val)
}

图9:uint64()的实现,从我们的确定性随机源获取有符号整数

这种新型源要么从数组中弹出一个数字,要么在数组完全消耗时从标准随机源产生一个随机值。这允许模糊测试器即使在所有确定性值都被消耗后也能继续。

准备,设置,开始!

一旦我们修改了代码以正确控制随机源,我们可以像这样利用Go模糊测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ go test -mod=readonly -run=_ -fuzz=FuzzFullAppSimulation -GenesisTime=1688995849 -Enabled=true -NumBlocks=2 -BlockSize=5 -Commit=true -Seed=0 -Period=1 -Verbose=1 -parallel=15
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 1s, gathering baseline coverage: 1/1 completed, now fuzzing with 15 workers
fuzz: elapsed: 3s, execs: 16 (5/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 6s, execs: 22 (2/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 54s, execs: 23 (0/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 57s, execs: 23 (0/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 1m0s, execs: 23 (0/sec), new interesting: 0 (total: 1)
fuzz: elapsed: 1m3s, execs: 23 (0/sec), new interesting: 5 (total: 6)
fuzz: elapsed: 1m6s, execs: 30 (2/sec), new interesting: 10 (total: 11)
fuzz: elapsed: 1m9s, execs: 38 (3/sec), new interesting: 11 (total: 12)

图10:使用新方法的短期模糊测试活动

运行此代码几个小时后,我们在这个小奖杯案例中收集了一些低严重性错误:

https://github.com/cosmos/cosmos-sdk/pull/16951 https://github.com/cosmos/cosmos-sdk/pull/18542 https://github.com/cosmos/cosmos-sdk/pull/16978

我们向Cosmos SDK团队提供了改进模拟测试的补丁,并且正在讨论如何更好地将其集成到主分支中。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计