Go项目安全评估技术 - Trail of Bits博客
Trail of Bits博客
Go项目安全评估技术
Robert Tonic
2019年11月7日
编译器、动态分析、教育、模糊测试、Go、Kubernetes、静态分析
随着我们今年夏天Kubernetes评估的成功,Trail of Bits Assurance实践收到了大量Go项目。因此,我们一直在调整用于其他编译语言的安全评估技术和策略,使其适用于Go项目。
我们首先从理解语言设计开始,识别开发人员可能不完全理解语言语义功能的领域。这些被误解的语义很多源于我们向客户报告的发现结果以及对语言本身的独立研究。虽然不全面,但这些问题领域包括作用域、协程、错误处理和依赖管理。值得注意的是,许多这些问题与运行时没有直接关系。Go运行时本身设计为默认安全,防止了许多类似C的漏洞。
在更好地理解根本原因后,我们寻找现有工具来帮助我们快速有效地检测客户端代码库。结果是一组静态和动态开源工具的样本,包括几个与Go无关的工具。为了补充这些工具,我们还确定了几个有助于检测的编译器配置。
静态分析
由于Go是一种编译语言,编译器甚至在生成二进制可执行文件之前就会检测并防止许多潜在的错误模式。虽然这对新的Go开发人员来说是一个主要的烦恼,但这些警告在防止意外行为和保持代码清洁可读方面极其重要。
静态分析往往会捕获很多编译器错误和警告中未包含的"低垂果实"。在Go生态系统中,有许多不同的工具,如go-vet、staticcheck以及analysis包中的工具。这些工具通常识别变量遮蔽、不安全指针使用和未使用的函数返回值等问题。调查这些工具显示警告的项目区域通常会导致可利用的功能。
这些工具绝非完美。例如,go-vet可能会遗漏非常常见的意外情况,如下例所示,其中函数A的err返回值未被使用,并在表达式左侧分配bSuccess时立即重新分配。编译器不会提供警告,go-vet也不会检测到这一点;errcheck也不会。实际上,成功识别这种情况的工具(非详尽)是前述的staticcheck和ineffassign,它们将A的err返回值识别为未使用或无效。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
package main
import "fmt"
func A() (bool, error) { return false, fmt.Errorf("I get overridden!") }
func B() (bool, error) { return true, nil }
func main() {
aSuccess, err := A()
bSuccess, err := B()
if err != nil {
fmt.Println(err)
}
fmt.Println(aSuccess, ":", bSuccess)
}
|
图1:一个示例程序,显示err的无效赋值欺骗了go-vet和errcheck,使其认为err已被检查。
1
2
3
4
5
6
7
8
9
10
|
$ go run .
false : true
$ errcheck .
$ go vet .
$ staticcheck .
main.go:5:50: error strings should not be capitalized (ST1005)
main.go:5:50: error strings should not end with punctuation or a newline (ST1005)
main.go:10:12: this value of err is never used (SA4006)
$ ineffassign .
<snip>/main.go:10:12: ineffectual assignment to err
|
图2:示例程序的输出,以及errcheck、go-vet、staticcheck和ineffassign的输出。
当你深入研究这个例子时,你可能会想知道为什么编译器没有警告这个问题。当变量在程序中未使用时,Go编译器会报错,但这个例子成功编译。这是由"短变量声明"的语义引起的。
1
|
ShortVarDecl = IdentifierList ":=" ExpressionList .
|
图3:“短变量声明"的语法规范。
根据规范,短变量声明具有重新声明变量的特殊能力,只要:
- 重新声明在多变量短声明中
- 重新声明的变量在同一块或函数参数列表中较早声明
- 重新声明的变量与先前声明类型相同
- 声明中至少有一个非空白变量是新的
所有这些约束在前面的例子中都成立,阻止编译器为此问题产生错误。
许多工具都有这样的边缘情况,它们无法识别相关问题,或者识别问题但描述不同。更复杂的是,这些工具通常需要在执行分析之前构建Go源代码。如果分析人员无法轻松构建代码库或其依赖项,这会使第三方安全评估变得复杂。
尽管存在这些缺陷,但当放在一起时,可用的工具只需一点努力就可以提供很好的提示,指示在给定项目中寻找问题的位置。我们建议至少使用gosec、go-vet和staticcheck。它们为大多数代码库提供了最好的文档和人体工程学。它们还为常见问题提供了各种检查(如ineffassign或errcheck),而不会过于具体。然而,为了更深入地分析特定类型的问题,可能必须使用更具体的分析器,直接针对SSA开发自定义工具,或使用$emmle。
动态分析
执行静态分析并审查结果后,动态分析技术通常是获得更深入结果的下一步。由于Go的内存安全性,通常通过动态分析发现的问题会导致硬崩溃或程序状态无效。已经构建了各种工具和方法来帮助识别Go生态系统中的这些类型的问题。此外,还可以改造现有的与语言无关的工具用于Go软件的动态测试,我们接下来展示这一点。
模糊测试
Go领域最著名的动态测试工具可能是Dimitry Vyukov的dvyukov/go-fuzz实现。这个工具允许你快速有效地实现变异模糊测试。它甚至有一个广泛的战利品墙。更高级的用户在寻找错误时可能还会发现分布式模糊测试和libFuzzer支持有用。
Google还制作了一个更原始的模糊测试器,名称令人困惑地相似,google/gofuzz,它通过用随机值初始化结构来帮助用户。与Dimitry的go-fuzz不同,Google的gofuzz不生成harness或帮助存储崩溃输出、模糊输入或任何其他类型的信息。虽然这对测试某些目标可能是一个缺点,但它构成了一个轻量级和可扩展的框架。
google/gofuzz#gofuzz
dvyukov/go-fuzz#usage
属性测试
与更传统的模糊测试方法不同,Go的testing包(通常用于单元和集成测试)提供了testing/quick子包用于Go函数的"黑盒测试”。换句话说,它是属性测试的基本原语。给定一个函数和生成器,该包可用于构建harness来测试给定输入生成器范围内可能的属性违规。以下示例直接来自文档。
1
2
3
4
5
6
7
8
9
|
func TestOddMultipleOfThree(t *testing.T) {
f := func(x int) bool {
y := OddMultipleOfThree(x)
return y%2 == 1 && y%3 == 0
}
if err := quick.Check(f, nil); err != nil {
t.Error(err)
}
}
|
图4:正在测试OddMultipleOfThree函数,其返回值应始终为3的奇数倍。如果不是,函数f将返回false,属性将被违反。这由quick.Check函数检测。
虽然此包提供的功能对于属性测试的简单应用是可以接受的,但重要属性通常不太适合如此基本的接口。为了解决这些缺点,leanovate/gopter框架诞生了。Gopter为常见的Go类型提供了各种生成器,并提供了帮助程序来协助你创建与Gopter兼容的自定义生成器。通过gopter/commands子包还支持状态测试,这对于测试属性在操作序列中保持很有用。更复杂的是,当属性被违反时,Gopter会缩小生成的输入。在下面的输出中查看属性测试与输入缩小的简要示例。
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
|
package main_test
import (
"github.com/leanovate/gopter"
"github.com/leanovate/gopter/gen"
"github.com/leanovate/gopter/prop"
"math"
"testing"
)
type Compute struct {
A uint32
B uint32
}
func (c *Compute) CoerceInt () { c.A = c.A % 10; c.B = c.B % 10; }
func (c Compute) Add () uint32 { return c.A + c.B }
func (c Compute) Subtract () uint32 { return c.A - c.B }
func (c Compute) Divide () uint32 { return c.A / c.B }
func (c Compute) Multiply () uint32 { return c.A * c.B }
func TestCompute(t *testing.T) {
parameters := gopter.DefaultTestParameters()
parameters.Rng.Seed(1234) // 仅为此示例生成可重现的结果
properties := gopter.NewProperties(parameters)
properties.Property("Add should never fail.", prop.ForAll(
func(a uint32, b uint32) bool {
inpCompute := Compute{A: a, B: b}
inpCompute.CoerceInt()
inpCompute.Add()
return true
},
gen.UInt32Range(0, math.MaxUint32),
gen.UInt32Range(0, math.MaxUint32),
))
properties.Property("Subtract should never fail.", prop.ForAll(
func(a uint32, b uint32) bool {
inpCompute := Compute{A: a, B: b}
inpCompute.CoerceInt()
inpCompute.Subtract()
return true
},
gen.UInt32Range(0, math.MaxUint32),
gen.UInt32Range(0, math.MaxUint32),
))
properties.Property("Multiply should never fail.", prop.ForAll(
func(a uint32, b uint32) bool {
inpCompute := Compute{A: a, B: b}
inpCompute.CoerceInt()
inpCompute.Multiply()
return true
},
gen.UInt32Range(0, math.MaxUint32),
gen.UInt32Range(0, math.MaxUint32),
))
properties.Property("Divide should never fail.", prop.ForAll(
func(a uint32, b uint32) bool {
inpCompute := Compute{A: a, B: b}
inpCompute.CoerceInt()
inpCompute.Divide()
return true
},
gen.UInt32Range(0, math.MaxUint32),
gen.UInt32Range(0, math.MaxUint32),
))
properties.TestingRun(t)
}
|
图5:Compute结构的测试harness。
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
32
33
|
user@host:~/Desktop/gopter_math$ go test
+ Add should never fail.: OK, passed 100 tests.
Elapsed time: 253.291µs
+ Subtract should never fail.: OK, passed 100 tests.
Elapsed time: 203.55µs
+ Multiply should never fail.: OK, passed 100 tests.
Elapsed time: 203.464µs
! Divide should never fail.: Error on property evaluation after 1 passed
tests: Check paniced: runtime error: integer divide by zero
goroutine 5 [running]:
runtime/debug.Stack(0x5583a0, 0xc0000ccd80, 0xc00009d580)
/usr/lib/go-1.12/src/runtime/debug/stack.go:24 +0x9d
github.com/leanovate/gopter/prop.checkConditionFunc.func2.1(0xc00009d9c0)
/home/user/go/src/github.com/leanovate/gopter/prop/check_condition_func.g
o:43 +0xeb
panic(0x554480, 0x6aa440)
/usr/lib/go-1.12/src/runtime/panic.go:522 +0x1b5
_/home/user/Desktop/gopter_math_test.Compute.Divide(...)
/home/user/Desktop/gopter_math/main_test.go:18
_/home/user/Desktop/gopter_math_test.TestCompute.func4(0x0, 0x0)
/home/user/Desktop/gopter_math/main_test.go:63 +0x3d
# <snip for brevity>
ARG_0: 0
ARG_0_ORIGINAL (1 shrinks): 117380812
ARG_1: 0
ARG_1_ORIGINAL (1 shrinks): 3287875120
Elapsed time: 183.113µs
--- FAIL: TestCompute (0.00s)
properties.go:57: failed with initial seed: 1568637945819043624
FAIL
exit status 1
FAIL _/home/user/Desktop/gopter_math 0.004s
|
图6:执行测试harness并观察属性测试的输出,其中Divide失败。
错误注入
在攻击Go系统时,错误注入出乎意料地有效。我们使用此方法发现的最常见错误涉及错误类型的处理。由于错误在Go中只是一种类型,当它返回时,它不会像panic语句那样自行改变程序的执行流程。我们通过从最低级别(内核)强制执行错误来识别此类错误。因为Go生成静态二进制文件,所以必须在没有LD_PRELOAD的情况下注入错误。我们的一个工具KRF允许我们做到这一点。
在我们最近对Kubernetes代码库的评估中,使用KRF在供应商依赖项深处提供了一个发现,只需随机故障由进程及其子进程生成的读写系统调用。这种技术对Kubelet有效,它通常与底层系统接口。当ionice命令被故障时触发错误,不向STDOUT输出任何内容并向STDERR发送错误。记录错误后,执行继续而不是将STDERR中的错误返回给调用者。这导致STDOUT后来被索引,引起索引超出范围的运行时panic。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
E0320 19:31:54.493854 6450 fs.go:591] Failed to read from stdout for cmd [ionice -c3 nice -n 19 du -s /var/lib/docker/overlay2/bbfc9596c0b12fb31c70db5ffdb78f47af303247bea7b93eee2cbf9062e307d8/diff] - read |0: bad file descriptor
panic: runtime error: index out of range
goroutine 289 [running]:
k8s.io/kubernetes/vendor/github.com/google/cadvisor/fs.GetDirDiskUsage(0xc001192c60, 0x5e, 0x1bf08eb000, 0x1, 0x0, 0xc0011a7188)
/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/fs/fs.go:600 +0xa86
k8s.io/kubernetes/vendor/github.com/google/cadvisor/fs.(*RealFsInfo).GetDirDiskUsage(0xc000bdbb60, 0xc001192c60, 0x5e, 0x1bf08eb000, 0x0, 0x0, 0x0)
/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/fs/fs.go:565 +0x89
k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common.(*realFsHandler).update(0xc000ee7560, 0x0, 0x0)
/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common/fsHandler.go:82 +0x36a
k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common.(*realFsHandler).trackUsage(0xc000ee7560)
/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common/fsHandler.go:120 +0x13b
created by
k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common.(*realFsHandler).Start
/workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/vendor/github.com/google/cadvisor/container/common/fsHandler.go:142 +0x3f
|
图7: resulting Kubelet panic的缩短调用栈。
1
2
3
4
|
stdoutb, souterr := ioutil.ReadAll(stdoutp)
if souterr != nil {
klog.Errorf("Failed to read from stdout for cmd %v - %v", cmd.Args, souterr)
}
|
图8:记录STDERR而不将错误返回给调用者。
1
|
usageInKb, err := strconv.ParseUint(strings.Fields(stdout)[0], 10, 64)
|
图9:尝试索引STDOUT,即使它是空的。这是运行时panic的原因。
有关包含重现步骤的更完整演练,我们的Kubernetes最终报告在附录G(第109页)中详细介绍了对Kubelet使用KRF。
Go的编译器还允许在二进制文件中包含检测,从而允许在运行时检测竞争条件。这对于作为攻击者识别潜在可利用的竞争非常有用,但它也可以用于识别defer、panic和recover的不正确处理。我们构建了trailofbits/on-edge来做到这一点:识别函数入口点和函数panic点之间的全局状态变化,并通过Go竞争检测器泄露此信息。关于OnEdge的更深入使用可以在我们之前的博客文章"Panicking the Right Way in Go"中找到。
在实践中,我们建议使用:
- dvyukov/go-fuzz为解析输入的组件构建harness
- google/gofuzz测试结构验证
- leanovate/gopter增强现有的单元和集成测试并测试规范正确性
- trailofbits/krf和trailofbits/on-edge测试错误处理
所有这些工具,除了KRF,在实践中都需要一些努力才能使用。
利用编译器优势
Go编译器有许多内置功能和指令,有助于发现错误。这些功能隐藏在各种开关中,需要一些配置才能达到我们的目的。
颠覆类型系统