Go语言中预防符号链接攻击的智能(且简单)方法
在多年编写Go代码后,我们许多人已将错误检查模式深入骨髓:“这个函数返回错误吗?最好在继续之前确保它是nil。”
这很好!这应该是我们编写Go时的默认行为。
然而,机械的错误检查有时会阻碍我们对错误实际含义的批判性思考:该函数何时返回错误?它是否包含了你认为它应该包含的所有情况?
例如,在os.Create
中,nil错误值可能会让你误以为文件创建是安全的。阅读相关文档后发现,os.Create
实际上会在文件已存在时截断文件,而不是抛出任何指示这不是新文件的错误。
这使我们容易受到符号链接攻击。
是否存在?
假设我的程序需要创建并使用一个文件。几乎每个Go代码范例都指导我们进行错误检查,但没有验证在调用Create之前文件是否已存在。如果已经为该文件设置了符号链接,不会发生错误,但由于截断行为,文件及其内容不会按预期运行。风险在于我们可以使用程序覆盖信息来删除信息。
在Trail of Bits,这个问题在审计中经常出现。幸运的是,修复方法非常简单。我们只需要在尝试创建文件之前检查文件是否存在。对我们处理Go范例的方法进行轻微调整可以使文件创建更安全,并使我们更接近在Go程序中优先考虑安全性。
情况说明
假设有一个文件my_logs
,我需要创建并写入。然而,在代码库的另一部分,有人之前使用ln -s other/logs my_logs
设置了符号链接。
1
2
3
4
|
- logs
- notes
- things I care about
- very important information we can't lose
|
other/logs的内容。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
package main
import (
"fmt"
"os"
)
func main() {
file, err := os.Create("my_logs")
if err != nil {
fmt.Printf("Error creating file: %s", err)
}
_, err = file.Write([]byte("My logs for this process"))
if err != nil {
fmt.Println(err)
}
}
|
symlink_attack.go.
1
2
3
4
5
6
|
$ ln -s other/logs my_logs
$ go build symlink_attack.go
$ ./symlink_attack
$ cat other/logs
- My logs for this process
$
|
如你所见,other/logs的内容被清除了,尽管我们的程序只与my_logs交互。
即使在这种意外情况下,os.Create
也会通过其截断行为删除重要数据。在恶意情况下,攻击者可以利用截断行为针对用户删除特定数据——可能是会揭示他们曾在系统上存在的审计日志。
简单修复,两种方法
为了解决这个问题,我们必须在调用Create之前插入os.IsNotExist
检查。如果你运行下面编辑过的symlink_attack.go,other/logs中的数据将保留且不会被覆盖。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
)
func main() {
if fileExists("my_logs") {
log.Fatalf("symlink attack failure")
}
file, err := os.Create("my_logs")
if err != nil {
fmt.Printf("Error creating file: %s", err)
}
_, err = file.Write([]byte("My logs for this process"))
if err != nil {
fmt.Printf("Failure to write: %s", err)
}
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
|
带IsNotExist检查的symlink_attack.go。
这里的限制是,通过在创建前检查os.IsNotExist
,我们使自己无法验证在存在检查和文件创建之间是否创建了符号链接(检查时间与使用时间错误)。为了解决这个问题,我们可以采用几种不同的方法。
第一种方法是使用你自己的OpenFile命令重新实现os.Create
,从而消除截断。
1
2
3
|
func Create(name string) (*File, error) {
return OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666)
}
|
os包中Create的定义。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
"syscall"
)
func main() {
file, err := os.OpenFile("my_logs", os.O_RDWR|os.O_CREATE|syscall.O_NOFOLLOW, 0666)
if err != nil {
log.Fatal(err)
}
_, err = file.Write([]byte("Is this the only thing in the file\n"))
if err != nil {
fmt.Printf("Failure to write: %s", err)
}
err = file.Close()
if err != nil {
fmt.Printf("Couldn't close file: %s", err)
}
buf, err := ioutil.ReadFile("./my_logs")
if err != nil {
fmt.Printf("Failed to read file: %s", err)
}
fmt.Printf("%s", buf)
}
|
使用O_NOFOLLOW的OpenFile的symlink_attack.go以避免跟随符号链接。
通过使用O_NOFOLLOW打开文件,你将不会跟随符号链接。因此,当创建新文件时,这将与os.Create
的工作方式相同。但是,如果在该位置设置了符号链接,它将无法打开。
另一种方法是创建TempFile并使用os.Rename
将其移动到首选位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
package main
import (
"fmt"
"io/ioutil"
"log"
"os"
)
func main() {
tmpfile, err := ioutil.TempFile(".", "")
if err != nil {
log.Fatal(err)
}
os.Rename(tmpfile.Name(), "my_logs")
if _, err := tmpfile.Write([]byte("Is this the only thing in the file")); err != nil {
log.Fatal(err)
}
buf, err := ioutil.ReadFile("./my_logs")
if err != nil {
fmt.Printf("Failed to read file: %s", err)
}
fmt.Printf("%s", buf)
}
|
带有TempFile创建和os.Rename的symlink_attack.go。
这种模式打破了my_logs和other/logs之间的符号链接。other/logs仍然有其内容,而my_logs只有预期的内容"Is this the only thing in the file"。
现在保护未来的你
无论你多么小心地检查Go中的错误,它们并不总是按照你的想法行为(tl;dr:阅读手册)。但是更新你在Go文件创建中的实践非常简单,可以避免意外的后果。