大家好,我是张晋涛。
我们用 Go 构建的二进制文件中默认包含了很多有用的信息。例如,可以获取构建用的 Go 版本:
(这里我使用我一直参与的一个开源项目 KIND 为例)
➜ kind git:(master) ✗ go version ./bin/kind
./bin/kind: go1.16
或者也可以获取该二进制所依赖的模块信息:
➜ kind git:(master) ✗ go version -m ./bin/kind
./bin/kind: go1.16
path sigs.k8s.io/kind
mod sigs.k8s.io/kind (devel)
dep github.com/BurntSushi/toml v0.3.1
dep github.com/alessio/shellescape v1.4.1
dep github.com/evanphx/json-patch/v5 v5.2.0
dep github.com/mattn/go-isatty v0.0.12
dep github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
dep github.com/pkg/errors v0.9.1
dep github.com/spf13/cobra v1.1.1
dep github.com/spf13/pflag v1.0.5
dep golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
dep gopkg.in/yaml.v2 v2.2.8
dep gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
dep k8s.io/apimachinery v0.20.2
dep sigs.k8s.io/yaml v1.2.0
查看 KIND 代码仓库中的 go.mod文件,都包含在内了。
其实 Linux 系统中二进制文件包含额外的信息并非 Go 所特有的,下面我将具体介绍其内部原理和实现。当然,用 Go 构建的二进制文件仍是本文的主角。
Linux ELF 格式
ELF 是 Executable and Linkable Format 的缩写,是一种用于可执行文件、目标文件、共享库和核心转储(core dump)的标准文件格式。ELF 文件 通常 是编译器之类的输出,并且是二进制格式。以 Go 编译出的可执行文件为例,我们使用 file 命令即可看到其具体类型 ELF 64-bit LSB executable
:
➜ kind git:(master) ✗ file ./bin/kind
./bin/kind: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
本文中我们来具体看看 64 位可执行文件使用的 ELF 文件格式的结构和 Linux 内核源码中对它的定义。
使用 ELF 文件格式的可执行文件是由 ELF 头(ELF Header) 开始,后跟 程序头(Program Header) 或 节头(Section Header) 或两者均有组成的。
ELF 头
ELF 头始终位于文件的零偏移(zero offset)处(即:起点位置),同时在 ELF 头中还定义了程序头和节头的偏移量。
我们可以通过 readelf 命令查看可执行文件的 ELF 头,如下:
➜ kind git:(master) ✗ readelf -h ./bin/kind
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x46c460
Start of program headers: 64 (bytes into file)
Start of section headers: 400 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 6
Size of section headers: 64 (bytes)
Number of section headers: 15
Section header string table index: 3
从上面的输出我们可以看到,ELF 头是以某个 Magic 开始的,此 Magic 标识了有关文件的信息,即:前四个 16 进制数,表示这是一个 ELF 文件。具体来说,将它们换算成其对应的 ASCII 码即可:
45 = E
4c = L
46 = F
7f 是其前缀,当然,也可以直接在 Linux 内核源码中拿到此处的具体定义:
// include/uapi/linux/elf.h#L340-L343
#define ELFMAG0 0x7f /* EI_MAG */
#define ELFMAG1 'E'
#define ELFMAG2 'L'
#define ELFMAG3 'F'
接下来的数 02 是与 Class 字段相对应的,表示其体系结构,它可以是 32 位(=01) 或是 64 位(=02)的,此处显示 02 表示是 64 位的,再有 readelf 将其转换为 ELF64 进行展示。这里的取值同样可以在 Linux 内核源码中找到:
// include/uapi/linux/elf.h#L347-L349
#define ELFCLASSNONE 0 /* EI_CLASS */
#define ELFCLASS32 1
#define ELFCLASS64 2
再后面的两个 01 01 则是与 Data 字段和 Version 字段相对应的,Data 有两个取值分别是 LSB(01)和 MSB(02),这里倒没什么必要展开。另外就是 Version 当前只有一个取值,即 01 。
// include/uapi/linux/elf.h#L352-L358
#define ELFDATANONE 0 /* e_ident[EI_DATA] */
#define ELFDATA2LSB 1
#define ELFDATA2MSB 2
#define EV_NONE 0 /* e_version, EI_VERSION */
#define EV_CURRENT 1
#define EV_NUM 2
接下来需要注意的就是我前面提到的关于偏移量的内容,即输出中的以下内容:
Start of program headers: 64 (bytes into file)
Start of section headers: 400 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 6
Size of section headers: 64 (bytes)
Number of section headers: 15
ELF 头总是在起点,在此例中接下来是程序头(Program Header),随后是节头(Section Header),这里的输出显示程序头是从 64 开始的,所以节头的位置就是:
64 + 56 * 6 = 400
与上述输出符合,同理,节头的结束位置是:
400 + 15 * 64 = 1360
下一节内容中将用到这部分知识。
程序头
通过 readelf -l
可以看到其程序头,包含了若干段(Segment),内核看到这些段时,将调用 mmap syscall 来使用它们映射到虚拟地址空间。这部分不是本文的重点,我们暂且跳过有个印象即可。
➜ kind git:(master) ✗ readelf -l ./bin/kind
Elf file type is EXEC (Executable file)
Entry point 0x46c460
There are 6 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040
0x0000000000000150 0x0000000000000150 R 0x1000
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x0000000000333a75 0x0000000000333a75 R E 0x1000
LOAD 0x0000000000334000 0x0000000000734000 0x0000000000734000
0x00000000002b3be8 0x00000000002b3be8 R 0x1000
LOAD 0x00000000005e8000 0x00000000009e8000 0x00000000009e8000
0x0000000000020ac0 0x00000000000552d0 RW 0x1000
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x8
LOOS+0x5041580 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 0x8
Section to Segment mapping:
Segment Sections...
00
01 .text
02 .rodata .typelink .itablink .gosymtab .gopclntab
03 .go.buildinfo .noptrdata .data .bss .noptrbss
04
05
节头
使用 readelf -S
即可查看其节头,其结构如下:
// include/uapi/linux/elf.h#L317-L328
typedef struct elf64_shdr {
Elf64_Word sh_name; /* Section name, index in string tbl */
Elf64_Word sh_type; /* Type of section */
Elf64_Xword sh_flags; /* Miscellaneous section attributes */
Elf64_Addr sh_addr; /* Section virtual addr at execution */
Elf64_Off sh_offset; /* Section file offset */
Elf64_Xword sh_size; /* Size of section in bytes */
Elf64_Word sh_link; /* Index of another section */
Elf64_Word sh_info; /* Additional section information */
Elf64_Xword sh_addralign; /* Section alignment */
Elf64_Xword sh_entsize; /* Entry size if section holds table */
} Elf64_Shdr;
对照实际的命令输出,含义就很明显了。
➜ kind git:(master) ✗ readelf -S ./bin/kind
There are 15 section headers, starting at offset 0x190:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000401000 00001000
0000000000332a75 0000000000000000 AX 0 0 32
[ 2] .rodata PROGBITS 0000000000734000 00334000
000000000011f157 0000000000000000 A 0 0 32
[ 3] .shstrtab STRTAB 0000000000000000 00453160
00000000000000a4 0000000000000000 0 0 1
[ 4] .typelink PROGBITS 0000000000853220 00453220
00000000000022a0 0000000000000000 A 0 0 32
[ 5] .itablink PROGBITS 00000000008554c0 004554c0
0000000000000978 0000000000000000 A 0 0 32
[ 6] .gosymtab PROGBITS 0000000000855e38 00455e38
0000000000000000 0000000000000000 A 0 0 1
[ 7] .gopclntab PROGBITS 0000000000855e40 00455e40
0000000000191da8 0000000000000000 A 0 0 32
[ 8] .go.buildinfo PROGBITS 00000000009e8000 005e8000
0000000000000020 0000000000000000 WA 0 0 16
[ 9] .noptrdata PROGBITS 00000000009e8020 005e8020
0000000000017240 0000000000000000 WA 0 0 32
[10] .data PROGBITS 00000000009ff260 005ff260
0000000000009850 0000000000000000 WA 0 0 32
[11] .bss NOBITS 0000000000a08ac0 00608ac0
000000000002f170 0000000000000000 WA 0 0 32
[12] .noptrbss NOBITS 0000000000a37c40 00637c40
0000000000005690 0000000000000000 WA 0 0 32
[13] .symtab SYMTAB 0000000000000000 00609000
0000000000030a20 0000000000000018 14 208 8
[14] .strtab STRTAB 0000000000000000 00639a20
000000000004178d 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
Go 二进制文件探秘
本文中,我们重点关注名为 .go.buildinfo
的部分。 使用 objdump 查看其具体内容:
➜ kind git:(master) ✗ objdump -s -j .go.buildinfo ./bin/kind
./bin/kind: file format elf64-x86-64
Contents of section .go.buildinfo:
9e8000 ff20476f 20627569 6c64696e 663a0800 . Go buildinf:..
9e8010 a0fc9f00 00000000 e0fc9f00 00000000 ................
这里我们按顺序来,先看到第一行的 16 个字节。
- 前 14 个字节是魔术字节,必须为
\xff Go buildinf:
; - 第 15 字节表示其指针大小,这里的值为
0x08
,表示 8 个字节; - 第 16 字节用于判断字节序是大端模式还是小端模式,非 0 为大端模式,0 为小端模式。
我们继续看第 17 字节开始的内容。
Go 版本信息
前面我们也看到了当前使用的字节序是小端模式,这里的地址应该是 0x009ffca0
。
我们来取出 16 字节的内容:
➜ kind git:(master) ✗ objdump -s --start-address 0x009ffca0 --stop-address 0x009ffcb0 ./bin/kind
./bin/kind: file format elf64-x86-64
Contents of section .data:
9ffca0 f5027d00 00000000 06000000 00000000 ..}.............
这里前面的 8 个字节是 Go 版本的信息,后 8 个字节是版本所占的大小(这里表示占 6 个字节)。
➜ kind git:(master) ✗ objdump -s --start-address 0x007d02f5 --stop-address 0x007d02fb ./bin/kind
./bin/kind: file format elf64-x86-64
Contents of section .rodata:
7d02f5 676f31 2e3136 go1.16
所以,如上所示,我们拿到了构建此二进制文件所用的 Go 版本的信息,是用 Go 1.16 进行构建的。
Go Module 信息
前面我们使用了 17~24 字节的信息,这次我们继续往后使用。
➜ kind git:(master) ✗ objdump -s --start-address 0x009ffce0 --stop-address 0x009ffcf0 ./bin/kind
./bin/kind: file format elf64-x86-64
Contents of section .data:
9ffce0 5a567e00 00000000 e6020000 00000000 ZV~.............
与前面获取 Go 版本信息时相同,前 8 个字节是指针,后 8 个字节是其大小。也就是说从 0x007e565a
开始,大小为 0x000002e6
,所以我们可以拿到以下内容:
➜ kind git:(master) ✗ objdump -s --start-address 0x007e565a --stop-address 0x7e5940 ./bin/kind
./bin/kind: file format elf64-x86-64
Contents of section .rodata:
7e565a 3077 af0c9274 080241e1 c107e6d6 18e6 0w...t..A.......
7e566a 7061 74680973 6967732e 6b38732e 696f path.sigs.k8s.io
7e567a 2f6b 696e640a 6d6f6409 73696773 2e6b /kind.mod.sigs.k
7e568a 3873 2e696f2f 6b696e64 09286465 7665 8s.io/kind.(deve
7e569a 6c29 090a6465 70096769 74687562 2e63 l)..dep.github.c
7e56aa 6f6d 2f427572 6e745375 7368692f 746f om/BurntSushi/to
7e56ba 6d6c 0976302e 332e3109 0a646570 0967 ml.v0.3.1..dep.g
7e56ca 6974 6875622e 636f6d2f 616c6573 7369 ithub.com/alessi
7e56da 6f2f 7368656c 6c657363 61706509 7631 o/shellescape.v1
7e56ea 2e34 2e31090a 64657009 67697468 7562 .4.1..dep.github
7e56fa 2e63 6f6d2f65 76616e70 68782f6a 736f .com/evanphx/jso
7e570a 6e2d 70617463 682f7635 0976352e 322e n-patch/v5.v5.2.
7e571a 3009 0a646570 09676974 6875622e 636f 0..dep.github.co
7e572a 6d2f 6d617474 6e2f676f 2d697361 7474 m/mattn/go-isatt
7e573a 7909 76302e30 2e313209 0a646570 0967 y.v0.0.12..dep.g
7e574a 6974 6875622e 636f6d2f 70656c6c 6574 ithub.com/pellet
7e575a 6965 722f676f 2d746f6d 6c097631 2e38 ier/go-toml.v1.8
7e576a 2e31 0968313a 314e6638 336f7270 726b .1.h1:1Nf83orprk
7e577a 4a79 6b6e5436 68377a62 75454755 456a JyknT6h7zbuEGUEj
7e578a 6379 566c4378 53554754 454e6d4e 4352 cyVlCxSUGTENmNCR
7e579a 4d3d 0a646570 09676974 6875622e 636f M=.dep.github.co
7e57aa 6d2f 706b672f 6572726f 72730976 302e m/pkg/errors.v0.
7e57ba 392e 31090a64 65700967 69746875 622e 9.1..dep.github.
7e57ca 636f 6d2f7370 6631332f 636f6272 6109 com/spf13/cobra.
7e57da 7631 2e312e31 090a6465 70096769 7468 v1.1.1..dep.gith
7e57ea 7562 2e636f6d 2f737066 31332f70 666c ub.com/spf13/pfl
7e57fa 6167 0976312e 302e3509 0a646570 0967 ag.v1.0.5..dep.g
7e580a 6f6c 616e672e 6f72672f 782f7379 7309 olang.org/x/sys.
7e581a 7630 2e302e30 2d323032 31303132 3431 v0.0.0-202101241
7e582a 3534 3534382d 32326461 36326531 3263 54548-22da62e12c
7e583a 3063 0968313a 56777967 55726e77 396a 0c.h1:VwygUrnw9j
7e584a 6e38 38633475 38474433 725a5162 7172 n88c4u8GD3rZQbqr
7e585a 502f 74676173 38387450 55624278 5172 P/tgas88tPUbBxQr
7e586a 6b3d 0a646570 09676f70 6b672e69 6e2f k=.dep.gopkg.in/
7e587a 7961 6d6c2e76 32097632 2e322e38 090a yaml.v2.v2.2.8..
7e588a 6465 7009676f 706b672e 696e2f79 616d dep.gopkg.in/yam
7e589a 6c2e 76330976 332e302e 302d3230 3231 l.v3.v3.0.0-2021
7e58aa 3031 30373139 32393232 2d343936 3534 0107192922-49654
7e58ba 3561 36333037 62096831 3a683871 446f 5a6307b.h1:h8qDo
7e58ca 7461 4550754a 4154724d 6d573034 4e43 taEPuJATrMmW04NC
7e58da 7767 37763232 61484832 38777770 6175 wg7v22aHH28wwpau
7e58ea 5568 4b394f6f 3d0a6465 70096b38 732e UhK9Oo=.dep.k8s.
7e58fa 696f 2f617069 6d616368 696e6572 7909 io/apimachinery.
7e590a 7630 2e32302e 32090a64 65700973 6967 v0.20.2..dep.sig
7e591a 732e 6b38732e 696f2f79 616d6c09 7631 s.k8s.io/yaml.v1
7e592a 2e32 2e30090a f9324331 86182072 0082 .2.0...2C1.. r..
7e593a 4210 4116d8f2 B.A...
我们成功的拿到了其所依赖的 Modules 相关的信息,
这与我们在文章开头执行 go version -m ./bin/kind
是可以匹配上的,只不过这里的内容相当于是做了序列化。
具体实现
在前面的内容中,关于如何使用 readelf 和 objdump 命令获取二进制文件的的 Go 版本和 Module 信息就已经涉及到了其具体的原理。这里我来介绍下 Go 代码的实现。
节头的名称是硬编码在代码中的
//src/cmd/go/internal/version/exe.go#L106-L110
for _, s := range x.f.Sections {
if s.Name == ".go.buildinfo" {
return s.Addr
}
}
同时,魔术字节也是通过如下定义:
var buildInfoMagic = []byte("\xff Go buildinf:")
获取 Version 和 Module 相关信息的逻辑如下,在前面的内容中也已经基本介绍过了,这里需要注意的也就是字节序相关的部分了。
ptrSize := int(data[14])
bigEndian := data[15] != 0
var bo binary.ByteOrder
if bigEndian {
bo = binary.BigEndian
} else {
bo = binary.LittleEndian
}
var readPtr func([]byte) uint64
if ptrSize == 4 {
readPtr = func(b []byte) uint64 { return uint64(bo.Uint32(b)) }
} else {
readPtr = bo.Uint64
}
vers = readString(x, ptrSize, readPtr, readPtr(data[16:]))
if vers == "" {
return
}
mod = readString(x, ptrSize, readPtr, readPtr(data[16+ptrSize:]))
if len(mod) >= 33 && mod[len(mod)-17] == '\n' {
// Strip module framing.
mod = mod[16 : len(mod)-16]
} else {
mod = ""
}
总结
我在这篇文章中分享了如何从 Go 的二进制文件中获取构建它时所用的 Go 版本及它依赖的模块信息。如果对原理不感兴趣的话,直接通过 go version -m 二进制文件
即可获取相关的信息。
具体实现还是依赖于 ELF 文件格式中的相关信息,同时也介绍了 readelf 和 objdump 工具的基本使用,ELF 格式除了本文介绍的这种场景外,还有很多有趣的场景可用,比如为了安全进行逆向之类的。
另外,你可能会好奇从 Go 的二进制文件获取这些信息有什么作用。最直接的来说,可以用于安全漏洞扫描,比如检查其依赖项是否有安全漏洞;或是可以对依赖进行分析(主要指:接触不到源代码的场景下)会比较有用。
欢迎订阅我的文章公众号【MoeLove】