Golang PIE 编译的测试

· Technology

在 Golang 1.6 版本中1,Golang 引入了 PIE(Position Independent Executable)编译模式2。在 1.15 中这种编译模式成为 Windows 平台的默认编译模式3。这种模式的本质是创建一种特殊类型的二进制文件,这些文件在运行时可以被加载到内存的任意位置,而不是一个固定的地址。

关于 PIE 编译

在过去的十年中,主要的操作系统和发行版(如 macOS、iOS、Android、FreeBSD、Ubuntu、Debian、Fedora)都默认启用或开始支持 PIE。对内存安全问题的关注促使非 PIE 二进制文件逐渐被淘汰。

精通此领域的读者可能会质疑 PIE 的相关性。Golang 的托管内存模型不是已经预防了 ASLR 声称要难以利用的问题吗?在某种程度上,这是正确的:如果你不使用 CGO 构建,那么编写出具有此类问题的代码并不容易。但如果你使用了 CGO,那你就链接了 C 代码。而 C 代码没有和 Go 一样的保护机制,其内存问题一直是并且将永远是一个主要的痛点,因此即使是最强的开发者也不能保证永远不犯错误。由此可见,我们仍然面临着内存安全问题,而 PIE 编译模式可以帮助我们解决这个问题。

加载到内存的任意位置

在传统的可执行文件中,程序的代码和数据通常都有一个固定的地址空间布局。这意味着程序每次运行时,其代码和数据都会被加载到相同的内存地址。这种方法简单有效,但也容易受到某些类型的安全攻击,比如缓冲区溢出攻击,因为攻击者可以预测代码和数据的内存地址。相比之下,位置独立的可执行文件(如 -buildmode=pie 生成的文件)设计用于在加载时动态地选择内存地址。操作系统在每次运行程序时,可以选择不同的内存位置来加载程序的代码和数据。这种方法提高了安全性,因为它增加了攻击者预测程序内存布局的难度。然而,它也可能带来额外的性能开销,因为程序需要使用间接的方式来引用内存地址。

传统的内存加载方式(非 PIE)

假设我们有一个简单的程序,它包含一些函数和数据。在传统的非PIE模式下,这个程序可能被设计为总是在相同的内存地址加载。例如:

  • 程序的起始点始终位于内存地址 0x1000。
  • 一个重要的数据结构始终位于 0x2000。

当这个程序运行时,无论何时何地,它总是会被加载到这些特定的内存地址。这使得程序简单高效,但也容易受到安全攻击,因为攻击者可以利用这种确定性来执行恶意操作。

PIE 模式下的内存加载方式

现在,假设同一个程序被编译为PIE。在这种情况下,程序的加载地址不再固定:

  • 程序可能在第一次运行时被加载到内存地址 0x5000,而在下次运行时被加载到 0x9000。
  • 同样,那个重要的数据结构也会随着程序的加载地址而变化,例如在第一次运行时位于 0x6000,而在下一次运行时位于 0xA000。

每次程序启动时,操作系统都会选择一个新的随机地址来加载程序和它的数据。这意味着攻击者不能预先知道程序将在哪个内存地址运行,从而大大增加了利用内存地址的安全攻击的难度。

使用 PIE 后,由于可执行文件可以在内存中的任意位置加载,这增加了系统的安全性,因为它使得攻击者难以预测程序代码的内存地址,从而阻碍了一些常见的攻击方法,例如缓冲区溢出攻击。

PIE 编译模式的性能损耗

地址无关代码能够在不做修改的情况下被复制到内存中的任意位置。这一点不同于重定位代码,因为重定位代码需要经过链接器或加载器的特殊处理才能确定合适的运行时内存地址。 地址无关代码需要在源代码级别遵循一套特定的语义,并且需要编译器的支持。那些引用了绝对内存地址的指令(比如绝对跳转指令)必须被替换为PC相对寻址指令。 这些间接处理过程可能导致PIC的运行效率下降,但是目前大多数处理器对PIC都有很好的支持,使得这效率上的这一点点下降基本可以忽略。

—— 翻译于 Wikipedia

但 PIE 到底会产生多大的性能和内存占用损耗呢?本文将对 PIE 编译模式进行测试,以验证这一说法。

PIE 的测试

由于 Go 的 PIE build mode 在 linux/amd64、linux/arm64 下不会默认启用,因此本文将在 linux/amd64 架构下进行测试。全部测试代码均位于仓库中: https://github.com/AH-dark/go-pie-comparation

测试环境

由于我本人使用 Mac 作为开发设备,在此次测试中我们使用 GitHub Codespace 作为测试环境。

  • CPU: AMD EPYC 7763 64-Core Processor × 2
  • Memory: 8GB
  • OS: Ubuntu 20.04.2 LTS
  • Go: 1.21.5 linux/amd64
  • GCC: 9.4.0 (Ubuntu 9.4.0-1ubuntu1~20.04.2)

性能测试

我们首先分别编译 PIE 模式和非 PIE 模式的应用程序,而后分别运行他们并使用 time 命令计算应用程序完成全部任务使用的时间。

package main

import (
	"fmt"
	"math"
	"sync"
)

var wg = sync.WaitGroup{}

func compute(start, end int) {
	defer wg.Done()
	var result float64
	for i := start; i < end; i++ {
		num := math.Sqrt(float64(i)) * math.Sin(float64(i)) * math.Cos(float64(i))
		result += num
	}
}

func doCompute() {
	const numWorkers = 4
	const numElements = 25000000

	// 并发数学计算
	for i := 0; i < numWorkers; i++ {
		wg.Add(1)
		go compute(i*numElements/numWorkers, (i+1)*numElements/numWorkers)
	}
}

func writeMemory() {
	// 内存操作
	data := make(map[int]int)
	for i := 0; i < 10000000; i++ {
		data[i] = i ^ 0xff00
	}
}

func main() {
	doCompute()
	writeMemory()

	wg.Wait()

	fmt.Println("Test completed")
}

我们使用预先配置好的 makefile 进行编译和测试。

make build

# 时间测试
make bench_time

两组测试结果如下:

Benchmarking time for non-PIE executable
Test completed
2.62user 0.48system 0:02.78elapsed 111%CPU (0avgtext+0avgdata 638976maxresident)k
0inputs+0outputs (0major+159478minor)pagefaults 0swaps

Benchmarking time for PIE executable
Test completed
2.70user 0.45system 0:02.68elapsed 117%CPU (0avgtext+0avgdata 638836maxresident)k
0inputs+0outputs (0major+158447minor)pagefaults 0swaps
Benchmarking time for non-PIE executable
Test completed
2.61user 0.50system 0:02.71elapsed 115%CPU (0avgtext+0avgdata 639012maxresident)k
0inputs+0outputs (0major+159475minor)pagefaults 0swaps

Benchmarking time for PIE executable
Test completed
2.54user 0.48system 0:02.59elapsed 116%CPU (0avgtext+0avgdata 638356maxresident)k
0inputs+0outputs (0major+159312minor)pagefaults 0swaps

可见,PIE 导致的性能损耗极低,甚至在某些情况下还能带来性能提升。对于大型应用程序这个性能损耗可能更显著,但对于大多数应用程序而言,这个性能损耗是可以忽略的。

内存占用测试

我们在程序中添加 net/http 包,以便于使用 net/http/pprof 进行内存占用测试。

package main

import (
    "fmt"
    "math"
    "net/http"
    _ "net/http/pprof"
    "sync"
)

var wg = sync.WaitGroup{}

func compute(start, end int) {
    defer wg.Done()
    var result float64
    for i := start; i < end; i++ {
        num := math.Sqrt(float64(i)) * math.Sin(float64(i)) * math.Cos(float64(i))
        result += num
    }
}

func doCompute() {
    const numWorkers = 4
    const numElements = 25000000

    // 并发数学计算
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go compute(i*numElements/numWorkers, (i+1)*numElements/numWorkers)
    }
}

func writeMemory() {
    // 内存操作
    data := make(map[int]int)
    for i := 0; i < 10000000; i++ {
        data[i] = i ^ 0xff00
    }
}

func main() {
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()
    
    doCompute()
    writeMemory()

    wg.Wait()

    fmt.Println("Test completed")

    select {}
}

在程序启动后,任务完成不会退出,我们可以使用 net/http/pprof 进行内存占用测试。

make build

# 内存占用测试
./bin/math_test_non_pie
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap

./bin/math_test_pie
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap

测试结果如下:

File: math_test_non_pie
Type: inuse_space
Time: Dec 22, 2023 at 4:07pm (UTC)
Showing nodes accounting for 239.41MB, 100% of 239.41MB total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context 	 	 
----------------------------------------------------------+-------------
                                          238.91MB   100% |   main.main /workspaces/go-pie-comparation/calculation-intensive/main.go:49 (inline)
  238.91MB 99.79% 99.79%   238.91MB 99.79%                | main.writeMemory /workspaces/go-pie-comparation/calculation-intensive/main.go:37
----------------------------------------------------------+-------------
                                            0.50MB   100% |   net/http.init /usr/local/go/src/net/http/h2_bundle.go:1189
    0.50MB  0.21%   100%     0.50MB  0.21%                | net/http.map.init.0 /usr/local/go/src/net/http/h2_bundle.go:1189
----------------------------------------------------------+-------------
                                          238.91MB   100% |   runtime.main /usr/local/go/src/runtime/proc.go:267
         0     0%   100%   238.91MB 99.79%                | main.main /workspaces/go-pie-comparation/calculation-intensive/main.go:49
                                          238.91MB   100% |   main.writeMemory /workspaces/go-pie-comparation/calculation-intensive/main.go:37 (inline)
----------------------------------------------------------+-------------
                                            0.50MB   100% |   runtime.doInit1 /usr/local/go/src/runtime/proc.go:6740
         0     0%   100%     0.50MB  0.21%                | net/http.init /usr/local/go/src/net/http/h2_bundle.go:1189
                                            0.50MB   100% |   net/http.map.init.0 /usr/local/go/src/net/http/h2_bundle.go:1189
----------------------------------------------------------+-------------
                                            0.50MB   100% |   runtime.main /usr/local/go/src/runtime/proc.go:249 (inline)
         0     0%   100%     0.50MB  0.21%                | runtime.doInit /usr/local/go/src/runtime/proc.go:6707
                                            0.50MB   100% |   runtime.doInit1 /usr/local/go/src/runtime/proc.go:6740
----------------------------------------------------------+-------------
                                            0.50MB   100% |   runtime.doInit /usr/local/go/src/runtime/proc.go:6707
         0     0%   100%     0.50MB  0.21%                | runtime.doInit1 /usr/local/go/src/runtime/proc.go:6740
                                            0.50MB   100% |   net/http.init /usr/local/go/src/net/http/h2_bundle.go:1189
----------------------------------------------------------+-------------
         0     0%   100%     0.50MB  0.21%                | runtime.main /usr/local/go/src/runtime/proc.go:249
                                            0.50MB   100% |   runtime.doInit /usr/local/go/src/runtime/proc.go:6707 (inline)
----------------------------------------------------------+-------------
         0     0%   100%   238.91MB 99.79%                | runtime.main /usr/local/go/src/runtime/proc.go:267
                                          238.91MB   100% |   main.main /workspaces/go-pie-comparation/calculation-intensive/main.go:49
----------------------------------------------------------+-------------

File: math_test_pie
Type: inuse_space
Time: Dec 22, 2023 at 4:08pm (UTC)
Showing nodes accounting for 233.91MB, 100% of 233.91MB total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context 	 	 
----------------------------------------------------------+-------------
                                          233.91MB   100% |   main.main /workspaces/go-pie-comparation/calculation-intensive/main.go:49 (inline)
  233.91MB   100%   100%   233.91MB   100%                | main.writeMemory /workspaces/go-pie-comparation/calculation-intensive/main.go:37
----------------------------------------------------------+-------------
                                          233.91MB   100% |   runtime.main /usr/local/go/src/runtime/proc.go:267
         0     0%   100%   233.91MB   100%                | main.main /workspaces/go-pie-comparation/calculation-intensive/main.go:49
                                          233.91MB   100% |   main.writeMemory /workspaces/go-pie-comparation/calculation-intensive/main.go:37 (inline)
----------------------------------------------------------+-------------
         0     0%   100%   233.91MB   100%                | runtime.main /usr/local/go/src/runtime/proc.go:267
                                          233.91MB   100% |   main.main /workspaces/go-pie-comparation/calculation-intensive/main.go:49
----------------------------------------------------------+-------------

对于仓库中另一组 io-copy 的测试也呈现了类似的结果。

File: math_test_non_pie
Type: inuse_space
Time: Dec 22, 2023 at 4:04pm (UTC)
Showing nodes accounting for 1GB, 100% of 1GB total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context 	 	 
----------------------------------------------------------+-------------
                                               1GB   100% |   runtime.main /usr/local/go/src/runtime/proc.go:267
       1GB   100%   100%        1GB   100%                | main.main /workspaces/go-pie-comparation/io-copy/main.go:54
----------------------------------------------------------+-------------
         0     0%   100%        1GB   100%                | runtime.main /usr/local/go/src/runtime/proc.go:267
                                               1GB   100% |   main.main /workspaces/go-pie-comparation/io-copy/main.go:54
----------------------------------------------------------+-------------

File: math_test_pie
Type: inuse_space
Time: Dec 22, 2023 at 4:04pm (UTC)
Showing nodes accounting for 1GB, 100% of 1GB total
----------------------------------------------------------+-------------
      flat  flat%   sum%        cum   cum%   calls calls% + context 	 	 
----------------------------------------------------------+-------------
                                               1GB   100% |   runtime.main /usr/local/go/src/runtime/proc.go:267
       1GB   100%   100%        1GB   100%                | main.main /workspaces/go-pie-comparation/io-copy/main.go:54
----------------------------------------------------------+-------------
         0     0%   100%        1GB   100%                | runtime.main /usr/local/go/src/runtime/proc.go:267
                                               1GB   100% |   main.main /workspaces/go-pie-comparation/io-copy/main.go:54
----------------------------------------------------------+-------------

由此看来,PIE 编译模式几乎不会带来额外的内存占用。

总结

PIE 已经被引入很久,且在当前很多平台被作为默认编译模式。但是,我们仍然可以看到很多人在讨论 PIE 编译模式的性能损耗。 本文对 PIE 编译模式进行了测试,结果显示 PIE 编译模式几乎不会带来额外的性能损耗和内存占用。

这启示我们,我们应当以求真务实的态度对待新兴的技术,而不是盲目地相信他人的说法。应当付诸实践,以验证其是否真的适合我们的应用场景。

附言:测试包中附带了对于汇编代码的 diff,如果你在 arm64 使用相同的命令进行编译,你会发现 PIE 编译模式与非 PIE 编译模式的汇编代码几乎没有差异。但在 amd64 下,PIE 编译模式的汇编代码会变更几十 MB 的代码,这是因为 PIE 在 darwin/arm64 被默认启用,而在 linux/arm64 下不会默认启用。

致谢

Footnotes

  1. Go 1.6 Release Notes

  2. Wikipedia: Position-independent code

  3. Go 1.15 Release Notes

Comments

Send Comments

Markdown supported. Please keep comments clean.