GNU binutils 里的九種武器
想像一下,在無法訪問軟體的源代碼時,但仍然能夠理解軟體的實現方式,在其中找到漏洞,並且更厲害的是還能修復錯誤。所有這些都是在只有二進位文件時做到的。這聽起來就像是超能力,對吧?
你也可以擁有這樣的超能力,GNU 二進位實用程序(binutils)就是一個很好的起點。GNU binutils 是一個二進位工具集,默認情況下所有 Linux 發行版中都會安裝這些二進位工具。
二進位分析是計算機行業中最被低估的技能。它主要由惡意軟體分析師、反向工程師和使用底層軟體的人使用。
本文探討了 binutils 可用的一些工具。我使用的是 RHEL,但是這些示例應該在任何 Linux 發行版上可以運行。
[~]# cat /etc/redhat-release
Red Hat Enterprise Linux Server release 7.6 (Maipo)
[~]#
[~]# uname -r
3.10.0-957.el7.x86_64
[~]#
請注意,某些打包命令(例如 rpm
)在基於 Debian 的發行版中可能不可用,因此請使用等效的 dpkg
命令替代。
軟體開發的基礎知識
在開源世界中,我們很多人都專註於源代碼形式的軟體。當軟體的源代碼隨時可用時,很容易獲得源代碼的副本,打開喜歡的編輯器,喝杯咖啡,然後就可以開始探索了。
但是源代碼不是在 CPU 上執行的代碼,在 CPU 上執行的是二進位或者說是機器語言指令。二進位或可執行文件是編譯源代碼時獲得的。熟練的調試人員深諳通常這種差異。
編譯的基礎知識
在深入研究 binutils 軟體包本身之前,最好先了解編譯的基礎知識。
編譯是將程序從某種編程語言(如 C/C++)的源代碼(文本形式)轉換為機器代碼的過程。
機器代碼是 CPU(或一般而言,硬體)可以理解的 1 和 0 的序列,因此可以由 CPU 執行或運行。該機器碼以特定格式保存到文件,通常稱為可執行文件或二進位文件。在 Linux(和使用 Linux 兼容二進位的 BSD)上,這稱為 ELF( 可執行和可鏈接格式 )。
在生成給定的源文件的可執行文件或二進位文件之前,編譯過程將經歷一系列複雜的步驟。以這個源程序(C 代碼)為例。打開你喜歡的編輯器,然後鍵入以下程序:
#include <stdio.h>
int main(void)
{
printf("Hello Worldn");
return 0;
}
步驟 1:用 cpp 預處理
C 預處理程序(cpp)用於擴展所有宏並將頭文件包含進來。在此示例中,頭文件 stdio.h
將被包含在源代碼中。stdio.h
是一個頭文件,其中包含有關程序內使用的 printf
函數的信息。對源代碼運行 cpp
,其結果指令保存在名為 hello.i
的文件中。可以使用文本編輯器打開該文件以查看其內容。列印 「hello world」 的源代碼在該文件的底部。
[testdir]# cat hello.c
#include <stdio.h>
int main(void)
{
printf("Hello Worldn");
return 0;
}
[testdir]#
[testdir]# cpp hello.c > hello.i
[testdir]#
[testdir]# ls -lrt
total 24
-rw-r--r--. 1 root root 76 Sep 13 03:20 hello.c
-rw-r--r--. 1 root root 16877 Sep 13 03:22 hello.i
[testdir]#
步驟 2:用 gcc 編譯
在此階段,無需創建目標文件就將步驟 1 中生成的預處理源代碼轉換為彙編語言指令。這個階段使用 GNU 編譯器集合(gcc)。對 hello.i
文件運行帶有 -S
選項的 gcc
命令後,它將創建一個名為 hello.s
的新文件。該文件包含該 C 程序的彙編語言指令。
你可以使用任何編輯器或 cat
命令查看其內容。
[testdir]#
[testdir]# gcc -Wall -S hello.i
[testdir]#
[testdir]# ls -l
total 28
-rw-r--r--. 1 root root 76 Sep 13 03:20 hello.c
-rw-r--r--. 1 root root 16877 Sep 13 03:22 hello.i
-rw-r--r--. 1 root root 448 Sep 13 03:25 hello.s
[testdir]#
[testdir]# cat hello.s
.file "hello.c"
.section .rodata
.LC0:
.string "Hello World"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-36)"
.section .note.GNU-stack,"",@progbits
[testdir]#
步驟 3:用 as 彙編
彙編器的目的是將彙編語言指令轉換為機器語言代碼,並生成擴展名為 .o
的目標文件。此階段使用默認情況下在所有 Linux 平台上都可用的 GNU 彙編器。
testdir]# as hello.s -o hello.o
[testdir]#
[testdir]# ls -l
total 32
-rw-r--r--. 1 root root 76 Sep 13 03:20 hello.c
-rw-r--r--. 1 root root 16877 Sep 13 03:22 hello.i
-rw-r--r--. 1 root root 1496 Sep 13 03:39 hello.o
-rw-r--r--. 1 root root 448 Sep 13 03:25 hello.s
[testdir]#
現在,你有了第一個 ELF 格式的文件;但是,還不能執行它。稍後,你將看到「 目標文件 」和「 可執行文件 」之間的區別。
[testdir]# file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
步驟 4:用 ld 鏈接
這是編譯的最後階段,將目標文件鏈接以創建可執行文件。可執行文件通常需要外部函數,這些外部函數通常來自系統庫(libc
)。
你可以使用 ld
命令直接調用鏈接器;但是,此命令有些複雜。相反,你可以使用帶有 -v
(詳細)標誌的 gcc
編譯器,以了解鏈接是如何發生的。(使用 ld
命令進行鏈接作為一個練習,你可以自行探索。)
[testdir]# gcc -v hello.o
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapper
Target: x86_64-redhat-linux
Configured with: ../configure --prefix=/usr --mandir=/usr/share/man [...] --build=x86_64-redhat-linux
Thread model: posix
gcc version 4.8.5 20150623 (Red Hat 4.8.5-36) (GCC)
COMPILER_PATH=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/:/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/:[...]:/usr/lib/gcc/x86_64-redhat-linux/
LIBRARY_PATH=/usr/lib/gcc/x86_64-redhat-linux/4.8.5/:/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/:/lib/../lib64/:/usr/lib/../lib64/:/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64'
/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/collect2 --build-id --no-add-needed --eh-frame-hdr --hash-style=gnu [...]/../../../../lib64/crtn.o
[testdir]#
運行此命令後,你應該看到一個名為 a.out
的可執行文件:
[testdir]# ls -l
total 44
-rwxr-xr-x. 1 root root 8440 Sep 13 03:45 a.out
-rw-r--r--. 1 root root 76 Sep 13 03:20 hello.c
-rw-r--r--. 1 root root 16877 Sep 13 03:22 hello.i
-rw-r--r--. 1 root root 1496 Sep 13 03:39 hello.o
-rw-r--r--. 1 root root 448 Sep 13 03:25 hello.s
對 a.out
運行 file
命令,結果表明它確實是 ELF 可執行文件:
[testdir]# file a.out
a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=48e4c11901d54d4bf1b6e3826baf18215e4255e5, not stripped
運行該可執行文件,看看它是否如源代碼所示工作:
[testdir]# ./a.out Hello World
工作了!在幕後發生了很多事情它才在屏幕上列印了 「Hello World」。想像一下在更複雜的程序中會發生什麼。
探索 binutils 工具
上面這個練習為使用 binutils 軟體包中的工具提供了良好的背景。我的系統帶有 binutils 版本 2.27-34;你的 Linux 發行版上的版本可能有所不同。
[~]# rpm -qa | grep binutils
binutils-2.27-34.base.el7.x86_64
binutils 軟體包中提供了以下工具:
[~]# rpm -ql binutils-2.27-34.base.el7.x86_64 | grep bin/
/usr/bin/addr2line
/usr/bin/ar
/usr/bin/as
/usr/bin/c++filt
/usr/bin/dwp
/usr/bin/elfedit
/usr/bin/gprof
/usr/bin/ld
/usr/bin/ld.bfd
/usr/bin/ld.gold
/usr/bin/nm
/usr/bin/objcopy
/usr/bin/objdump
/usr/bin/ranlib
/usr/bin/readelf
/usr/bin/size
/usr/bin/strings
/usr/bin/strip
上面的編譯練習已經探索了其中的兩個工具:用作彙編器的 as
命令,用作鏈接器的 ld
命令。繼續閱讀以了解上述 GNU binutils 軟體包工具中的其他七個。
readelf:顯示 ELF 文件信息
上面的練習提到了術語「目標文件」和「可執行文件」。使用該練習中的文件,通過帶有 -h
(標題)選項的 readelf
命令,以將文件的 ELF 標題轉儲到屏幕上。請注意,以 .o
擴展名結尾的目標文件顯示為 Type: REL (Relocatable file)
(可重定位文件):
[testdir]# readelf -h hello.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 [...]
[...]
Type: REL (Relocatable file)
[...]
如果嘗試執行此目標文件,會收到一條錯誤消息,指出無法執行。這僅表示它尚不具備在 CPU 上執行所需的信息。
請記住,你首先需要使用 chmod
命令在對象文件上添加 x
(可執行位),否則你將得到「許可權被拒絕」的錯誤。
[testdir]# ./hello.o
bash: ./hello.o: Permission denied
[testdir]# chmod +x ./hello.o
[testdir]#
[testdir]# ./hello.o
bash: ./hello.o: cannot execute binary file
如果對 a.out
文件嘗試相同的命令,則會看到其類型為 EXEC (Executable file)
(可執行文件)。
[testdir]# readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
[...] Type: EXEC (Executable file)
如上所示,該文件可以直接由 CPU 執行:
[testdir]# ./a.out Hello World
readelf
命令可提供有關二進位文件的大量信息。在這裡,它會告訴你它是 ELF 64 位格式,這意味著它只能在 64 位 CPU 上執行,而不能在 32 位 CPU 上運行。它還告訴你它應在 X86-64(Intel/AMD)架構上執行。該二進位文件的入口點是地址 0x400430
,它就是 C 源程序中 main
函數的地址。
在你知道的其他系統二進位文件上嘗試一下 readelf
命令,例如 ls
。請注意,在 RHEL 8 或 Fedora 30 及更高版本的系統上,由於安全原因改用了 位置無關可執行文件 (PIE),因此你的輸出(尤其是 Type:
)可能會有所不同。
[testdir]# readelf -h /bin/ls
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)
使用 ldd
命令了解 ls
命令所依賴的系統庫,如下所示:
[testdir]# ldd /bin/ls
linux-vdso.so.1 => (0x00007ffd7d746000)
libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f060daca000)
libcap.so.2 => /lib64/libcap.so.2 (0x00007f060d8c5000)
libacl.so.1 => /lib64/libacl.so.1 (0x00007f060d6bc000)
libc.so.6 => /lib64/libc.so.6 (0x00007f060d2ef000)
libpcre.so.1 => /lib64/libpcre.so.1 (0x00007f060d08d000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007f060ce89000)
/lib64/ld-linux-x86-64.so.2 (0x00007f060dcf1000)
libattr.so.1 => /lib64/libattr.so.1 (0x00007f060cc84000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f060ca68000)
對 libc
庫文件運行 readelf
以查看它是哪種文件。正如它指出的那樣,它是一個 DYN (Shared object file)
(共享對象文件),這意味著它不能直接執行;必須由內部使用了該庫提供的任何函數的可執行文件使用它。
[testdir]# readelf -h /lib64/libc.so.6
ELF Header:
Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - GNU
ABI Version: 0
Type: DYN (Shared object file)
size:列出節的大小和全部大小
size
命令僅適用於目標文件和可執行文件,因此,如果嘗試在簡單的 ASCII 文件上運行它,則會拋出錯誤,提示「文件格式無法識別」。
[testdir]# echo "test" > file1
[testdir]# cat file1
test
[testdir]# file file1
file1: ASCII text
[testdir]# size file1
size: file1: File format not recognized
現在,在上面的練習中,對目標文件和可執行文件運行 size
命令。請注意,根據 size
命令的輸出可以看出,可執行文件(a.out
)的信息要比目標文件(hello.o
)多得多:
[testdir]# size hello.o
text data bss dec hex filename
89 0 0 89 59 hello.o
[testdir]# size a.out
text data bss dec hex filename
1194 540 4 1738 6ca a.out
但是這裡的 text
、data
和 bss
節是什麼意思?
text
節是指二進位文件的代碼部分,其中包含所有可執行指令。data
節是所有初始化數據所在的位置,bss
節是所有未初始化數據的存儲位置。(LCTT 譯註:一般來說,在靜態的映像文件中,各個部分稱之為 節 ,而在運行時的各個部分稱之為 段 ,有時統稱為段。)
比較其他一些可用的系統二進位文件的 size
結果。
對於 ls
命令:
[testdir]# size /bin/ls
text data bss dec hex filename
103119 4768 3360 111247 1b28f /bin/ls
只需查看 size
命令的輸出,你就可以看到 gcc
和 gdb
是比 ls
大得多的程序:
[testdir]# size /bin/gcc
text data bss dec hex filename
755549 8464 81856 845869 ce82d /bin/gcc
[testdir]# size /bin/gdb
text data bss dec hex filename
6650433 90842 152280 6893555 692ff3 /bin/gdb
strings:列印文件中的可列印字元串
在 strings
命令中添加 -d
標誌以僅顯示 data
節中的可列印字元通常很有用。
hello.o
是一個目標文件,其中包含列印出 Hello World
文本的指令。因此,strings
命令的唯一輸出是 Hello World
。
[testdir]# strings -d hello.o
Hello World
另一方面,在 a.out
(可執行文件)上運行 strings
會顯示在鏈接階段該二進位文件中包含的其他信息:
[testdir]# strings -d a.out
/lib64/ld-linux-x86-64.so.2
!^BU
libc.so.6
puts
__libc_start_main
__gmon_start__
GLIBC_2.2.5
UH-0
UH-0
=(
[]AA]A^A_
Hello World
;*3$"
objdump:顯示目標文件信息
另一個可以從二進位文件中轉儲機器語言指令的 binutils 工具稱為 objdump
。使用 -d
選項,可從二進位文件中反彙編出所有彙編指令。
回想一下,編譯是將源代碼指令轉換為機器代碼的過程。機器代碼僅由 1 和 0 組成,人類難以閱讀。因此,它有助於將機器代碼表示為彙編語言指令。彙編語言是什麼樣的?請記住,彙編語言是特定於體系結構的;由於我使用的是 Intel(x86-64)架構,因此如果你使用 ARM 架構編譯相同的程序,指令將有所不同。
[testdir]# objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000
:
0: 55 push %rbp
1: 48 89 e5 mov %rsp,%rbp
4: bf 00 00 00 00 mov $0x0,%edi
9: e8 00 00 00 00 callq e
e: b8 00 00 00 00 mov $0x0,%eax
13: 5d pop %rbp
14: c3 retq
該輸出乍一看似乎令人生畏,但請花一點時間來理解它,然後再繼續。回想一下,.text
節包含所有的機器代碼指令。彙編指令可以在第四列中看到(即 push
、mov
、callq
、pop
、retq
等)。這些指令作用於寄存器,寄存器是 CPU 內置的存儲器位置。本示例中的寄存器是 rbp
、rsp
、edi
、eax
等,並且每個寄存器都有特殊的含義。
現在對可執行文件(a.out
)運行 objdump
並查看得到的內容。可執行文件的 objdump
的輸出可能很大,因此我使用 grep
命令將其縮小到 main
函數:
[testdir]# objdump -d a.out | grep -A 9 main>
000000000040051d
:
40051d: 55 push %rbp
40051e: 48 89 e5 mov %rsp,%rbp
400521: bf d0 05 40 00 mov $0x4005d0,%edi
400526: e8 d5 fe ff ff callq 400400
40052b: b8 00 00 00 00 mov $0x0,%eax
400530: 5d pop %rbp
400531: c3 retq
請注意,這些指令與目標文件 hello.o
相似,但是其中包含一些其他信息:
- 目標文件
hello.o
具有以下指令:callq e
- 可執行文件
a.out
由以下指令組成,該指令帶有一個地址和函數:callq 400400 <puts@plt>
上面的彙編指令正在調用puts
函數。請記住,你在源代碼中使用了一個printf
函數。編譯器插入了對puts
庫函數的調用,以將Hello World
輸出到屏幕。
查看 put
上方一行的說明:
- 目標文件
hello.o
有個指令mov
:mov $0x0,%edi
- 可執行文件
a.out
的mov
指令帶有實際地址($0x4005d0
)而不是$0x0
:mov $0x4005d0,%edi
該指令將二進位文件中地址 $0x4005d0
處存在的內容移動到名為 edi
的寄存器中。
這個存儲位置的內容中還能是別的什麼嗎?是的,你猜對了:它就是文本 Hello, World
。你是如何確定的?
readelf
命令使你可以將二進位文件(a.out
)的任何節轉儲到屏幕上。以下要求它將 .rodata
(這是只讀數據)轉儲到屏幕上:
[testdir]# readelf -x .rodata a.out
Hex dump of section '.rodata':
0x004005c0 01000200 00000000 00000000 00000000 ....
0x004005d0 48656c6c 6f20576f 726c6400 Hello World.
你可以在右側看到文本 Hello World
,在左側可以看到其二進位格式的地址。它是否與你在上面的 mov
指令中看到的地址匹配?是的,確實匹配。
strip:從目標文件中剝離符號
該命令通常用於在將二進位文件交付給客戶之前減小二進位文件的大小。
請記住,由於重要信息已從二進位文件中刪除,因此它會妨礙調試。但是,這個二進位文件可以完美地執行。
對 a.out
可執行文件運行該命令,並注意會發生什麼。首先,通過運行以下命令確保二進位文件沒有被剝離(not stripped
):
[testdir]# file a.out
a.out: ELF 64-bit LSB executable, x86-64, [......] not stripped
另外,在運行 strip
命令之前,請記下二進位文件中最初的位元組數:
[testdir]# du -b a.out
8440 a.out
現在對該可執行文件運行 strip
命令,並使用 file
命令以確保正常完成:
[testdir]# strip a.out
[testdir]# file a.out a.out: ELF 64-bit LSB executable, x86-64, [......] stripped
剝離該二進位文件後,此小程序的大小從之前的 8440
位元組減小為 6296
位元組。對於這樣小的一個程序都能有這麼大的空間節省,難怪大型程序經常被剝離。
[testdir]# du -b a.out
6296 a.out
addr2line:轉換地址到文件名和行號
addr2line
工具只是在二進位文件中查找地址,並將其與 C 源代碼程序中的行進行匹配。很酷,不是嗎?
為此編寫另一個測試程序;只是這一次確保使用 gcc
的 -g
標誌進行編譯,這將為二進位文件添加其它調試信息,並包含有助於調試的行號(由源代碼中提供):
[testdir]# cat -n atest.c
1 #include <stdio.h>
2
3 int globalvar = 100;
4
5 int function1(void)
6 {
7 printf("Within function1n");
8 return 0;
9 }
10
11 int function2(void)
12 {
13 printf("Within function2n");
14 return 0;
15 }
16
17 int main(void)
18 {
19 function1();
20 function2();
21 printf("Within mainn");
22 return 0;
23 }
用 -g
標誌編譯並執行它。正如預期:
[testdir]# gcc -g atest.c
[testdir]# ./a.out
Within function1
Within function2
Within main
現在使用 objdump
來標識函數開始的內存地址。你可以使用 grep
命令來過濾出所需的特定行。函數的地址在下面突出顯示(55 push %rbp
前的地址):
[testdir]# objdump -d a.out | grep -A 2 -E 'main>:|function1>:|function2>:'
000000000040051d :
40051d: 55 push %rbp
40051e: 48 89 e5 mov %rsp,%rbp
--
0000000000400532 :
400532: 55 push %rbp
400533: 48 89 e5 mov %rsp,%rbp
--
0000000000400547
:
400547: 55 push %rbp
400548: 48 89 e5 mov %rsp,%rbp
現在,使用 addr2line
工具從二進位文件中的這些地址映射到 C 源代碼匹配的地址:
[testdir]# addr2line -e a.out 40051d
/tmp/testdir/atest.c:6
[testdir]#
[testdir]# addr2line -e a.out 400532
/tmp/testdir/atest.c:12
[testdir]#
[testdir]# addr2line -e a.out 400547
/tmp/testdir/atest.c:18
它說 40051d
從源文件 atest.c
中的第 6
行開始,這是 function1
的起始大括弧({
)開始的行。function2
和 main
的輸出也匹配。
nm:列出目標文件的符號
使用上面的 C 程序測試 nm
工具。使用 gcc
快速編譯並執行它。
[testdir]# gcc atest.c
[testdir]# ./a.out
Within function1
Within function2
Within main
現在運行 nm
和 grep
獲取有關函數和變數的信息:
[testdir]# nm a.out | grep -Ei 'function|main|globalvar'
000000000040051d T function1
0000000000400532 T function2
000000000060102c D globalvar
U __libc_start_main@@GLIBC_2.2.5
0000000000400547 T main
你可以看到函數被標記為 T
,它表示 text
節中的符號,而變數標記為 D
,表示初始化的 data
節中的符號。
想像一下在沒有源代碼的二進位文件上運行此命令有多大用處?這使你可以窺視內部並了解使用了哪些函數和變數。當然,除非二進位文件已被剝離,這種情況下它們將不包含任何符號,因此 nm
就命令不會很有用,如你在此處看到的:
[testdir]# strip a.out
[testdir]# nm a.out | grep -Ei 'function|main|globalvar'
nm: a.out: no symbols
結論
GNU binutils 工具為有興趣分析二進位文件的人提供了許多選項,這只是它們可以為你做的事情的冰山一角。請閱讀每種工具的手冊頁,以了解有關它們以及如何使用它們的更多信息。
via: https://opensource.com/article/19/10/gnu-binutils
作者:Gaurav Kamathe 選題:lujun9972 譯者:wxy 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive