深入瞭解 Go ELF 信息

大家好,我是張晉濤。

我們用 Go 構建的二進制文件中默認包含了很多有用的信息。例如,可以獲取構建用的 Go 版本:

(這裏我使用我一直參與的一個開源項目 KIND[1] 爲例)

➜  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 內核源碼 [2] 中拿到此處的具體定義:

// 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 內核源碼 [3] 中找到:

// 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 個字節。

我們繼續看第 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([]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】

參考資料

[1]

KIND 項目地址: https://github.com/kubernetes-sigs/kind

[2]

ELF Magic 內核源碼: https://github.com/torvalds/linux/blob/2c85ebc57b3e1817b6ce1a6b703928e113a90442/include/uapi/linux/elf.h#L340-L343

[3]

ELF Class 內核定義: https://github.com/torvalds/linux/blob/2c85ebc57b3e1817b6ce1a6b703928e113a90442/include/uapi/linux/elf.h#L347-L350

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/4up10s2NRjQrTXCKv-p6eA