GO语言安全审计
bcloud 发表于 四川 WEB安全 1613浏览 · 2024-07-16 14:35

前言

最近一直有在学习GO语言,互联网上有许多关于基于GO语言搭建的网站的代码审计文章,但是GO语言本身的语言特性导致的错误或者BUG是最重要的。在这几年GO凭借它的多线程技术优势和垃圾回收机制特性以及多架构进行轻松编译,导致GO语言容错率极高,使其成为了嵌入式领域和物联网IOT设备项目的理想选择,而基于go语言的goroutines的简单性使之成为了GO语言搭建web网站的理想选择了,许多的第三方库也支持goroutines。而且内置的测试框架支持模糊匹配测试了,更是极大方便测试人员。
https://go.dev/doc/security/fuzz/
这里解释一下goroutines,goroutines是Go语言中的一种轻量级线程机制,用于并发执行代码。与传统操作系统线程相比,Goroutines 的创建和切换开销非常低,使得它们非常适合高并发应用,所以对于网站开发非常友好。

基础

不过在重点讲解之前我们还需要了解一下面对代码量极多的情况比较有用的几个帮我我们审查的工具。当然如果不使用工具的话我们也可以从web网站的路由处入手来进行审查或者进行调试函数来查看是否存在非预期情况。不过为了加快我们的审计时间以及快速定位可疑性文件还是需要借助工具。

ABCGo

https://github.com/droptheplot/abcgo
该工具引入了ABC指标来衡量代码质量。ABC指标是软件度量的一种,由复杂性(A)、基本路径覆盖(B)和类的数量(C)组成,用于评估代码的可维护性和复杂性。这个项目不仅提供了核心的ABC分析功能,还附带了一个Vim插件,方便开发者在Vim编辑器中直接查看和分析代码质量。

gocyclo

https://github.com/fzipp/gocyclo
该工具用于检测复杂度。比如说for循环,如果改代码嵌套了三四层循环那么代码复杂读不言而喻,可能就需要进行拆解分析使代码看起来更为简洁明了

漏洞类型

GO语言是一款高级的内存管理语言,因此安全重视程度不言而喻,但是在网络安全中没有绝对的安全,语言特性导致该类型的安全情况就是会产生,就比如java的反序列化漏洞,即使已经这么多年了每年还是会爆出许多漏洞。因此GO语言的安全漏洞主要分为如下几类,当然不止如下几类,如果需要了解更多可以参考Semgrep工具,该工具是一个开源的静态代码分析工具,旨在帮助开发人员和安全专家发现和修复软件代码中的安全漏洞、代码质量问题和最佳实践违规等。

1.输入验证过滤不严谨导致的RCE,SQL注入等
2.URL处理不当导致目录遍历,路径穿越情况
3.文件权限设置不严格导致越权操作等情况
4.并发问题,主要是goroutine泄露问题
5.DOS攻击导致拒绝服务

除了上述列出来的五个还有许多,我会列举几个问题讲解,另外由于并非所有的问题都会存在安全风险,所以在进行审计时可能一个漏洞再弄清楚上下文关系使可能会再某种情况就变成了严重的安全漏洞

目录遍历

为了让大家简单的了解一些看起来不起眼,却是审计一个个GO函数而产生的,所以我们从最常见的漏洞目录遍历还是讲解。路径/目录遍历是一种典型的漏洞,攻击者可以通过提供恶意输入与服务器的文件系统进行交互。这通常采取添加多个../或..来控制文件路径的形式。当然这种情况只是最基本的,可能会存在多种变形的情况,这里我们不讨论那么深入。
这里我们要注意的是GO语言中的filepath包的如下两个函数

filepath.Join()
filepath.Clean()

根据多篇文章表明,filepath.Join()这是目录遍历漏洞的常见漏洞触发点,如下为官方文档介绍,大致意思就是Join 将任意数量的路径元素合并成一个单一的路径,并用操作系统特定的分隔符进行分隔。空的路径元素会被忽略。结果就是Cleaned

在上述提到一个关键词Cleaned,就是上面提到的filepath.Clean()函数。根据文档解释,filepath.Clean()的作用如下:

1.用一个分隔符元素替换多个分隔符元素。
2.消除每个 . 路径名元素(当前目录)。
3.消除每个内部 .. 路径名元素(父目录)及其前面的非 .. 元素。
4.消除以根路径开头的 .. 元素:也就是说,假设分隔符是“/”,将路径开头的“/..”替换为“/”。

如果大致的看一下会发现确实可以防止目录遍历攻击,但是并没有完全有效的防止目录遍历攻击,我们看如下测试代码来验证我们的猜想:

package main

import (
    "fmt"
    "path/filepath"
)

func main() {
    strings := []string{
        "/a/./../b",
        "a/../b",
        "..a/./../b",
        "a/../../../../../../../b/c",
    }

    domain := "ytm.com"
    fmt.Println("结果如下:")
    for _, s := range strings {
        fmt.Println(filepath.Join(domain, s))
    }
}

输出结果如下

现在正如上面对于filepath.Clean()解释的情况一样,我们确实清楚了输入的异常字符串,即清理了任何可能不会让我们进入ytm的内容。但是我们看从filepath/path.go的第 127 行开始(Go v1.18.2,其他版本可能有所变化),我们可以看到该filepath.Clean()函数专门允许switch-case'../../../../' 类型的输入。也就是说,如果 dotdot字符串开头没有分隔符,则该字符串将保持不变。


那么我们对上述代码改为如下代码,添加了一层filepath.Clean()函数,我们再次运行会发现最后一句打印语句还是../开头,我们会像预期filepath.Join()的情况一样,因此我们的目录遍历payload保持不变。

package main

import (
    "fmt"
    "path/filepath"
)

func main() {
    strings := []string{
        "/a/./../b",
        "a/../b",
        "..a/./../b",
        "a/../../../../../../../b/c",
    }

    domain := "ytm.com"
    fmt.Println("结果如下:")
    for _, s := range strings {
        fmt.Println(filepath.Clean(filepath.Join(domain, s)))
    }
}

总结

首先我们通过上述两端代码的演示我们可以知道Go语言的filepath.Clean()函数能够有效避免目录遍历攻击是一种误解。并且我们不需要在花心思在filepath.Join()函数中,因为Clean()它已经内置了。

GOROUTINE 泄漏

GO语言的并发模型爆出过许多错误。相信大家对并发中的条件竞争很熟悉,但是对goroutine 泄漏可能听说不多,但是该问题确确实实存在的,可以参考如下文章

https://github.com/kubernetes/kubernetes/pull/5316
https://github.com/pingcap/tiflow/pull/1034/commits/0a9a1c1d07b6cdd70a0cb2221359d8e221bfb57c
https://bugs.launchpad.net/juju/+bug/1813104

虽然goroutine泄漏的文章很多,但是这里还是简单解释一下,让大家了解一下。
有两个基本概念/关键字决定了goroutine。一个是使用前导go关键字的内联函数声明,如下所示:

package main

import (
    "fmt"
    "time"
)

func print(s []string) {
    for _, v := range s {
        fmt.Println(v)
    }
}

func main() {
    strings := []string{"one", "two", "three", "four", "five"}
    fmt.Println("结果如下:")
    go print(strings)

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


goroutine 的有趣之处在于,调用函数不必等待它们返回即可返回自身。在上述情况下,它通常会导致程序在我们看到控制台上的任何打印之前退出。这是 goroutine 泄漏问题的一部分。
导致 goroutine 泄漏的另一个重要 Go 概念是通道。如GO官网解释那样:

通道是连接并发 goroutine的管道。

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

import (
    "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)

该代码展示了如何使用 Goroutines 和 channels 来并发地计算两个数字范围内的数值,并找出能被特定数字整除的第一个数。

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

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

package main

import (
    "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")
}

该代码通过使用 Goroutines 和 channels 来实现超时机制。它会等待用户选择(通过模拟的 userChoice 函数),如果用户选择在超时之前完成,则输出用户的选择;如果在指定时间内没有完成,则返回超时消息。

这个简单的示例用于模拟需要用户交互但超时值很低的功能。这意味着,除非我们的用户反应非常快,否则超时会在它做出选择之前发生,因此goroutine内的userChoice()函数将永远不会返回,从而导致泄漏。

安全问题

此类漏洞的安全影响在很大程度上取决于具体的情况,但最有可能导致拒绝服务的情况。因此,只有当程序的生命周期足够长,并且启动了足够多的 goroutine 来大量消耗内存资源时,这才会成为问题。因此该问题主要是取决于用例和环境才会导致该漏洞产生更严重的影响。

修复方式

最简单的解决方法是使用缓冲通道,这意味着 goroutines 具有非阻塞(异步)行为:

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

fmt.Sprintf()

熟悉C语言的人也会对Go的fmt.Sprintf()感到熟悉。此字符串格式化函数的工作原理与C的类似。到目前为止,这很容易,并且本质上没有任何问题。但是开发人员经常在不该使用的地方使用该函数。

创建服务

开发人员经常会做下面的事情:

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

这行代码乍一看是将主机名和端口组合成一个目标地址字符串,可能是为了稍后连接到服务器。看起来好像没有什么问题,但是如果hostname是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格式化动词表示纯字符串。用户可以注入控制字符,例如\0xA换行符或\xB制表符。在大多数情况下,这可能会导致各种header头注入漏洞。

因此我们有如下两种解决方案:

1.使用%q格式化动词,它将创建一个带编码控制字符的引号字符串

2.可以使用strconv.Quote(),它将引用字符串并编码控制字符

Unsafe包

GO语言中有一个包命名为unsafe,参考文档可知

https://pkg.go.dev/unsafe

Package unsafe contains operations that step around the type safety of Go programs.
该包包含绕过GO程序类型安全的操作

从安全角度来看,其函数的使用用途是与syscall包一起使用,该操作非常常见,但是具体怎么回事,我们需要了解GO语言中的unsafe.Pointer和uintptr分别是什么。

简而言之,unsafe.Pointer 是一个 Go 内置类型(就像 string、map 和 chan 一样),这意味着它在内存中有一个关联的 Go 对象。基本上,任何 Go 指针都可以被转换为 unsafe.Pointer,这会使得编译器不对该对象执行边界检查。也就是说,开发人员可以告诉 Go 编译器绕过其类型安全。除此之外,uintptr 基本上只是 unsafe.Pointer 所指向的内存地址的整数表示。

那么再来看syscall,系统调用在编译后的Go的二进制文件运行时,这意味着它们在调用时需要原始指针,不知道如何处理完整的unsafe.Pointer对象。因此,当我们要 Go程序中调用syscall 时,我们需要将unsafe.Pointer转换为 uintptr,以丢弃 Go 对象在内存中具有的所有其他数据。这将把指针转换为指针所指向的存储器地址的简单整数表示:

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

到这里相信大家对unsafe包有一定的原理了解。那么我们当前讨论的另一个重要内容为Go语言中有一个non-generational concurrent, tri-color mark and sweep垃圾回收器,我们无法知道GO在运行时何时会触发垃圾回收机制。

如果从 unsafe.Pointer 转换为 uintptr 并在 syscall 中使用时触发垃圾收集机制,我们可能会向系统调用传递一个完全不同的内存结构。这是因为垃圾收集器可能会在内存中移动对象,但它不会更新 uintptr,因此该地址可能与我们执行转换时完全不同。

安全问题

同样,与其他Go异常类似,该漏洞的影响实际上取决于上下文。首先,由于垃圾回收而更改内存的机率非常低。但是,这种机率会随着我们拥有的 goroutine 数量和程序运行时间的增加而显著增加。最有可能的是,我们会得到一个无效指针解引用异常,但有可能将这样一个漏洞变成一个利用点。

修复方式

使用如下代码可有效防御该问题:

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

该代码使用Go语言的syscall 包中的 Syscall 函数来进行系统调用 ioctl,它允许程序与操作系统内核进行低级别的设备控制。具体来说,这段代码进行了一次 ioctl 调用,传递了文件描述符、请求代码和一个指向数据的指针,并接收返回的错误代码 errno

OS.EXECUTABLE()

OS.EXECUTABLE()函数如何处理符号链接取决于操作系统,我们可以看如下代码:

func withoutEval() string {
    execBin, _ := os.Executable()
    path, err := filepath.Abs(filepath.Dir(execBin))
    if err != nil {
        log.Fatalf("error %v\n", 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: %d\n", bytesRead)
    fmt.Printf("Bytes: %s\n", fbytes)
}

这段 Go 代码包括两个函数:withoutEvalprintFilewithoutEval 函数获取当前可执行文件的目录路径,并打印它;printFile 函数打开一个指定目录中的文件,并读取其中的一部分内容,然后打印读取到的字节数和字节内容。

那么我们假设读取一个配置文件,该文件假设位于我们运行程序的同一目录中

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

bcloud@ubuntu:~/Desktop/sym$ echo BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB > test.txt
bcloud@ubuntu:~/Desktop/sym$ ./sym
Executable location: /home/bcloud/Desktop/sym/sym
/home/bcloud/Desktop/sym/test.txt
Number of bytes read: 16
Bytes: BBBBBBBBBBBBBBBB

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

bcloud@ubuntu:~/Desktop/sym$ echo AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA > bin/test.txt
bcloud@ubuntu:~/Desktop/sym$ mv sym sym.bak
bcloud@ubuntu:~/Desktop/sym$ ln -s bin/sym sym

在 Linux 上,Go会跟踪符号链接,因此会尝试从以下位置读取配置文件/home/user/Documents/

bcloud@ubuntu:~/Desktop/sym$ ./sym
Executable location: /home/bcloud/Desktop/sym/bin/sym
/home/bcloud/Desktop/sym/bin/test.txt
Number of bytes read: 16
Bytes: AAAAAAAAAAAAAAAA

安全问题

假设我们有一个长期运行的 Go 二进制文件,例如服务或类似文件,位于受保护的位置,以防止低权限用户访问,并且配置文件规定了一些安全选项,例如对服务器或类似文件的主机证书验证。在 Windows 和 MacOS 上,即使对于权限较低的用户,我们也可以在可控制的位置创建指向此二进制文件的符号链接,并在那里添加修改后的配置文件,程序将在下次运行时读取该文件。实际上,这使得攻击者能够从低权限用户帐户覆盖那里的安全设置。

修复方式

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

0 条评论
某人
表情
可输入 255