大家好,我是张晋涛。
我们用 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 构建的二进制文件仍是本文的主角。
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 头始终位于文件的零偏移(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.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 个字节。
\xff Go buildinf:
;0x08
,表示 8 个字节;我们继续看第 17 字节开始的内容。
前面我们也看到了当前使用的字节序是小端模式,这里的地址应该是 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 进行构建的。
前面我们使用了 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】