想像一下,在無法訪問軟體的原始碼時,但仍然能夠理解軟體的實現方式,在其中找到漏洞,並且更厲害的是還能修復錯誤。所有這些都是在只有二進位文件時做到的。這聽起來就像是超能力,對吧?
你也可以擁有這樣的超能力,GNU 二進位實用程序(binutils)就是一個很好的起點。GNU binutils 是一個二進位工具集,默認情況下所有 Linux 發行版中都會安裝這些二進位工具。
二進位分析是計算機行業中最被低估的技能。它主要由惡意軟體分析師、反向工程師和使用底層軟體的人使用。
本文探討了 binutils 可用的一些工具。我使用的是 RHEL,但是這些示例應該在任何 Linux 發行版上可以運行。
[~]# cat /etc/redhat-releaseRed Hat Enterprise Linux Server release 7.6 (Maipo)
請注意,某些打包命令(例如 rpm)在基於 Debian 的發行版中可能不可用,因此請使用等效的 dpkg 命令替代。
軟體開發的基礎知識
在開源世界中,我們很多人都專注於原始碼形式的軟體。當軟體的原始碼隨時可用時,很容易獲得原始碼的副本,打開喜歡的編輯器,喝杯咖啡,然後就可以開始探索了。
但是原始碼不是在 CPU 上執行的代碼,在 CPU 上執行的是二進位或者說是機器語言指令。二進位或可執行文件是編譯原始碼時獲得的。熟練的調試人員深諳通常這種差異。
編譯的基礎知識
在深入研究 binutils 軟體包本身之前,最好先了解編譯的基礎知識。
編譯是將程序從某種程式語言(如 C/C++)的原始碼(文本形式)轉換為機器代碼的過程。
機器代碼是 CPU(或一般而言,硬體)可以理解的 1 和 0 的序列,因此可以由 CPU 執行或運行。該機器碼以特定格式保存到文件,通常稱為可執行文件或二進位文件。在 Linux(和使用 Linux 兼容二進位的 BSD)上,這稱為 ELF(可執行和可連結格式Executable and Linkable Format)。
在生成給定的源文件的可執行文件或二進位文件之前,編譯過程將經歷一系列複雜的步驟。以這個源程序(C 代碼)為例。打開你喜歡的編輯器,然後鍵入以下程序:
步驟 1:用 cpp 預處理
C 預處理程序(cpp)用於擴展所有宏並將頭文件包含進來。在此示例中,頭文件 stdio.h 將被包含在原始碼中。stdio.h 是一個頭文件,其中包含有關程序內使用的 printf 函數的信息。對原始碼運行 cpp,其結果指令保存在名為 hello.i 的文件中。可以使用文本編輯器打開該文件以查看其內容。列印 「hello world」 的原始碼在該文件的底部。
[testdir]# cpp hello.c > hello.i-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
步驟 2:用 gcc 編譯
在此階段,無需創建目標文件就將步驟 1 中生成的預處理原始碼轉換為彙編語言指令。這個階段使用 GNU 編譯器集合(gcc)。對 hello.i 文件運行帶有 -S 選項的 gcc 命令後,它將創建一個名為 hello.s 的新文件。該文件包含該 C 程序的彙編語言指令。
你可以使用任何編輯器或 cat 命令查看其內容。
[testdir]# gcc -Wall -S hello.i-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.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-36)".section .note.GNU-stack,"",@progbits
步驟 3:用 as 彙編
彙編器的目的是將彙編語言指令轉換為機器語言代碼,並生成擴展名為 .o 的目標文件。此階段使用默認情況下在所有 Linux 平臺上都可用的 GNU 彙編器。
testdir]# as hello.s -o hello.o-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
現在,你有了第一個 ELF 格式的文件;但是,還不能執行它。稍後,你將看到「目標文件object file」和「可執行文件executable file」之間的區別。
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.oCOLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapperTarget: x86_64-redhat-linuxConfigured with: ../configure --prefix=/usr --mandir=/usr/share/man [...] --build=x86_64-redhat-linuxgcc 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
運行此命令後,你應該看到一個名為 a.out 的可執行文件:
-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 可執行文件:
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 binutilsbinutils-2.27-34.base.el7.x86_64
binutils 軟體包中提供了以下工具:
[~]# rpm -ql binutils-2.27-34.base.el7.x86_64 | grep bin/
上面的編譯練習已經探索了其中的兩個工具:用作彙編器的 as 命令,用作連結器的 ld 命令。繼續閱讀以了解上述 GNU binutils 軟體包工具中的其他七個。
readelf:顯示 ELF 文件信息
上面的練習提到了術語「目標文件」和「可執行文件」。使用該練習中的文件,通過帶有 -h(標題)選項的 readelf 命令,以將文件的 ELF 標題轉儲到屏幕上。請注意,以 .o 擴展名結尾的目標文件顯示為 Type: REL (Relocatable file)(可重定位文件):
[testdir]# readelf -h hello.oMagic: 7f 45 4c 46 02 01 01 00 [...]Type: REL (Relocatable file)
如果嘗試執行此目標文件,會收到一條錯誤消息,指出無法執行。這僅表示它尚不具備在 CPU 上執行所需的信息。
請記住,你首先需要使用 chmod 命令在對象文件上添加 x(可執行位),否則你將得到「權限被拒絕」的錯誤。
bash: ./hello.o: Permission denied[testdir]# chmod +x ./hello.obash: ./hello.o: cannot execute binary file
如果對 a.out 文件嘗試相同的命令,則會看到其類型為 EXEC (Executable file)(可執行文件)。
[testdir]# readelf -h a.outMagic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00[...] 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 及更高版本的系統上,由於安全原因改用了位置無關可執行文件position independent executable(PIE),因此你的輸出(尤其是 Type:)可能會有所不同。
[testdir]# readelf -h /bin/lsMagic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00Data: 2's complement, little endianType: EXEC (Executable file)
使用 ldd 命令了解 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.6Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00Data: 2's complement, little endianType: DYN (Shared object file)
size:列出節的大小和全部大小
size 命令僅適用於目標文件和可執行文件,因此,如果嘗試在簡單的 ASCII 文件上運行它,則會拋出錯誤,提示「文件格式無法識別」。
[testdir]# echo "test" > file1size: file1: File format not recognized
現在,在上面的練習中,對目標文件和可執行文件運行 size 命令。請注意,根據 size 命令的輸出可以看出,可執行文件(a.out)的信息要比目標文件(hello.o)多得多:
text data bss dec hex filenametext data bss dec hex filename1194 540 4 1738 6ca a.out
但是這裡的 text、data 和 bss 節是什麼意思?
text 節是指二進位文件的代碼部分,其中包含所有可執行指令。data 節是所有初始化數據所在的位置,bss 節是所有未初始化數據的存儲位置。(LCTT 譯註:一般來說,在靜態的映像文件中,各個部分稱之為節section,而在運行時的各個部分稱之為段segment,有時統稱為段。)
比較其他一些可用的系統二進位文件的 size 結果。
對於 ls 命令:
text data bss dec hex filename103119 4768 3360 111247 1b28f /bin/ls
只需查看 size 命令的輸出,你就可以看到 gcc 和 gdb 是比 ls 大得多的程序:
text data bss dec hex filename755549 8464 81856 845869 ce82d /bin/gcctext data bss dec hex filename6650433 90842 152280 6893555 692ff3 /bin/gdb
strings:列印文件中的可列印字符串
在 strings 命令中添加 -d 標誌以僅顯示 data 節中的可列印字符通常很有用。
hello.o 是一個目標文件,其中包含列印出 Hello World 文本的指令。因此,strings 命令的唯一輸出是 Hello World。
[testdir]# strings -d hello.o
另一方面,在 a.out(可執行文件)上運行 strings 會顯示在連結階段該二進位文件中包含的其他信息:
[testdir]# strings -d a.out/lib64/ld-linux-x86-64.so.2
objdump:顯示目標文件信息
另一個可以從二進位文件中轉儲機器語言指令的 binutils 工具稱為 objdump。使用 -d 選項,可從二進位文件中反彙編出所有彙編指令。
回想一下,編譯是將原始碼指令轉換為機器代碼的過程。機器代碼僅由 1 和 0 組成,人類難以閱讀。因此,它有助於將機器代碼表示為彙編語言指令。彙編語言是什麼樣的?請記住,彙編語言是特定於體系結構的;由於我使用的是 Intel(x86-64)架構,因此如果你使用 ARM 架構編譯相同的程序,指令將有所不同。
[testdir]# objdump -d hello.ohello.o: file format elf64-x86-64Disassembly of section .text:1: 48 89 e5 mov %rsp,%rbp4: bf 00 00 00 00 mov $0x0,%edi9: e8 00 00 00 00 callq ee: b8 00 00 00 00 mov $0x0,%eax
該輸出乍一看似乎令人生畏,但請花一點時間來理解它,然後再繼續。回想一下,.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\>40051e: 48 89 e5 mov %rsp,%rbp400521: bf d0 05 40 00 mov $0x4005d0,%edi400526: e8 d5 fe ff ff callq 40040040052b: b8 00 00 00 00 mov $0x0,%eax
請注意,這些指令與目標文件 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.outHex dump of section '.rodata':0x004005c0 01000200 00000000 00000000 00000000 ....0x004005d0 48656c6c 6f20576f 726c6400 Hello World.
你可以在右側看到文本 Hello World,在左側可以看到其二進位格式的地址。它是否與你在上面的 mov 指令中看到的地址匹配?是的,確實匹配。
strip:從目標文件中剝離符號
該命令通常用於在將二進位文件交付給客戶之前減小二進位文件的大小。
請記住,由於重要信息已從二進位文件中刪除,因此它會妨礙調試。但是,這個二進位文件可以完美地執行。
對 a.out 可執行文件運行該命令,並注意會發生什麼。首先,通過運行以下命令確保二進位文件沒有被剝離(not stripped):
a.out: ELF 64-bit LSB executable, x86-64, [.] not stripped
另外,在運行 strip 命令之前,請記下二進位文件中最初的字節數:
現在對該可執行文件運行 strip 命令,並使用 file 命令以確保正常完成:
[testdir]# file a.out a.out: ELF 64-bit LSB executable, x86-64, [.] stripped
剝離該二進位文件後,此小程序的大小從之前的 8440 字節減小為 6296 字節。對於這樣小的一個程序都能有這麼大的空間節省,難怪大型程序經常被剝離。
addr2line:轉換地址到文件名和行號
addr2line 工具只是在二進位文件中查找地址,並將其與 C 原始碼程序中的行進行匹配。很酷,不是嗎?
為此編寫另一個測試程序;只是這一次確保使用 gcc 的 -g 標誌進行編譯,這將為二進位文件添加其它調試信息,並包含有助於調試的行號(由原始碼中提供):
[testdir]# cat -n atest.c7 printf("Within function1\n");13 printf("Within function2\n");21 printf("Within main\n");
用 -g 標誌編譯並執行它。正如預期:
[testdir]# gcc -g atest.c
現在使用 objdump 來標識函數開始的內存地址。你可以使用 grep 命令來過濾出所需的特定行。函數的地址在下面突出顯示(55 push %rbp 前的地址):
[testdir]# objdump -d a.out | grep -A 2 -E 'main>:|function1>:|function2>:'40051e: 48 89 e5 mov %rsp,%rbp400533: 48 89 e5 mov %rsp,%rbp400548: 48 89 e5 mov %rsp,%rbp
現在,使用 addr2line 工具從二進位文件中的這些地址映射到 C 原始碼匹配的地址:
[testdir]# addr2line -e a.out 40051d[testdir]# addr2line -e a.out 400532[testdir]# addr2line -e a.out 400547
它說 40051d 從源文件 atest.c 中的第 6 行開始,這是 function1 的起始大括號({)開始的行。function2 和 main 的輸出也匹配。
nm:列出目標文件的符號
使用上面的 C 程序測試 nm 工具。使用 gcc 快速編譯並執行它。
現在運行 nm 和 grep 獲取有關函數和變量的信息:
[testdir]# nm a.out | grep -Ei 'function|main|globalvar'000000000040051d T function10000000000400532 T function2000000000060102c D globalvarU __libc_start_main@@GLIBC_2.2.5
你可以看到函數被標記為 T,它表示 text 節中的符號,而變量標記為 D,表示初始化的 data 節中的符號。
想像一下在沒有原始碼的二進位文件上運行此命令有多大用處?這使你可以窺視內部並了解使用了哪些函數和變量。當然,除非二進位文件已被剝離,這種情況下它們將不包含任何符號,因此 nm就命令不會很有用,如你在此處看到的:
[testdir]# nm a.out | grep -Ei 'function|main|globalvar'
結論
GNU binutils 工具為有興趣分析二進位文件的人提供了許多選項,這只是它們可以為你做的事情的冰山一角。請閱讀每種工具的手冊頁,以了解有關它們以及如何使用它們的更多信息。
via: https://opensource.com/article/19/10/gnu-binutils
作者:Gaurav Kamathe 選題:lujun9972 譯者:wxy 校對:wxy
本文由 LCTT 原創編譯,Linux中國 榮譽推出