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()
看出:
将多个分隔符元素替换为一个
消除每一个 . 路径名元素(当前目录)
消除每个内部 .. 路径名元素(父目录)以及它之前的非 .. 元素
消除以根路径开头的 .. 元素:即,假设分隔符为“/”,将路径开头的“/..”替换为“/”
如果是粗略一看文档,这可以很容易地以确保该函数防止目录遍历攻击的方式阅读。
但是,如果我们准备一个快速测试程序,我们可以看到它并没有完全做到这一点:
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-case
to 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
格式化动词表示纯字符串。用户可以注入控制字符,例如