提升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执行中更复杂和有趣的问题。
向上移动!
如果我们想捕捉更有趣的bug,就需要超越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:使用新方法的短期模糊测试活动
运行此代码几小时后,我们在这个小型奖杯案例中收集了一些低严重性bug:
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团队提供了改进模拟测试的补丁,并正在讨论如何更好地将其集成到master中。