二進制分析是計算機行業中最被低估的技能。
想象一下,在無法訪問軟件的源代碼時,但仍然能夠理解軟件的實現方式,在其中找到漏洞,并且更厲害的是還能修復錯誤。所有這些都是在只有二進制文件時做到的。這聽起來就像是超能力,對吧?
你也可以擁有這樣的超能力,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 World\n'); 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 World\n'); 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 =( []A\A]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 function1\n'); 8 return 0; 9 } 10 11 int function2(void) 12 { 13 printf('Within function2\n'); 14 return 0; 15 } 16 17 int main(void) 18 { 19 function1(); 20 function2(); 21 printf('Within main\n'); 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:///article/19/10/gnu-binutils
作者:Gaurav Kamathe 選題:lujun9972 譯者:wxy 校對:wxy
本文由 LCTT 原創編譯,Linux中國 榮譽推出
|