GOLANG 代码审计笔记

admin 2022年7月13日21:52:07评论301 views字数 8673阅读28分54秒阅读模式

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

这里的关键词是“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

现在我们可以看到,我们确实清理了我们的输入字符串,也就是说,删除了任何可能不会让我们进入 elttam 的内容。但是,如果我们深入了解一下,从filepath/path.go的第 127 行开始(从 Go v1.18.2 开始,以防文件将来发生变化),我们可以看到该filepath.Clean()函数特别有一个switch-caseto allow '. ./../../../' 类型的输入。也就是说,如果../字符串开始时没有前导分隔符,则该字符串将保持不变。

如果我们通过额外的一轮运行我们之前的测试字符串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

作一个快速的总结,Go中filepath.Clean()将我们从路径遍历攻击中拯救出来是一种误解。此外,我们不必费心在 a 之后直接调用它filepath.Join(),因为Clean()它已经内置了。


GOROUTINE 泄漏

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

虽然对 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")}

这是一个模拟需要用户交互但超时值非常低的功能的快速示例。这意味着除非我们的用户速度非常快,否则超时将在他们做出选择之前发生,因此匿名在goroutine 中的userChoice()函数将永远不会返回并因此泄漏。


安全隐患

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

修复解决

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

func someAction() string {
ch := make(chan string, 1)
timeout := make(chan bool)

FMT.SPRINTF()

熟悉 C 的人也会对 Go 的fmt.Sprintf()感到熟悉这个字符串格式化函数的工作方式与 C 中的类似,其中每个格式化verb2格式化连续的参数到目前为止,这很容易,并且本质上没有任何问题(与 C 相比,Gofmt.Sprintf()内存是安全的)。然而,开发人员在不应该使用此功能的地方,还使用此功能的情况很多。

创建主机:端口字符串

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

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

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

为避免此问题,使用net.JoinHostPort将按以下方式创建一个字符串:[host]:port,这是一个普遍接受的连接字符串。

未转义的控制字符

fmt.Sprintf()最常用的格式化动词之一是熟悉的%s,它表示纯字符串。但是,如果在 REST API 调用中使用这样的格式化字符串会发生什么,例如:

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

要记住的重要一点是%s格式化动词表示纯字符串。用户可以注入控制字符,例如xA换行符或xB制表符。在大多数情况下,这可能会导致各种标头注入漏洞。

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

  • 例如,使用%q格式化动词,它将创建一个带引号的字符串,并带有编码的控制字符

  • 使用strconv.Quote()which 将引用字符串并编码控制字符

UNSAFE

Go 有一个恰当其名的包:unsafe正如文档所解释的(以及有关其使用的警告):

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

从安全角度来看,其功能的典型用途是与syscall包一起使用(不过还有许多其他功能)。要理解为什么这种配对很常见,我们需要了解一下Go 中的 unsafe.Pointeruintptr分别是什么。

简单地说,unsafe.Pointer是 Go 内置类型(就像stringmap,chan等),这意味着它在内存中有一个关联的 Go 对象。基本上任何 Go 指针都可以被强制转换unsafe.Pointer,这将告诉编译器不要对对象执行边界检查——即开发人员可以告诉 Go 编译器绕过其类型安全。除此之外,uintptr基本上只是unsafe.Pointer指向内存地址的整数表示。

现在回到syscall正如具有内核或 C 编程知识的读者所期望的那样,系统调用在已编译的 Go 二进制文件“下方”运行,这意味着他们在调用时期望原始指针——他们不知道如何处理完整的 Gounsafe.Pointer对象。因此,当我们想从 Go 程序调用系统调用时,我们需要将 an 转换unsafe.Pointer为 anuintptr以丢失 Go 对象在内存中的所有附加数据。这会将指针转换为指针指向的内存地址的简单整数表示:

rawPointer := uintptr(unsafe.Pointer(pointer))

到目前为止,一切都很好。我们目前讨论的另一件重要的事情是 Go 有一个non-generational concurrent, tri-color mark and sweep垃圾收集器。这是合理的,所以我们只关注它是并发的这一事实。简单来说,这意味着我们无法确定在 Go 程序运行期间何时会发生垃圾收集。

把这两件事放在一起,有眼光的读者会意识到,如果在从unsafe.Pointerto的转换uintptr和在系统调用中使用之间发生垃圾收集,我们可能会将内存中完全不同的结构传递给系统调用。这是因为垃圾收集器可能会在内存中移动对象,但不会更新uintptr,因此与我们执行转换时相比,该地址可能包含完全不同的数据。

安全隐患

同样,与其他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)}

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

loltan@ubuntu:~/Desktop/symlink$ echo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA > test.txtloltan@ubuntu:~/Desktop/symlink$ ./symlink Executable location: /home/loltan/Desktop/symlink/symlink/home/loltan/Desktop/symlink/test.txtNumber of bytes read: 16Bytes: AAAAAAAAAAAAAAAA

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

loltan@ubuntu:~/Desktop/symlink$ echo BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB > bin/test.txtloltan@ubuntu:~/Desktop/symlink$ mv symlink symlink.bakloltan@ubuntu:~/Desktop/symlink$ ln -s bin/symlink symlink

现在出现了奇怪的行为。在 Linux 上,Go 遵循符号链接,因此将尝试从以下位置读取配置文件/home/user/Documents/

loltan@ubuntu:~/Desktop/symlink$ ./symlinkExecutable location: /home/loltan/Desktop/symlink/bin/symlink/home/loltan/Desktop/symlink/bin/test.txtNumber of bytes read: 16Bytes: BBBBBBBBBBBBBBBB

在 MacOS 和 Windows 上,程序不会遵循符号链接,而是会在Desktop

安全隐患

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

修复解决

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

附参考:

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

GOLANG 代码审计笔记


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

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年7月13日21:52:07
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   GOLANG 代码审计笔记http://cn-sec.com/archives/1176173.html

发表评论

匿名网友 填写信息