智能(且简单)的方法防止Go中的符号链接攻击
在多年编写Go代码后,我们许多人都已经深入骨髓地学会了错误检查模式:“这个函数返回错误吗?哦,最好在继续之前确保它是nil。”
这很棒!这应该是我们编写Go时的默认行为。
然而,机械的错误检查有时会阻碍对错误实际含义的批判性思考:该函数何时返回错误?它是否包含了你认为它包含的所有内容?
例如,在os.Create
中,nil错误值可能会让你误以为文件创建是安全的。阅读相关文档后,os.Create
实际上会在文件已存在时截断文件,而不是抛出任何指示这不是新文件的错误。
这使我们容易受到符号链接攻击。
它是否存在?
假设我的程序需要创建并使用一个文件。几乎每个惯用Go代码示例都引导我们进行错误检查,但没有验证在调用Create之前文件是否已存在。如果已经为该文件设置了符号链接,不会发生错误,但由于截断行为,文件及其内容不会按预期运行。风险在于我们可以使用程序覆盖信息来删除信息。
在Trail of Bits,这个问题在审计中经常出现。幸运的是,修复它非常简单。我们只需要在尝试创建文件之前检查文件是否存在。对我们处理惯用Go的方法进行轻微调整可以使文件创建更安全,并使我们更接近在Go程序中优先考虑安全性。
情况
假设有一个文件my_logs
,我需要创建并写入。但是,在代码库的另一部分,有人之前使用ln -s other/logs my_logs
设置了符号链接。
other/logs
的内容:
1
2
3
4
|
- logs
- notes
- things I care about
- very important information we can't lose
|
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)
}
|
Create的os包定义。
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:rtfm)。但是更新您在Go文件创建中的实践非常简单,并且可以使您免于意外的后果。