go 代码安全审计
由于 go 的语法相对简单没有其他语言那么多的奇技淫巧,学会很容易,然而如果去观察 go 语言的杀手级应用 docker 和 Kubernetes 会发现用好 go 很难,因为 go 的并发、内存管理等关键点在开发过程往往会容易忽略,进而引发安全问题。
面对上述问题,我自己的经验是日常研发过程中需要将代码安全审计 code audit 融入到 code review 中,能工具化就工具化,能自动化就自动化。code audit/code review 不是目的,锻炼自己的良好的代码风格和代码安全意识才是目的。虽然每个语言都有自己的特点和编程范式,但是代码安全审计都是有自己不变的东西,所以不妨以 go 语言为例从常见的问题、易犯错误的场景来逐步建立自己的编程习惯和思维。
这篇文章将从常见的代码安全漏洞,危险的代码模式等角度切入,讲一讲 go 语言代码安全审计以及如何避免,最后还会附上一些常见的资料、工具等供有兴趣的读者取用。
常见的代码安全漏洞
SQL injection(SQL 注入)
是什么
SQL 注入漏洞:错把外部输入数据当作 SQL 执行。
SELECT id,name,number FROM PhoneTable WHERE name = 'abc';
-- sql injection
-- abc --> abc' or 1=1;#
SELECT id,name,number FROM PhoneTable WHERE name = 'abc' or 1=1;#';
怎么办
SQL 注入的解决办法是 SQL 预编译处理。在 SQL 发送到数据库执行之前,讲 SQL 语句的语法结构进行解析和编译,并缓存起来进行复用。
SELECT id,name,number FROM PhoneTable WHERE name = '占位符';
SQL 预编译处理的局限性:例如 order by number
Cmdline injection(命令行注入)
是什么
命令行注入:shell 命令拼接的场景下导致攻击者可以在服务器上执行任意命令。实现命令行注入有两个条件:1.命令执行函数(例如 exec.Command()
);2.用户可控参数(参数未经过完整的过滤或者转义处理直接传递给命令执行函数;
ping ${USER_INPUT_IP}
# IP address
USER_INPUT_IP="127.0.0.1"
ping 127.0.0.1
# injection
USER_INPUT_IP="127.0.0.1 | cat > test.txt"
ping 127.0.0.1 | cat > test.txt
怎么办
shell 特征 | 示例 |
---|---|
顺序执行 | pre_cmd;malicious_cmd |
管道 | pre_cmd|malicious_cmd |
命令替换 | pre_cmd`malicious_cmd` |
命令替换 | pre_cmd${malicious_cmd} |
AND操作 | pre_cmd&&malicious_cmd |
OR操作 | pre_cmd||malicious_cmd |
转义绕过 | u'n'ame<br>u"n"ame |
编码绕过 | echo -e "\x2f\x65\x74\x63\x2f\x70\x61\x73\x73\x77\x64" |
斜杠绕过 | u\name<br>/\b\i\n/s\h |
可变扩展绕过 | /???/c?t/???/p?ss??|cat |
单命令行场景:
- 白名单限定命令行场景,第一个参数不允许是
sh, bash, python3
等;
多命令场景:
- 多命令拆分成单命令,然后再进行逻辑组合;
- 参数编号从
$0
开始,参数两侧要加上双引号; - 不可以将参数拼接到 cmd 中;
Server-side Template injection(服务端模版注入)
是什么
模版对于用户可控参数未经过滤,直接拼接到模版代码中执行。
type User struct {
Id int
Name string
Passwd string
}
func StringTplExanlw http.Responsewriter, r *http.Request){
user := &User{1, "admin- ,"123455)
queryParans := r.URL.Query[)
arg := queryParans.Get("arg")
tpl1 := fmt.Sprintf(`<h1>Hi, ` + arg +
`</h1> Your name is {{.Name}}!`)
html, err := template.New("login").Parse(tpl1)
html = template.Must(html,err)
html.Execute(w, user)
}
怎么办
利用模版引擎的预编译处理,包括占位符和模版逻辑。
SSRF(服务端请求伪造)
是什么
Server Side Request Forgery, SSRF 服务端请求伪造,攻击者利用服务器漏洞将服务器作为跳板,对服务器所在的内网进行探测和渗透。
url := req.URL.Query().Get("url")
// 未对 url 进行校验,直接返回对应信息
resp, err := http.Get("url")
if err != nil {
// error handling
}
defer resp.Body.Close()
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
// error handling
}
// data 包括内网数据
怎么办
通过用户可控的 URL 发起请求就有可能发生 SSRF 漏洞,为了防御这个问题需要过滤掉请求函数对非预期地址的访问。
常见容易发生 SSRF 的场景:
- 采用高危协议如gopher、dict、ftp、file (应当仅允许http和https请求)
- 对内部地址的访问(需要全面拦截内部地址,如ipv4和ipv6地址、别名或缩写形式的IP地址、十进制的IP地址)
- 跟随 30x跳转(不跟随 30x 的跟转或者每跳转一次都验证是否为合法地址)
Broken Access Control(越权)
是什么
Broken Access Control 权限控制失效就是常说的越权,攻击者通过某种方式绕过系统的权限控制获得系统中本不应该属于攻击者的权限,这是由于没有鉴权或错误鉴权导致攻击者超越了原本的权限,执行了危险操作。
越权漏洞可以分为垂直越权和水平越权两种:
- 垂直越权:指使用权限低的用户可以访问到权限较高的用户
- 水平越权:指相同权限下不同的用户可以互相访问
怎么办
- 提升鉴权清晰度,降低越权发现成本,例如增加一个authFilter方法,在调用http.HandleFunc方法时使用authFilter方法装饰真正的handler,这样,所有的url都可以可以在authFilter中进行鉴权。
func authFilter(f http.HandlerFunc) http.HandlerFunc {
return func(wr http.ResponseWriter, r *http.Request) {
// 鉴权相关的逻辑
f.ServeHTTP(wr, r)
}
}
func hello(wr http.ResponseWriter, r *http.Request) {
wr.Write([]byte("hello"))
}
func someHandler(wr http.ResponseWriter, r *http.Request) {
wr.Write([]byte("some handler"))
}
func main() {
http.HandleFunc("/", authFilter(hello))
http.HandleFunc("/xxx", authFilter(someHandler))
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
- 增加日志审计的能力,在容易出现越权问题的地方增加日志审计记录。
Sensitive Information Management(敏感信息保护)
是什么
敏感信息是指各种层面上要求得到保护的数据,该类数据只能够得到授权才能使用,一旦泄露会造成严重影响甚至法律风险。
对于代码中敏感信息泄漏的问题:
- 代码明文编码敏感信息(例如硬编码了 password 等敏感信息)
- 日志中记录了敏感信息(debug 日志记录了手机号等敏感信息)
- 环境变量记录了敏感信息
怎么办
- 使用账号密码遵循权限最小够用原则
- 禁止硬编码明文永久凭证
- 敏感信息相关的工具进行扫描(如 Git-Secrets)
- 日志脱敏
危险 go 代码模式
尽管 go 是一种自带内存管理语言,默认情况下非常注重安全性,但开发人员仍然需要密切关注一些危险的代码结构,可以参考 semgrep 规则包或者查看 gosec 工具。最常见的 go 安全漏洞可分为以下几类:
- Cryptographic misconfigurations,加密错误配置
- Input validation,输入验证(SQLi、RCE 等)
- File/URL path handling 文件/URL 路径处理
- File permissions,文件权限(该类问题不太常见,因为默认权限自 ~v1.17 以来非常安全)
- Concurrency issues,并发问题(goroutine 泄漏、竞争条件等)
- Denial of Service,拒绝服务
- Time of check, time of use issues,检查时间、使用时间问题
从 go 代码安全审计的角度我觉得不需要详细介绍上述的漏洞分类,因为并非所有错误都可能存在安全风险或者至少不会立即显现出来,需要做的是对于上述 go 代码安全漏洞抽象出其对应的模式,在代码审计的时候重点确认代码上下文是否符合改模式,然后分析其影响。
路径遍历
路径/目录遍历是一个 go 代码中非常常见的典型漏洞,攻击者可以通过提供恶意输入与服务器的文件系统进行交互。这通常采用添加多个 ../
或 ..
的形式来控制文件路径。path/filepath
包提供的 Join
可以将任意数量的路径元素连接到单个路径中,并使用特定于操作系统的分隔符将它们分隔开,空元素将被忽略,
多份错误报告表明 filepath.Join()
是目录遍历漏洞的常见罪魁祸首,如下代码所示:
package main
import (
"fmt"
"path/filepath"
)
func main() {
strings := []string{
"/test/./../test",
"test/../test",
"..test/./../test",
"test/../../../../../../../test/test",
}
domain := "edony.ink"
for _, s := range strings {
fmt.Println(filepath.Join(domain, s))
}
}
/* 运行输出
$ go run main.go
edony.ink/test
edony.ink/test
edony.ink/test
../../../../../test/test
*/
goroutine 泄漏
调用函数的时候不必等待 goroutine 返回就可以返回,导致 goroutine 泄漏的另一个重要的 go 概念就是 chan
,可以向通道 chan
发送数据或从通道 chan
接收数据。因为无缓冲通道unbuffer chan
旨在用于同步操作即在从通道接收到数据之前程序无法继续,因此它会阻止进一步执行,当无缓冲通道没有机会在其通道上发送数据时就会发生 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)
// this goroutine will blocked for `ch`,
// but someAction has already returned and thus leak.
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")
}
字符串拼接
go 的 fmt.Sprintf()
是内存安全的,然而开发人员在不应该使用此功能的地方很常见:
端口字符串
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
。在大多数情况下,这可能会导致各种标头注入漏洞。
我们对此有两种可能的解决方案:
- 使用
%q
格式化动词,这将创建一个带引号的字符串,其中包含编码的控制字符 - 使用
strconv.Quote()
它将引用字符串并对控制字符进行编码
unsafe 包
unsafe 包包含绕过 go 程序类型安全的操作,从安全角度来看,其功能的典型用法是与 syscall
包一起使用(不过还有许多其他包)。要理解为什么这种配对很常见,我们需要分别了解一下 Go 中的 unsafe.Pointer
和 uintptr
是什么。
简单来说, unsafe.Pointer
是 go 内置类型(就像 string 、 map 、 chan 等) ,这意味着它在内存中有一个关联的 go 对象。基本上任何 go 指针都可以转换为 unsafe.Pointer
,这将告诉编译器不要对对象执行边界检查即开发人员可以告诉 go 编译器绕过其类型安全。除此之外, uintptr
基本上只是 unsafe.Pointer
所指向的内存地址的整数表示。
现在回到 syscall
。正如具有内核或 C 编程知识的读者可能期望的那样,系统调用在已编译的 Go 二进制文件“下方”运行,这意味着它们在调用时期望原始指针 - 他们不知道如何处理完整的 Go unsafe.Pointer 对象。因此,当我们想要从 Go 程序调用系统调用时,我们需要将 unsafe.Pointer 转换为 uintptr 以丢失 Go 对象在内存中的所有附加数据。这会将指针转换为指针所指向的内存地址的简单整数表示形式:
rawPointer := uintptr(unsafe.Pointer(pointer))
到目前为止,一切都很好。对于我们当前的讨论来说,另一个重要的事情是 go 有一个 non-generational concurrent, tri-color mark and sweep 垃圾收集器。这有点复杂暂时可以只关注它是并发的这一特征。简单来说,这意味着我们无法确定 go 程序运行时何时会发生垃圾收集。
将这两件事放在一起,会意识到如果在从 unsafe.Pointer
到 uintptr
的转换及其在系统调用中的使用之间发生垃圾收集,我们可能会完全传递一个内存中的结构与系统调用不同。这是因为垃圾收集器可能会在内存中移动对象,但它不会更新 uintptr
,因此与我们执行转换时相比,该地址可能包含完全不同的数据。
其他资料
代码 Review
- securego/gosec: Go security checker
- Go Code Reviews - Engineering Fundamentals Playbook
- Golang Audit Checklist
Vulnerability Management
- Vulnerability Management for Go - The Go Programming Language
- Go Vulnerability Database - Go Packages
Public discussion