第一章:Go fuzz测试盲区突破:3类非随机输入触发崩溃(Unicode归一化、time.Time纳秒精度、math/big大数边界),fuzzing campaign构建模板
Go 的内置 fuzzing 框架虽强大,但默认的 bytes 随机生成器在面对语义敏感类型时存在系统性盲区——它无法感知 Unicode 归一化等价性、time.Time 的纳秒截断行为,或 math/big.Int 在边界值下的溢出传播路径。这些盲区导致大量真实世界崩溃长期逃逸 fuzzing 检测。
Unicode 归一化等价性触发 panic
当函数对字符串做 strings.ToLower 后直接比较(如 if s == "café"),若传入未归一化的 U+00E9(é)与 U+0065 U+0301(e + 重音符)组合,可能因底层 rune 比较逻辑不一致引发 panic。Fuzz target 应显式预处理输入:
func FuzzNormalize(f *testing.F) {
f.Fuzz(func(t *testing.T, in string) {
// 强制 NFC 归一化,暴露未归一化输入的差异
normalized := norm.NFC.String(in)
if len(normalized) > 0 && utf8.RuneCountInString(normalized) != utf8.RuneCountInString(in) {
// 触发归一化敏感路径
process(normalized) // 崩溃点常在此处
}
})
}
time.Time 纳秒精度陷阱
time.Unix(0, 1e9-1).UTC().Format("2006-01-02") 在某些时区下会因纳秒进位导致 time.Date 内部状态不一致。Fuzz campaign 需构造跨纳秒边界的时间戳:
| 秒值 | 纳秒值 | 触发条件 |
|---|---|---|
| 0 | 999999999 | 最大纳秒,易触发进位 |
| -1 | 1 | 负时间+微小纳秒,触发时区计算异常 |
math/big 大数边界溢出
big.Int 的 Add/Mul 在接近 MaxInt64 或 2^N-1 时可能因内部 nat 切片扩容失败而 panic。推荐 fuzz 输入模板:
func FuzzBigOps(f *testing.F) {
f.Add(new(big.Int).SetInt64(math.MaxInt64), new(big.Int).SetUint64(1))
f.Fuzz(func(t *testing.T, a, b []byte) {
x := new(big.Int).SetBytes(a)
y := new(big.Int).SetBytes(b)
// 强制触发高位进位:x * y 接近 2^1024
z := new(big.Int).Mul(x, y)
if z.BitLen() > 1024 { // 边界检查点
z.Sqrt(z) // 此处易 panic
}
})
}
构建完整 fuzzing campaign 时,需将三类种子分别注入:f.Add() 注入已知边界值,f.Fuzz() 使用自定义 Corpus 目录存放 Unicode 归一化对、time.Unix 极端参数、big.Int 二进制序列,并通过 -fuzztime=2h -fuzzcachedata=true 持久化探索。
第二章:Unicode归一化引发的fuzz盲区与定向突破
2.1 Unicode标准与Go字符串底层表示的语义鸿沟
Go 字符串是UTF-8 编码的不可变字节序列,而非 Unicode 码点序列。这导致 len(s) 返回字节数而非字符数,与 Unicode 标准中“字符(grapheme cluster)”的语义存在根本错位。
UTF-8 与 Rune 的割裂
s := "👋🌍" // 2 个 emoji,各占 4 字节 UTF-8 编码
fmt.Println(len(s)) // 输出:8(字节数)
fmt.Println(utf8.RuneCountInString(s)) // 输出:2(rune 数)
len() 操作作用于底层 []byte,无视多字节编码边界;而 RuneCountInString 遍历 UTF-8 序列解码出 Unicode 码点(rune),揭示语义单位。
常见误用对照表
| 操作 | 实际含义 | Unicode 语义等价性 |
|---|---|---|
s[i] |
单字节(可能非法) | ❌ 非字符边界访问 |
for _, r := range s |
安全遍历 rune | ✅ 符合字符语义 |
strings.Split(s,"") |
按字节切分 | ❌ 破坏 UTF-8 序列 |
字符边界校验流程
graph TD
A[输入字节流] --> B{是否为合法 UTF-8 起始字节?}
B -->|否| C[错误:孤立尾字节]
B -->|是| D[解析首字节确定长度N]
D --> E{后续N-1字节是否符合0x80-0xBF?}
E -->|否| C
E -->|是| F[提取完整rune]
2.2 归一化形式(NFC/NFD/NFKC/NFKD)在fuzz输入生成中的隐式失效
Unicode 归一化常被误认为“标准化即安全”,但在模糊测试中,它可能悄然抹除关键变异点。
归一化如何“吃掉”fuzz变异
当 fuzz 引擎生成 U+00E9(é)与 U+0065 U+0301(e + ◌́)两种等价序列时,若预处理强制 NFC,后者将被折叠为前者——语义等价,但字节结构与解析路径完全不同。
import unicodedata
payloads = [b'\xc3\xa9', b'e\xcc\x81'] # NFC vs NFD bytes
normalized = [unicodedata.normalize('NFC', p.decode()).encode() for p in payloads]
print([p.hex() for p in normalized]) # → ['c3a9', 'c3a9']:差异消失!
逻辑分析:
unicodedata.normalize('NFC', ...)强制合成字符,使原始字节级变异失效;参数p.decode()隐含 UTF-8 编码假设,若输入含非法序列则抛异常,进一步掩盖边界行为。
四种归一化形式的敏感性对比
| 形式 | 是否分解组合字符 | 是否兼容等价(如 ①→1) | 对fuzz变异影响 |
|---|---|---|---|
| NFC | 否(合成) | 否 | 高度抑制结构变异 |
| NFD | 是(分解) | 否 | 保留分解差异,但丢失组合上下文 |
| NFKC | 否 | 是 | 最危险:数字/符号映射导致逻辑跳转 |
| NFKD | 是 | 是 | 双重失真:既分解又兼容替换 |
影响链可视化
graph TD
A[Fuzz引擎生成NFD序列] --> B[NFC预处理]
B --> C[合成字符→单码点]
C --> D[绕过解析器多码点分支]
D --> E[漏洞未触发]
2.3 基于rune序列构造的可控归一化碰撞样本集设计
为实现细粒度Unicode归一化鲁棒性测试,需构造语义等价但rune序列不同的可控碰撞样本。
核心构造策略
- 选取NFC/NFD等价对(如
évse\u0301) - 插入零宽连接符(ZWJ, U+200D)或零宽非连接符(ZWNJ, U+200C)维持视觉一致性
- 对齐rune长度以消除长度偏置
归一化碰撞生成示例
func makeCollisionPair() (nfc, nfd string) {
base := "cafe" // 基础字符串
accent := "\u0301" // 组合重音符
nfc = base + "\u00e9" // NFC: café(单码点)
nfd = base + "e" + accent // NFD: café(基础+组合)
return
}
逻辑分析:"\u00e9"(LATIN SMALL LETTER E WITH ACUTE)与 "e"+\u0301 在NFC/NFD下等价;函数返回一对rune长度不同(4 vs 5)、视觉相同、归一化后一致的样本,适用于验证Normalize.String()行为。
碰撞样本元信息表
| 类型 | 示例输入 | rune数 | NFC等价 | 归一化后rune数 |
|---|---|---|---|---|
| 基础重音 | "e\u0301" |
2 | ✓ | 1 |
| ZWJ修饰 | "👨\u200d💻" |
3 | ✓ | 2 |
graph TD
A[原始字符串] --> B{含组合字符?}
B -->|是| C[生成NFD变体]
B -->|否| D[注入ZWJ/ZWNJ]
C & D --> E[归一化校验]
E --> F[加入样本集]
2.4 在strings.Map、regexp、path/filepath中复现归一化导致的panic案例
Unicode 归一化(如 NFD/NFC)在字符串处理中常被隐式触发,而 Go 标准库部分函数对非规范码点缺乏防御性检查。
strings.Map 的边界陷阱
// 将字符映射为自身(看似无害)
result := strings.Map(func(r rune) rune { return r }, "\u0301\u0301") // 双重组合符
逻辑分析:strings.Map 内部不校验输入是否构成合法 Unicode 序列;\u0301\u0301(两个独立的重音符)在映射过程中触发内部归一化路径,导致 runtime.panic —— 因底层 unicode/norm 包在非规范输入下未做预检。
regexp 和 filepath 的连锁反应
| 包 | 触发场景 | panic 原因 |
|---|---|---|
regexp |
编译含非规范组合符的模式 | norm.NFC.Bytes() 内部越界 |
path/filepath |
Clean("a/\u0301../b") |
路径归一化时解析失败 |
graph TD
A[输入非规范Unicode] --> B{strings.Map}
A --> C{regexp.Compile}
A --> D{filepath.Clean}
B --> E[panic: invalid UTF-8]
C --> E
D --> E
2.5 构建Unicode-aware fuzz corpus:从ICU数据集到go-fuzz自定义corpus generator
数据同步机制
从 ICU Unicode Character Database (UCD) 下载 UnicodeData.txt 和 EmojiSources.txt,提取涵盖全类别(如 Cs, Cf, So, Emoji_Presentation)的码点序列。
自定义生成器核心逻辑
func GenerateCorpus() [][]byte {
var corpus [][]byte
for _, r := range unicodeRanges {
for i := r.Lo; i <= r.Hi; i++ {
if unicode.IsPrint(rune(i)) {
corpus = append(corpus, []byte(string(rune(i))))
}
}
}
return corpus
}
该函数遍历预定义 Unicode 范围(如 0x1F600–0x1F64F 表情符号块),对每个合法可打印码点调用 string() 构造 UTF-8 字节序列。rune(i) 确保跨代理对正确解析;[]byte(...) 输出符合 go-fuzz 输入格式的原始字节切片。
支持的字符类别概览
| 类别 | 示例范围 | 用途 |
|---|---|---|
Emoji_Presentation |
U+1F600–U+1F64F | 高保真表情测试 |
Cs (Surrogate) |
U+D800–U+DFFF | 检测代理对处理缺陷 |
Cf (Format) |
U+200E–U+200F | 验证双向文本解析鲁棒性 |
graph TD
A[ICU UCD] --> B[Parse UnicodeData.txt]
B --> C[Filter by Category & Emoji]
C --> D[Generate UTF-8 byte slices]
D --> E[Write to go-fuzz corpus dir]
第三章:time.Time纳秒精度引发的时序边界崩溃
3.1 time.Time内部表示(wall、ext、loc)与纳秒截断/溢出的未定义行为
time.Time 在 Go 运行时中由三个字段构成:wall(壁钟时间位图)、ext(扩展秒数,处理跨 2^31 秒范围)、loc(时区指针)。其中 wall 低 34 位存储纳秒(0–999,999,999),高 29 位为 Unix 纪元秒的低位;ext 存储秒数高位(含符号),二者拼接得完整纳秒级绝对时间。
// src/time/time.go 中关键结构(简化)
type Time struct {
wall uint64 // bit0-33: 纳秒, bit34-63: wallSec(低29位)
ext int64 // 高32位秒数(含符号扩展)
loc *Location
}
⚠️ 当纳秒部分 ≥ 1e9 时,
wall字段不自动进位,而是静默截断——导致t.Nanosecond()返回错误值,且t.Add(1 * time.Nanosecond)可能触发未定义行为(如ext未同步更新)。
| 字段 | 位宽 | 用途 | 溢出风险 |
|---|---|---|---|
wall & 0x7FFFFFFF |
31 bits | 壁钟秒(低31位) | 秒级回绕(2^31 s ≈ 68年) |
wall >> 34 |
30 bits | 纳秒(0–999,999,999) | 截断无提示 |
ext |
64 bits | 秒高位 | 符号扩展错误可致负时间 |
graph TD
A[time.Now] --> B[wall = sec%2^31 \| nanos]
B --> C{nanos >= 1e9?}
C -->|Yes| D[静默截断 → 无效纳秒]
C -->|No| E[合法 time.Time]
3.2 在time.AfterFunc、time.Until、time.Sub等API中触发time.Now().Add的隐式下溢
当 time.Now().Add(d) 中 d 为极小负值(如 -9223372036854775808ns,即 math.MinInt64),会触发有符号整数下溢,导致纳秒计数器回绕为极大正数。
隐式下溢的典型路径
time.Until(t)若t早于time.Now()超过1<<63-1纳秒,返回负值传入AfterFunctime.AfterFunc(time.Until(t), f)实际等价于time.AfterFunc(time.Now().Add(-duration).Sub(time.Now()), f)→ 触发Add下溢
关键代码示例
d := time.Duration(math.MinInt64) // -9223372036854775808 ns
t := time.Now().Add(d) // ⚠️ 下溢:纳秒字段变为 9223372036854775808
fmt.Println(t.UnixNano()) // 输出正极大值(非预期时间)
逻辑分析:time.Duration 是 int64 别名;Add() 直接执行整数加法,无溢出检查。math.MinInt64 + now.nanos 溢出后成为正数,使 t 变为遥远未来时间点。
| API | 触发条件 | 后果 |
|---|---|---|
time.Until |
输入时间早于当前超 292年 |
返回极大负值 |
time.AfterFunc |
传入该负值 | 定时器延迟数百年执行 |
graph TD
A[time.Until(t)] --> B{t < Now?}
B -->|是| C[计算负duration]
C --> D[time.AfterFunc(negDur, f)]
D --> E[time.Now().Add(negDur) → 下溢]
E --> F[定时器误设为公元2262年]
3.3 构建纳秒级delta fuzz driver:覆盖-1ns、maxInt64 ns、跨闰秒边界时间戳
为验证时间处理逻辑的鲁棒性,fuzz driver需生成极端但合法的纳秒级时间差值。
核心测试边界点
-1 ns:触发向下溢出路径(如time.Add(-1)在零时刻的回绕行为)math.MaxInt64 ns ≈ 292年:检验有符号64位纳秒计数器的上限承载能力2016-12-31T23:59:60Z等闰秒边界:验证系统是否正确解析TAI/UTC双时标转换
关键 fuzz 生成逻辑
func generateDeltaNanos() int64 {
switch rand.Intn(4) {
case 0: return -1 // -1ns 边界
case 1: return math.MaxInt64 // 最大正向delta
case 2: return 1e9 * 60 // 跨闰秒:+60s = +60_000_000_000 ns
default: return rand.Int63n(1e12) - 5e11 // 随机居中扰动
}
}
该函数确保每次调用均命中预设边界;-1 触发负增量路径,MaxInt64 压测整数溢出防护,60s 纳秒等价值用于构造跨越闰秒插入点的时间差。
边界输入覆盖表
| Delta (ns) | 触发场景 | 预期校验目标 |
|---|---|---|
| -1 | 负增量边界 | time.Time.Before() 一致性 |
| 9223372036854775807 | maxInt64 | Add() 不 panic,结果可序列化 |
| 60000000000 | 跨闰秒(+60s) | UTC→TAI转换后秒字段不重复或跳变 |
graph TD
A[Generate delta] --> B{-1ns?}
B -->|Yes| C[Validate underflow handling]
B -->|No| D{maxInt64?}
D -->|Yes| E[Check Add overflow guard]
D -->|No| F[Check leap second alignment]
第四章:math/big大数边界下的fuzz失效场景
4.1 big.Int/Float/Rat底层位宽管理与内存分配异常路径分析
Go 标准库中 big.Int、big.Float 和 big.Rat 均基于动态位宽整数数组(nat)实现,其核心在于按需扩展的 *big.nat 底层切片。
内存分配关键路径
big.Int.SetBytes()触发nat.make()分配:若输入字节长度为n,则预估位宽≈ n*8,实际分配ceil(n*8 / 64)个uint64元素;big.Float.SetPrec(p)调整精度时,若p > current.bits,触发float.alloc()重分配并清零新内存;big.Rat的SetFrac在分母为 0 或分子/分母过大时,进入panic("division by zero")或runtime.throw("invalid memory address")异常分支。
异常路径示例
func (z *Int) SetBytes(buf []byte) *Int {
if len(buf) == 0 {
return z.SetInt64(0) // 快速路径
}
n := len(buf)
// ⚠️ 溢出风险:n > math.MaxInt64/8 → make([]uint64, huge) → OOM 或 runtime.panic
d := make(nat, (n+7)/8) // 向上取整至 uint64 个数
// ……字节填充逻辑
}
该函数未校验 n 是否导致 make 参数溢出;当 n > 2^61 时,(n+7)/8 可能绕回负值,触发 makeslice: len out of range panic。
| 类型 | 位宽单位 | 分配失败典型错误 |
|---|---|---|
big.Int |
uint64 |
runtime error: makeslice: len out of range |
big.Float |
uint64 |
panic: invalid float precision(prec ≤ 0) |
big.Rat |
*Int |
panic: division by zero(分母为零) |
graph TD
A[SetBytes/alloc/SetFrac] --> B{参数校验}
B -- 有效 --> C[计算所需 uint64 元素数]
B -- 超限/非法 --> D[panic 或 throw]
C --> E{make([]uint64, n) 是否溢出?}
E -- 是 --> F[runtime.panic “makeslice”]
E -- 否 --> G[初始化并拷贝数据]
4.2 在big.Int.Exp、big.Rat.SetFrac、big.Float.Quo中触发OOM或panic的超长位串构造
指数爆炸的隐式内存申请
big.Int.Exp 对底数 x 和指数 y 均作深拷贝,当 y.BitLen() > 10^6 时,结果位宽可达 O(|x|.BitLen() × y),瞬间分配 GB 级内存:
// 构造百万位指数 → 触发 OOM(非 panic,但进程被 OS kill)
x := big.NewInt(2)
y := new(big.Int).Lsh(big.NewInt(1), 1_000_000) // y = 2^1000000
res := new(big.Int).Exp(x, y, nil) // 申请 ~125KB * 2^1000000 字节 → 不可能完成
逻辑分析:
Exp内部调用nat.exp,其算法复杂度为O(n²)(n 为 y 的位长),且中间缓冲区按len(res) ≈ len(x) * y.BitLen()预分配;参数y本身仅占几 KB,但其语义容量引发指数级资源请求。
有理数与浮点数的隐式精度陷阱
| 方法 | 触发条件 | 行为 |
|---|---|---|
big.Rat.SetFrac |
分母含 2^1000000 因子 |
panic: “overflow”(内部 int 转换失败) |
big.Float.Quo |
除数有效位 > 1e7 |
OOM 或 panic: "invalid precision" |
graph TD
A[传入超长位串] --> B{类型分支}
B --> C[big.Int.Exp: 位宽爆炸]
B --> D[big.Rat.SetFrac: 分母质因数分解耗尽栈]
B --> E[big.Float.Quo: mantissa 缓冲区越界]
4.3 基于位模式枚举的big corpus生成策略:从2^64到2^131072的渐进式覆盖
传统枚举受限于内存与地址空间,而位模式驱动的分形展开策略突破该瓶颈:将高维语义空间映射为可递归分割的位超立方体。
核心递归生成器
def bitpattern_corpus(depth, seed=0b101):
if depth == 0: return [seed]
prev = bitpattern_corpus(depth - 1)
return [x << 1 for x in prev] + [(x << 1) | 1 for x in prev]
# 逻辑:每层将当前所有模式左移1位,再分别补0/1 → 空间规模指数翻倍(2^depth)
# 参数:depth=16 → 2^16个64位模式;depth=2^16 → 隐式生成2^(2^16)≈2^65536个131072位模式
渐进覆盖三阶段
- 轻量层(depth ≤ 12):全内存驻留,用于快速验证
- 流式层(12
- 符号层(depth > 20):仅存生成规则与哈希指纹
| 层级 | 位宽 | 实际枚举量 | 存储方式 |
|---|---|---|---|
| L1 | 64 | 2^12 | 全加载 |
| L2 | 4096 | 2^20 | mmap分块 |
| L3 | 131072 | 符号推导 | 规则树 |
graph TD
A[种子位模式] --> B{depth==0?}
B -->|是| C[返回单元素列表]
B -->|否| D[递归生成depth-1]
D --> E[左移补0]
D --> F[左移补1]
E & F --> G[合并结果]
4.4 结合go-fuzz的build tags与自定义mutator实现big-aware fuzzing pipeline
为什么需要 big-aware 模糊测试?
传统 fuzzing 在处理大对象(如 GB 级内存映射、长序列 protobuf)时易因内存爆炸或超时被裁剪。big-aware 指在变异、输入生成、覆盖率反馈等环节显式感知数据规模并动态适配。
build tags 实现环境隔离
// fuzz_big.go
//go:build bigfuzz
// +build bigfuzz
package main
import "github.com/dvyukov/go-fuzz/go-fuzz-dep"
func Fuzz(data []byte) int {
if len(data) > 10<<20 { // 允许最大 10MB 输入
return 0
}
// ... 处理逻辑
return 1
}
//go:build bigfuzz启用独立构建变体,避免污染常规测试;len(data) > 10<<20显式设上限,防止 OOM 中断 fuzz loop。
自定义 mutator 控制增长节奏
| Mutator 类型 | 触发条件 | 行为 |
|---|---|---|
ExpandChunk |
当前输入 | 随机复制 1–4KB 子段插入 |
SparseFill |
输入 ≥ 1MB | 仅在 0.1% 位置注入新字节 |
LengthAwareCross |
两输入均 > 500KB | 按比例分块交叉(非均匀) |
pipeline 协同流程
graph TD
A[go-fuzz CLI] -->|+build tags| B(go-fuzz-build)
B --> C[bigfuzz.a]
C --> D{Custom Mutator}
D -->|size-aware| E[Input Corpus]
E --> F[Coverage-guided Loop]
F -->|≥1MB| D
第五章:fuzzing campaign构建模板与工程化落地
标准化输入资产治理
一个可复现的 fuzzing campaign 必须从受控输入源启动。实践中,我们为某金融支付 SDK 构建 campaign 时,将协议样本分为三类资产:(1)合法流量 PCAP 解析出的 ASN.1 编码 TLV 结构体;(2)Fuzzing 前人工构造的边界值序列(如金额字段填入 0x80000000、0xFFFFFFFF);(3)历史 crash 触发用例归档目录(含 ASan 报告与寄存器快照)。所有资产统一存于 Git LFS 托管的 assets/ 目录下,并通过 SHA256 校验和清单 manifest.json 追踪版本:
{
"corpus_v2_202405": {
"sha256": "a7e9f3d1b8c...e4f2a",
"size_bytes": 142857,
"last_modified": "2024-05-22T09:17:03Z"
}
}
自动化编译与插桩流水线
采用 GitHub Actions 实现跨平台 fuzzing 二进制构建。针对 LLVM-based libFuzzer,定义如下关键步骤:
- 使用
clang++ -fsanitize=fuzzer,address,undefined -g -O2编译目标库 - 插入
__AFL_INIT()调用以启用 AFL++ 的 persistent mode - 生成带符号表的
.so供libfuzzer动态链接,同时保留.ll中间表示用于后续覆盖率分析
该流程已集成至 CI/CD,每次 PR 合并自动触发构建并上传产物至 S3 存储桶,路径格式为 s3://fuzz-binaries/{target_name}/{commit_hash}/libtarget_fuzz.so。
分布式执行调度架构
下图展示基于 Kubernetes 的 fuzzing 集群拓扑,其中 fuzz-operator 控制器按 campaign 配置动态伸缩 fuzzer-pod 数量,并通过 Redis Stream 管理语料分发队列:
graph LR
A[CI Pipeline] -->|Upload binary & corpus| B(S3 Bucket)
B --> C{K8s Operator}
C --> D[fuzzer-pod-1]
C --> E[fuzzer-pod-N]
D --> F[Redis Stream: corpus_queue]
E --> F
F --> G[Minimizer Service]
G --> H[Crash Triage DB]
多维度监控看板配置
部署 Prometheus + Grafana 实时追踪核心指标:每秒新路径发现率(libfuzzer_new_paths_total)、内存泄漏增长斜率(process_resident_memory_bytes{job=~"fuzzer.*"})、崩溃复现稳定性(crash_repro_rate{campaign="payment_sdk_v3"})。某次 campaign 运行中检测到 heap-use-after-free 漏洞后,看板自动标红并触发 Slack 告警,附带 curl -X POST https://triage.example.com/api/v1/crashes -d '{"id":"CR-2024-0522-889"}' 快速跳转链接。
持久化结果归档规范
所有 fuzzing 输出按 campaign ID 分区存储于对象存储,结构如下:
s3://fuzz-results/payment_sdk_v3_20240522/
├── coverage/
│ ├── lcov.info # 合并各 pod 覆盖率数据
│ └── coverage_report/ # HTML 可视化报告
├── crashes/
│ ├── CR-2024-0522-889/ # 包含 crash.input、asan.log、stacktrace.txt
│ └── CR-2024-0522-890/
├── corpus/
│ ├── final/ # 最终精简语料集(<5MB)
│ └── incremental/ # 每小时增量快照
└── metadata.yaml # campaign 配置、运行时长、CPU 小时消耗等
该结构被下游自动化回归测试系统直接消费,每日凌晨自动拉取 final/ 语料执行 smoke test。
