Go项目安全评估技术深度解析

本文详细探讨了Go项目的安全评估技术,包括静态分析工具如go-vet和staticcheck的使用,动态测试方法如模糊测试和属性测试,以及编译器配置技巧。通过实际代码示例和工具对比,帮助开发者识别和修复常见安全漏洞。

Go项目安全评估技术

Trail of Bits Assurance实践在夏季Kubernetes评估成功后,收到了大量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/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函数,其返回值应始终为奇数的三的倍数。如果不是,函数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编译器有许多内置功能和指令,有助于发现错误。这些功能隐藏在各种开关中,需要一些配置才能达到我们的目的。

颠覆类型系统

有时当尝试测试系统的功能时,导出的函数不是我们想要测试的。获得对所需函数的可测试

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计