Go 中一些 bug 类的快速总结

介绍

整理在 Internet 上找到的各种资源和方法,同时添加一些关于可能导致安全漏洞的语言怪癖注释。

前言

在过去的几年里,go已经成为多种用途的语言。它的编译和垃圾回收特性以及对许多架构的简单交叉编译支持使其成为嵌入式和物联网项目的理想选择,而 goroutine 的简单性使其成为基于 Web 的项目的理想选择,这得到了许多第三方库的进一步支持。更不用说它现在原生支持模糊测试的内置测试框架了。

由于该语言的流行,在这篇博文中,我们想重点介绍一些代码结构,以供安全工程师注意。

关于 Golang 版本的快速说明:本文中的示例在 Go v1.17 和 v1.18 上进行了测试。未来对语言的更新可能会改变所描述的行为。

入门

不过,在我们进入有趣的部分之前,我想花点时间强调几个在面对庞大的代码库时非常方便的工具。为了了解我们的方向,我们当然可以从 Web 项目中的路由处理程序或命令行工具中的函数调度程序开始,然后从那里开始。但是,如果我们想快速了解一些热点、复杂性高的文件,我们有两个工具可以帮助我们:

  • abcgo,它将测量每个文件的ABC软件指标

  • gocyclo可以帮助我们了解每个程序复杂度的软件度量工具

借助这两个工具的信息和我们首选的代码审查方法,我们可以快速找到代码库中可能存在一些错误的部分。

Bug类

尽管 Go 是一种内存管理语言,默认情况下非常注重安全性,但开发人员仍然需要注意一些危险的代码结构。这篇博文的目的不是列出每个相关的错误类别,但如果有人感兴趣,一个好的起点是各种可用的 semgrep规则 包- 或查看gosec。粗略地说,最常见的 Go 安全漏洞可分为以下几类:

  • 密码错误配置

  • 输入验证(SQLi、RCE 等)

  • 文件/URL 路径处理

  • 文件权限(不太常见,因为默认权限自~v1.17 以来非常安全)

  • 并发问题(goroutine 泄漏、条件竞争等)

  • 拒绝服务

  • 检查时间、使用时间问题

我们将重点介绍几个模式,而不是逐一介绍这些类别,具有高影响力、晦涩难懂、太常见、相对未知、有趣,或是这些模式的任意组合。

此外,作为以下讨论的一般说明:并非所有bug都可能存在安全风险,或者至少不会立即显现出来。为了以通用的方式来描述这些模式,我们必须找出很多上下文,这意味着如果读者在代码审查中遇到任何这些,他们将不得不单独找出影响。有时bug只是一个bug,但在某些情况下,它们才会成为严重的安全漏洞。


目录遍历

为了简化一些更晦涩的BUG类,让我们从一些相对简单但在 Go 代码中太常见的东西开始。路径/目录遍历是一个经典漏洞,攻击者可以通过提供恶意输入与服务器的文件系统进行交互。这通常采用添加多个“../”或“..”的形式来控制文件路径。

path/filepath
filepath.Join()filepath.Clean()
filepath.Join()

Join joins any number of path elements into a single path, separating them with an OS specific Separator. Empty elements are ignored. The result is Cleaned

filepath.Clean()filepath.Clean()
  1. 将多个分隔符元素替换为一个

  2. 消除每一个 . 路径名元素(当前目录)

  3. 消除每个内部 .. 路径名元素(父目录)以及它之前的非 .. 元素

  4. 消除以根路径开头的 .. 元素:即,假设分隔符为“/”,将路径开头的“/..”替换为“/”

如果是粗略一看文档,这可以很容易地以确保该函数防止目录遍历攻击的方式阅读。

但是,如果我们准备一个快速测试程序,我们可以看到它并没有完全做到这一点:

package mainimport (
"fmt"
"path/filepath")func main() {
strings := []string{
"/elttam/./../elttam",
"elttam/../elttam",
"..elttam/./../elttam",
"elttam/../../../../../../../elttam/elttam",
}
domain := "elttam.com"
for _, s := range strings {
fmt.Println(filepath.Join(domain, s))
}}

输出:

❯ go run path-traversal.goelttam.com/elttamelttam.com/elttamelttam.com/elttam../../../../../elttam/elttam
filepath.Clean()switch-case
filepath.Clean()filepath.Join()switch-case
package mainimport (
"fmt"
"path/filepath")func main() {
strings := []string{
"/elttam/./../elttam",
"elttam/../elttam",
"..elttam/./../elttam",
"elttam/../../../../../../../elttam/elttam",
}
domain := "elttam.com"
for _, s := range strings {
fmt.Println(filepath.Clean(filepath.Join(domain, s)))
}}

输出:

❯ go run path-traversal-clean.goelttam.com/elttamelttam.com/elttamelttam.com/elttam../../../../../elttam/elttam
filepath.Clean()filepath.Join()Clean()


GOROUTINE 泄漏

Go 易于使用的并发模型也承载了最微妙但最突出的bug类别之一。你们可能熟悉竞争条件,但可能没有听说过 goroutine 泄漏。

虽然对 goroutine 泄漏有很好的描述,但为了完整起见,让我在这里也快速解释一下。

go
package mainimport (
"fmt"
"time")func print(s []string) {
for _, v := range s {
fmt.Println(v)
}}func main() {
strings := []string{"one", "two", "three", "four", "five"}

go print(strings)
time.Sleep(3 * time.Second)
fmt.Println("Exiting")}

输出:

❯ go run goroutines-test.goonetwothreefourfiveExiting

关于 goroutine 的有趣之处在于调用函数不必等待它们返回,然后再返回自身。在上述情况下,它通常会导致程序在我们在控制台上看到任何打印之前退出。这是 goroutine 泄漏问题的一部分。

另一个导致 goroutine 泄漏的重要 Go 概念是通道。正如Go by Example 网站所解释的:

Channels are the pipes that connect concurrent goroutines.

在其基本用法中,我们可以向通道发送数据或从通道接收数据:

package mainimport (
"fmt")func mod(min int, max int, div int, signal chan int) {
for n := min; n <= max; n++ {
if n%div == 0 {
signal <- n
}
}}func main() {
fsignal := make(chan int)
ssignal := make(chan int)
go mod(15, 132, 14, fsignal)
go mod(18, 132, 17, ssignal)
fourteen, seventeen := <-fsignal, <-ssignal
fmt.Println("Divisible by 14: ", fourteen)
fmt.Println("Divisible by 17: ", seventeen)}

在这个例子中,我们有阻塞的、无缓冲的通道。两者齐头并进,因为无缓冲通道旨在用于同步操作,在从通道接收到数据之前程序无法继续执行,因此它会阻止进一步的执行。

当一个无缓冲的通道由于其调用函数已经返回而没有机会在其通道上发送数据时,就会发生 Goroutine 泄漏。这意味着挂起的 goroutine 将保留在内存中,因为垃圾回收器总是会看到它在等待数据。以这个例子为例:

package mainimport (
"fmt"
"time")func userChoice() string {
time.Sleep(5 * time.Second)
return "right choice"}func someAction() string {
ch := make(chan string)
timeout := make(chan bool)
go func() {
res := userChoice()
ch <- res
}()
go func() {
time.Sleep(2 * time.Second)
timeout <- true
}()
select {
case <-timeout:
return "Timeout occured"
case userchoice := <-ch:
fmt.Println("User made a choice: ", userchoice)
return ""
}}func main() {
fmt.Println(someAction())
time.Sleep(1 * time.Second)
fmt.Println("Exiting")}
userChoice()函数

此bug类的安全影响在很大程度上取决于上下文,但很可能会导致拒绝服务情况。需要注意的是,只有当程序有足够长的生命周期并且它启动了足够多的 goroutine 以耗尽内存资源时,这才会成为一个问题。同样,这在很大程度上取决于每个 Go 程序的用例和环境,严重影响此类错误的影响。

最简单的修复方法是使用缓冲通道,这意味着goroutine的非阻塞(异步)行为:

func someAction() string {
ch := make(chan string, 1)
timeout := make(chan bool)
FMT.SPRINTF()
fmt.Sprintf()感到熟悉fmt.Sprintf()

开发人员经常会做类似以下的事情:

target := fmt.Sprintf("%s:%s", hostname, port)

乍一看,这行代码看起来像是在主机名后面附加了一个端口,主机名由冒号分隔,可能是为了以后连接到服务器。但是,如果主机名是IPv6地址,会发生什么情况?在这种情况下,如果在网络连接中使用生成的字符串,则网络库第一次遇到冒号时,它将假设它是协议分隔符,这至少会创建一个异常。

net.JoinHostPort[host]:port
%s
URI := fmt.Sprintf("admin/updateUser/%s", userControlledParam)resp, err := http.Post(filepath.Join("https://victim.site/", URI), "application/json", body)
%sxAxB

为此,我们有两种可能的解决方案:

%qstrconv.Quote()
UNSAFE
unsafe

Package unsafe contains operations that step around the type safety of Go programs.

syscall
stringmapchanunsafe.Pointerunsafe.Pointer
syscallunsafe.Pointerunsafe.Pointeruintptr
rawPointer := uintptr(unsafe.Pointer(pointer))
non-generational concurrent, tri-color mark and sweep
unsafe.Pointeruintptruintptr

同样,与其他Go奇怪之处类似,这个bug的影响实际上取决于上下文。首先,由于uintptr下的垃圾收集而导致内存更改的可能性非常低。然而,这种可能性将显著增加我们的goroutine和程序运行的时间。我们很可能会遇到无效的指针解引用异常,但也有可能将此类错误转化为可以利用的漏洞。

避免这个问题相当容易:到uintptr的转换及其使用需要在原子操作步骤中进行,例如:

_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), request, uintptr(unsafe.Pointer(pointer)))
OS.EXECUTABLE()

这个函数行为的有趣之处在于它如何处理符号链接将取决于操作系统——这在 Go 中是相对奇怪的设定。

让我们以这个例子为例,它读取一个假定的配置文件,该文件假定位于我们运行程序的同一目录中:

func withoutEval() string {
execBin, _ := os.Executable()
path, err := filepath.Abs(filepath.Dir(execBin))
if err != nil {
log.Fatalf("error %vn", err)
}
fmt.Println("Path without filepath.EvalSymlinks():", path)
return path}func printFile(path string) {
fname := "test.txt"
abspath := filepath.Join(path, fname)
file, err := os.Open(abspath)
if err != nil {
log.Fatal(err)
}
defer file.Close()
fbytes := make([]byte, 16)
bytesRead, err := file.Read(fbytes)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Number of bytes read: %dn", bytesRead)
fmt.Printf("Bytes: %sn", fbytes)}

当我们在没有任何符号链接的情况下运行这个程序时,我们会得到预期的行为,即配置文件将从主二进制文件所在的同一目录中读取:

[email protected]:~/Desktop/symlink$ echo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA > test.txt[email protected]:~/Desktop/symlink$ ./symlink Executable location: /home/loltan/Desktop/symlink/symlink/home/loltan/Desktop/symlink/test.txtNumber of bytes read: 16Bytes: AAAAAAAAAAAAAAAA

另外,假设我们的实际二进制文件位于其他目录中,并且我们通过符号链接运行我们的程序,例如:

[email protected]:~/Desktop/symlink$ echo BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB > bin/test.txt[email protected]:~/Desktop/symlink$ mv symlink symlink.bak[email protected]:~/Desktop/symlink$ ln -s bin/symlink symlink
/home/user/Documents/
[email protected]:~/Desktop/symlink$ ./symlinkExecutable location: /home/loltan/Desktop/symlink/bin/symlink/home/loltan/Desktop/symlink/bin/test.txtNumber of bytes read: 16Bytes: BBBBBBBBBBBBBBBB
Desktop

与其他微妙的代码模式一样,这将取决于上下文。但是为了给读者一个想法,让我们假设,例如提权,我们有一个长期运行的Go二进制文件,例如服务或类似文件,位于一个受低权限用户保护的位置,配置文件指示一些安全选项,例如针对服务器或类似物的主机证书验证。在Windows和MacOS上,即使用户权限较低,我们也可以在我们控制的位置创建指向该二进制文件的符号链接,并在那里添加修改后的配置文件,该文件将在程序下次运行(或发生配置重新分析)时被程序读取。实际上,这使攻击者能够覆盖安全设置,即使是从低权限用户帐户。

修复相对简单。我们只需要传递操作系统的结果。os.Executable()到os.EvalSymlinks()。此函数会检查路径是否为符号链接,如果是,则返回链接指向的绝对路径。

附参考:

https://go.dev/doc/fuzz/

GOLANG 代码审计笔记


原文始发于微信公众号(军机故阁):GOLANG 代码审计笔记