用 go-panikint 检测 Go 中静默的算术错误

Trail of Bits 发布了 go-panikint,一个修改过的 Go 编译器,它将整数溢出转换为显式 panic,从而解决 Go 默认情况下算术运算溢出不报错的问题。他们使用 go-panikint 在 Cosmos SDK 的 RPC 分页逻辑中发现了一个整数溢出漏洞,展示了这种方法如何消除 Go 项目模糊测试的一个主要盲点。

Go 语言的标准整数类型上的算术运算默认是无声的,这意味着溢出会在不触发 panic 的情况下“环绕”。这种行为向模糊测试(fuzzing)活动隐藏了一整类安全漏洞。今天,我们发布了 go-panikint,这是一个修改后的 Go 编译器,它将无声的整数溢出转换为显式的 panic。我们使用它在 Cosmos SDK 的 RPC 分页逻辑中发现了一个真实的整数溢出,展示了这种方法如何为任何模糊测试 Go 项目的人消除一个主要的盲点。(Cosmos SDK 中的问题尚未修复,但已创建了一个 pull request 来缓解它。)

沉默的声音

在 Rust 中,调试版本被设计为在整数溢出时触发 panic,这是一个对模糊测试非常有价值的特性。然而,Go 采取了一种不同的方法。在 Go 中,标准整数类型的算术溢出默认是无声的。这些操作只是简单地“环绕”,这可能是一种有风险的行为,并且是严重漏洞的潜在来源。

这不是一个疏忽,而是 Go 社区中一个经过深思熟虑、长期争论的 设计选择。虽然 Go 的内存安全预防了整个类别的漏洞,但它的整数并不能免受溢出的影响。未检查的算术运算可能导致绕过关键安全检查的逻辑错误。

当然,静态分析工具可以识别潜在的整数溢出。问题是它们经常产生大量的误报。很难知道被标记的代码行是否真的可以被攻击者访问,或者由于周围代码中的缓解检查,溢出是否实际上是无害的。另一方面,模糊测试提供了一个明确的答案:如果你可以用模糊器触发它,那么这个 bug 是真实且可达的。然而,问题仍然是 Go 的默认行为不会导致崩溃,从而使这些 bug 未被检测到。

go-panikint 的工作原理

为了解决这个问题,我们 fork 了 Go 编译器并修改了它的后端。go-panikint 功能的核心是在编译器将代码转换为 静态单赋值 (SSA) 形式(一种较低级别的中间表示 (IR))时注入的。在这个阶段,对于每个数学运算,我们的编译器都会插入额外的检查。如果其中一个检查在运行时失败,它会触发一个带有详细错误消息的 panic。这些运行时检查被直接编译到最终的二进制文件中。

除了算术溢出,go-panikint 还可以检测整数截断问题,即将一个值转换为较小的整数类型会导致数据丢失。这是一个例子:

var x uint16 = 256
result := uint8(x)

图 1:由于不安全的类型转换导致数据丢失的转换

虽然这个特性是有效的,但我们发现在我们的模糊测试活动中,它产生了误报。因此,我们将不再进一步调查,并将专注于算术问题。

让我们分析一下用于将两个数字相加的程序的检查。如果我们编译这个程序,然后反编译它,我们可以清楚地看到这些检查是如何插入的。在这里,if 条件用于检测有符号整数溢出:

  • 情况 1:两个操作数都是负数。结果也应该是负数。如果结果 (sVar23) 变得更大(不那么负或者甚至是正数),这表明发生了有符号溢出。

  • 情况 2:两个操作数都是非负数。结果应该大于或等于每个操作数。如果结果变得小于一个操作数,这表明发生了有符号溢出。

  • 情况 3:只有一个操作数是负数。在这种情况下,不会发生有符号溢出。

if (*x_00 == '+') {
  val = (uint32)*(undefined8 *)(puVar9 + 0x60);
  sVar23 = val + sVar21;
  puVar17 = puVar9 + 8;
  if (((sdword)val < 0 && sVar21 < 0) && (sdword)val < sVar23 ||
      ((sdword)val >= 0 && sVar21 >= 0) && sVar23 < (sdword)val) {
    runtime.panicoverflow(); // <-- panic if overflow caught
  }
  goto LAB_1000a10d4;
}

图 2:来自 Go 程序的反编译乘法示例

使用 go-panikint 非常简单。你只需编译该工具,然后使用生成的 Go 二进制文件代替官方的。所有其他命令和构建过程都完全相同,使其易于集成到现有的工作流程中。

git clone https://github.com/trailofbits/go-panikint
cd go-panikint/src && ./make.bash
export GOROOT=/path/to/go-panikint # 指向 go-panikint 根目录的路径
./bin/go test -fuzz=FuzzIntegerOverflow # 模糊测试我们的 harness

图 3:go-panikint 的安装和使用

让我们用一个非常简单的程序来试试。这个程序没有模糊测试 harness,只有一个 main 函数用于演示目的。

package main
import "fmt"

func main() {
    var a int8 = 120
    var b int8 = 20
    result := a + b
    fmt.Printf("%d + %d = %d\n", a, b, result)
}

图 4:简单的整数溢出 bug

$ go run poc.go # 原生编译器
120 + 20 = -116

$ GOROOT=$pwd ./bin/go run poc.go # go-panikint
panic: runtime error: integer overflow in int8 addition operation

goroutine 1 [running]:
main.main()
    ./go-panikint/poc.go:8 +0xb8
exit status 2

图 5:使用两个编译器运行 poc.go

然而,并非所有的溢出都是 bug;有些是故意的,尤其是在底层代码中,例如 Go 编译器本身,用于随机性或密码学算法。为了处理这些情况,我们构建了两种过滤机制:

  1. 基于源位置的过滤:这允许我们通过白名单一些给定的文件路径来忽略 Go 编译器自身源代码中已知的、故意的溢出。

  2. 代码内注释:任何算术运算都可以通过添加一个简单的注释来标记为非问题,例如 // overflow_false_positive// truncation_false_positive。这可以防止 go-panikint 在依赖于环绕行为的代码上触发 panic。

发现一个真实的 bug

为了验证我们的工具,我们用它对 Cosmos SDK 进行了一次模糊测试活动,并在 RPC 分页逻辑中发现了一个 整数溢出漏洞。当查询中的偏移量和限制参数之和超过 uint64 的最大值时,查询将返回一个空的验证器列表,而不是预期的集合。

// Paginate does pagination of all the results in the PrefixStore based on the
// provided PageRequest. onResult should be used to do actual unmarshaling.
func Paginate(
    prefixStore types.KVStore,
    pageRequest *PageRequest,
    onResult func(key, value []byte) error,
) (*PageResponse, error) {
...
end := pageRequest.Offset + pageRequest.Limit
...

图 6:如果用户提供一个大的偏移量,end 可能会溢出 uint64 并返回一个空的验证器列表

这一发现证明了将模糊测试与运行时检查相结合的力量:go-panikint 将无声的溢出变成了一个清晰的 panic,模糊器将其报告为带有可重现测试用例的崩溃。已经创建了一个 pull request 来缓解这个问题。

研究人员和开发人员的用例

我们构建 go-panikint 时考虑了两个主要的用例:

  1. 安全研究和模糊测试:对于安全研究人员来说,go-panikint 是一个很棒的新工具,可以用于发现 bug。通过简单地替换模糊测试环境中的 Go 编译器,研究人员可以发现两类全新的漏洞,而这些漏洞以前是动态分析无法发现的。

  2. 持续部署和集成:开发人员可以将 go-panikint 集成到他们的 CI/CD 管道中,并有可能发现标准测试运行会错过的 bug。

我们邀请社区在你自己的项目中尝试 go-panikint,将其集成到你的 CI 管道中,并帮助我们发现下一波隐藏的算术 bug。

Go 项目的安全评估技术 2019 年 11 月 7 日\ 随着我们的 Kubernetes 获得成功,Trail of Bits Assurance 实践收到了大量 Go 项目……mrva 简介,一种用于 CodeQL 多仓库变体分析的终端优先方法 2025 年 12 月 11 日\ 我们的新工具 mrva 是一种终端优先工具,用于在本地运行 CodeQL 多存储库变体分析,允许用户……为 LLVM 引入常数时间支持以保护密码学代码 2025 年 12 月 2 日\ Trail of Bits 为 LLVM 开发了常数时间编码支持,以防止编译器破坏密码学……

  • 原文链接: blog.trailofbits.com/202...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
Trail of Bits
Trail of Bits
https://www.trailofbits.com/