关于 constant-time-analysis
constant-time-analysis 是面向多种语言加密代码的静态安全分析技能。它检查编译后的汇编或字节码,寻找执行时间可变的操作,并指导结合源代码判断问题是否涉及秘密数据。
主要功能
- 01静态分析汇编或字节码
- 02检测执行时间可变的操作
- 03按函数筛选发现
- 04生成 JSON 或 GitHub 注释
- 05指导秘密数据依赖排查
适用场景
- 01审查加密例程的时序风险
- 02检查不同目标配置下的编译输出
- 03结合源代码排查标记操作
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ trailofbits/ skills --skill constant-time-analysis安装后在 Claude Code 中输入/constant-time-analysis确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.mdagents/openai.yamlassets/trail-of-bits-mark.svgreferences/compiled.mdjavascript.mdkotlin.mdphp.mdpython.mdruby.mdswift.mdvm-compiled.md详细介绍
常数时间分析
编译代码,检查生成的汇编或字节码中是否存在可变时间指令,然后判断哪些被标记的操作实际会接触秘密数据。编译步骤是机械性的;分类判断才是关键工作。
适用场景
- 实现或审查签名、加密、KEM 或密钥派生例程
- 代码对源自密钥、明文、随机数或令牌的值使用
/或% - 用户提到“常数时间”“时序攻击”“侧信道”或“KyberSlash”
- 审查名为
sign、verify、encrypt、decrypt、derive_key的函数
不适用场景
- 测量运行中二进制文件的时序差异——请使用
testing-handbook-skills插件中的constant-time-testing技能,该技能涵盖 dudect 和统计方法,且可能未安装。此技能静态检查编译器输出,不会执行待测代码。 - 非加密代码,或所有输入均为公开信息的加密代码
- 使用由经过审查的库保证常数时间特性的高级 API
- 缓存及其他微架构侧信道——查看汇编无法发现这些问题
语言路由
解读任何发现之前,先阅读目标语言的指南;每份指南都会列出该语言中的危险指令和符合惯用写法的常数时间替代方案。
| 指南 | 语言 |
|---|---|
| references/compiled.md | C、C++、Go、Rust |
| references/swift.md | Swift |
| references/vm-compiled.md | Java、C# |
| references/kotlin.md | Kotlin |
| references/php.md | PHP |
| references/javascript.md | JavaScript、TypeScript |
| references/python.md | Python |
| references/ruby.md | Ruby |
运行分析器
分析器接受一个文件,并根据其扩展名检测语言。始终传入 --warnings:
uv run {baseDir}/ct_analyzer/analyzer.py --warnings <source_file>
不传入该选项时,分析器只报告错误级别的发现,也就是除法、取模和弱随机数生成器。四类检测器的严重级别为警告,因此不会报告:依赖机密数据的分支、提前退出比较(memcmp、strcmp、.equals、==)、由机密数据索引的表查找,以及可变时间编码。在实际代码中,对身份验证标签进行提前退出比较是最常见的计时漏洞——Lucky Thirteen 正是如此——因此,默认运行不会提示你最可能遇到的发现。
| 标志 | 效果 |
|---|---|
--warnings | 添加上述四类警告级别的检测项。每次都传入此选项 |
--func <regex> | 将输出限定为名称匹配正则表达式的函数 |
--json | 机器可读输出 |
--github | GitHub Actions 注释 |
--arch <target> | 目标架构(x86_64、arm64、riscv64……)——仅限原生语言 |
--opt-level <level> | 优化级别(O0 到 O3、Os、Oz)——仅限原生语言 |
--compiler <name> | 覆盖编译器选择(gcc、clang、go、rustc、swiftc) |
对于大型文件,可以使用正则表达式将分析范围缩小到处理机密数据的例程,例如 --func 'sign|verify'。
**对原生编译代码(C、C++、Go、Rust、Swift)使用多个 --arch 和 --opt-level 运行。**除法计时和分支降级取决于架构和优化:x86_64 的 IDIV 与 arm64 的 SDIV 行为不同,而 -O2 下的 cmov 在 -O0 下可能变成分支。一次干净的运行只能证明某一种配置安全,而不能证明代码安全。
**--arch 的交叉编译方式取决于工具链。**clang 使用 --target 进行交叉编译,无需第二个编译器,但任何包含 libc 头文件的源代码也需要目标平台的 C 库头文件——例如 libc6-dev-riscv64-cross 及类似软件包——否则会因 bits/libc-header-start.h file not found 而失败。Go 通过 GOARCH 进行交叉编译,但 go tool objdump 没有 riscv64 反汇编器。GNU 交叉工具链是一个独立的二进制文件,因此必须明确指定 gcc 所需的交叉编译器——--compiler x86_64-linux-gnu-gcc、--compiler riscv64-linux-gnu-gcc——不会替你选择任何替代项,因此报告始终会标明实际运行的二进制文件。rustc 需要目标平台的标准库(rustup target add),而 Linux 上的 Swift 只支持主机目标。应与构建产品所用的工具链进行对比,而不是与发行版打包的任意交叉编译版本进行对比。
**修复后,应使用不同编译器和目标,并覆盖包括 Os 和 Oz 在内的所有级别,重新运行完整测试。**任何通过向编译器提供常量除数以便其进行强度削减的修复,都只在编译器选择配合时有效,而这种选择的变化比看起来更大。将 key_coef / (2 * gamma2) 替换为用 #define 定义的除数,在这里仍会生成实际的除法指令:
| 工具链 | 会生成除法指令的级别 |
|---|---|
| gcc riscv64 | O0 到 Oz——所有级别 |
| gcc arm64、gcc x86_64 | Os、Oz |
| clang arm64 | O0、Oz |
强度削减是优化器提供的便利,并非语言保证。优先使用显式的乘法移位,并在完整输入范围内验证其与原始表达式等价,而不是只测试抽样值——差一个 2 的幂的倒数在偏离结果前,可能会对数百万个输入都给出相同结果。
Java、Kotlin 和 C# 会编译为 JVM/CIL 字节码。分析器读取这些字节码,因此 --arch 和 --opt-level 不适用;JIT 仍可能引入分析器无法检测到的可变时间原生代码。
各语言的覆盖限制
覆盖范围并不一致,存在的缺口会影响干净报告的含义:
| 语言 | 报告未覆盖的内容 |
|---|---|
| Go | 仅包含来自已分析文件的符号。go build 会将运行时链接进来;其中的除法操作——全部基于公开数据——否则会主导分析结果 |
| JavaScript、TypeScript | 字节码分析结果仅限于文件中按名称声明的函数,因为 V8 转储 node 内部代码的方式和转储你自己的代码一样。匿名回调由源代码扫描处理。对于 TypeScript,字节码分析结果会标出函数名,但不包含行号,因为 V8 的位置索引对应转译后的输出 |
| Python、Ruby、PHP | 字节码反映的是运行该代码的解释器,而非经 JIT 编译的运行时或其他运行时 |
| Rust | 除非文件声明了 fn main,否则会作为库进行分析;没有调用方的私有函数可能会在分析前被优化掉 |
| Swift | 在 Linux 上以主机平台为目标;iOS 和 macOS 三元组需要 Apple 工具链 |
由于分析结果和未发现问题都取决于配置,报告结果时请说明所用的编译器、架构和优化级别。
要扫描整个目录,请在 shell 中使用循环——该分析器是确定性脚本,每个文件调用一次:
for f in src/crypto/*.c; do uv run {baseDir}/ct_analyzer/analyzer.py --warnings --json "$f"; done
前提条件
| 语言 | 要求 |
|---|---|
| C、C++、Go、Rust | gcc/clang、go、rustc 位于 PATH 中 |
| Swift | Xcode 或 Swift 工具链(swiftc) |
| Java / Kotlin | JDK(javac、javap);Kotlin 还需要 kotlinc |
| C# | .NET SDK 和 ilspycmd(dotnet tool install -g ilspycmd) |
| PHP | 带有 VLD 扩展或 OPcache 的 PHP |
| JavaScript / TypeScript | Node.js |
| Python | Python 3.x |
| Ruby | 支持 --dump=insns 的 Ruby |
如果出现“未找到工具链”错误,请参阅 references/vm-compiled.md,了解 JVM 和 .NET 的安装、macOS 仅 keg 的 PATH 配置以及故障排查。
解读结果
PASSED — 对于运行时使用的配置,没有严重级别为 error 的发现。警告不会影响结果,因此 Result: PASSED 同时显示 Warnings: 6 属于正常情况,但这并不代表结果干净。请先阅读警告列表,再作出结论。
FAILED — 发现危险指令,并按函数报告:
[ERROR] SDIV
Function: decompose_vulnerable
Reason: SDIV has early termination optimization; execution time depends on operand values
分类研判结果
分析器不进行数据流分析。它会标记每条危险指令,无论机密数据是否会流入该指令,因此 FAILED 报告只是一份待处理清单,而非最终判定。将原始输出直接当作漏洞集合报告,是此技能最主要的失误模式。
对于每条被标记的指令,阅读源代码并回答一个问题:某个操作数是否依赖机密数据? 从该指令所在的函数追溯到调用方的输入,然后进行分类:
// FALSE POSITIVE: operands are a buffer length, already public from the ciphertext size
int num_blocks = data_len / 16;
// TRUE POSITIVE: dividend is a private-key coefficient; IDIV/SDIV leaks its magnitude
int32_t q = secret_coef / GAMMA2;
| 问题 | 如果是 |
|---|---|
| 操作数是编译时常量吗? | 很可能是误报 |
| 操作数是公开参数吗——长度、计数、索引上限? | 很可能是误报 |
| 操作数源自密钥、明文、nonce 或令牌吗? | 真阳性 |
| 攻击者能否影响操作数的值? | 真阳性 |
对每个被标记的项目,都要说明判定结果以及作为依据的数据流。无法追溯到机密数据的项目不算发现;请明确说明,而不是悄然略过。
{baseDir}/ct_analyzer/tests/triage_samples/ 按每种语言存放一个已知答案案例:每个 fixture 都将真阳性与分析器以相同方式报告的误报配对,expectations.json 则记录各自的类别及原因。triage_c.c 是最简示例——分析器会同时标记 ct_high_bits 和 ct_block_count 中的除法,正确的分类研判会确认前者并排除后者。
弱 RNG 和编码相关发现需要回答不同的问题。 对于 Math.random、mt_rand、random.randint、System.Random 和 base64_encode,没有任何操作数是机密数据,因此“某个操作数是否依赖机密数据?”无法解决这些问题。应改为询问结果的用途:用于生成 nonce 或密钥种子属于真阳性,用于抖动重试延迟则不属于。这些发现是通过对源代码进行正则表达式扫描得出的,而非来自字节码,因此会标注为 <source> 并附带行号,而不是归属到所在函数——PHP 除外,它会标注函数名。
比较和查表相关发现有各自的问题,也有各自的修复方式。 对于提前退出的比较,应询问任一侧是否为机密数据:比较认证标签、MAC 或密码哈希属于真阳性,比较公开的协议头则不属于。对于表查找,应询问 索引 是否为机密数据——数组内容无关紧要,关键在于是什么选择了该元素。按当前写法,这两类问题都可被利用,因此确认后需要使用该语言的恒定时间原语,而不是重写循环:
| 语言 | 恒定时间比较 |
|---|---|
| C、C++ | CRYPTO_memcmp(OpenSSL)或 sodium_memcmp |
| Go | crypto/subtle.ConstantTimeCompare |
| Rust | subtle crate 的 ConstantTimeEq |
| Java、Kotlin | MessageDigest.isEqual |
| C# | CryptographicOperations.FixedTimeEquals |
| PHP | hash_equals |
| Python | hmac.compare_digest |
| Ruby | OpenSSL.secure_compare |
| JavaScript、TypeScript | crypto.timingSafeEqual |
机密索引查找没有可直接替换的方案:它需要采用位切片或算术形式,并访问每个元素,因此 AES S-box 表是典型案例。通过表编码机密数据——base64_encode、bin2hex、chr/ord——在库中也是同一类问题;PHP 的参考修复方案是 paragonie/constant_time_encoding。
局限性
- 仅静态分析——读取汇编和字节码,不分析运行时行为。缓存时序和其他微架构通道不可见。
- 不进行数据流分析——参见上文的分类研判。
- 依赖配置——不同的编译器、优化级别、架构或运行时版本,可能会从相同的源代码生成不同的指令。
现实影响
- KyberSlash(2023) — ML-KEM 实现中的除法指令导致密钥恢复
- Lucky Thirteen(2013) — CBC 填充验证中的时间差异使明文恢复成为可能
- RSA 时间攻击 — 早期实现通过除法耗时泄露私钥位
参考资料
- Cryptocoding Guidelines — 加密领域的防御性编码
- KyberSlash — 后量子密码学中的除法耗时问题
- BearSSL Constant-Time — 实用的恒定时间技术