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)
}
}
|
执行攻击:
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
$
|
可以看到,即使我们的程序只与my_logs交互,other/logs的内容也被清除了。
即使在这种意外场景中,os.Create也通过其截断行为删除了重要数据。在恶意场景中,攻击者可以利用截断行为针对用户删除特定数据——可能是会揭示他们在系统中存在的审计日志。
简单修复:两种方法
方法一:使用IsNotExist检查
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
|
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()
}
|
这里的限制是,通过在创建前检查os.IsNotExist,我们无法验证在存在检查和文件创建之间是否创建了符号链接(检查时间与使用时间竞争漏洞)。为了解决这个问题,我们可以采用几种不同的方法。
方法二:使用O_NOFOLLOW标志
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打开文件,你将不会跟随符号链接。因此,当创建新文件时,这将与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
|
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)
}
|
这种模式打破了my_logs和other/logs之间的符号链接。other/logs仍然保留其内容,而my_logs只有预期的内容"Is this the only thing in the file”。
保护未来的自己
无论你在Go中检查错误多么小心,它们并不总是按照你的想法行为(tl;dr:阅读手册)。但是更新你在Go文件创建中的实践真的很简单,可以避免意外的后果。