提升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团队提供了改进模拟测试的补丁,并且正在讨论如何更好地将其集成到主分支中。