二進位制分析是計算機行業中最被低估的技能。
想象一下,在無法存取軟體的原始碼時,但仍然能夠理解軟體的實現方式,在其中找到漏洞,並且更厲害的是還能修復錯誤。所有這些都是在只有二進位制檔案時做到的。這聽起來就像是超能力,對吧?
你也可以擁有這樣的超能力,GNU 二進位制實用程式(binutils)就是一個很好的起點。GNU binutils 是一個二進位制工具集,預設情況下所有 Linux 發行版中都會安裝這些二進位制工具。
二進位制分析是計算機行業中最被低估的技能。它主要由惡意軟體分析師、反向工程師和使用底層軟體的人使用。
本文探討了 binutils 可用的一些工具。我使用的是 RHEL,但是這些範例應該在任何 Linux 發行版上可以執行。
[~]# cat /etc/redhat-release Red Hat Enterprise Linux Server release 7.6 (Maipo)[~]# [~]# uname -r3.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;}
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 -lrttotal 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]#
在此階段,無需建立目標檔案就將步驟 1 中生成的預處理原始碼轉換為組合語言指令。這個階段使用 GNU 編譯器集合(gcc)。對 hello.i
檔案執行帶有 -S
選項的 gcc
命令後,它將建立一個名為 hello.s
的新檔案。該檔案包含該 C 程式的組合語言指令。
你可以使用任何編輯器或 cat
命令檢視其內容。
[testdir]#[testdir]# gcc -Wall -S hello.i[testdir]#[testdir]# ls -ltotal 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, @functionmain:.LFB0:.cfi_startprocpushq %rbp.cfi_def_cfa_offset 16.cfi_offset 6, -16movq %rsp, %rbp.cfi_def_cfa_register 6movl $.LC0, %edicall putsmovl $0, %eaxpopq %rbp.cfi_def_cfa 7, 8ret.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]#
組合器的目的是將組合語言指令轉換為機器語言程式碼,並生成擴充套件名為 .o
的目標檔案。此階段使用預設情況下在所有 Linux 平台上都可用的 GNU 組合器。
testdir]# as hello.s -o hello.o[testdir]#[testdir]# ls -ltotal 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.ohello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
這是編譯的最後階段,將目標檔案連結以建立可執行檔案。可執行檔案通常需要外部函數,這些外部函數通常來自系統庫(libc
)。
你可以使用 ld
命令直接呼叫連結器;但是,此命令有些複雜。相反,你可以使用帶有 -v
(詳細)標誌的 gcc
編譯器,以了解連結是如何發生的。(使用 ld
命令進行連結作為一個練習,你可以自行探索。)
[testdir]# gcc -v hello.oUsing built-in specs.COLLECT_GCC=gccCOLLECT_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-linuxThread model: posixgcc 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 -ltotal 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.outa.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 版本 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 軟體包工具中的其他七個。
上面的練習提到了術語“目標檔案”和“可執行檔案”。使用該練習中的檔案,通過帶有 -h
(標題)選項的 readelf
命令,以將檔案的 ELF 標題轉儲到螢幕上。請注意,以 .o
擴充套件名結尾的目標檔案顯示為 Type: REL (Relocatable file)
(可重定位檔案):
[testdir]# readelf -h hello.oELF Header:Magic: 7f 45 4c 46 02 01 01 00 [...][...]Type: REL (Relocatable file)[...]
如果嘗試執行此目標檔案,會收到一條錯誤訊息,指出無法執行。這僅表示它尚不具備在 CPU 上執行所需的資訊。
請記住,你首先需要使用 chmod
命令在物件檔案上新增 x
(可執行位),否則你將得到“許可權被拒絕”的錯誤。
[testdir]# ./hello.obash: ./hello.o: Permission denied[testdir]# chmod +x ./hello.o[testdir]#[testdir]# ./hello.obash: ./hello.o: cannot execute binary file
如果對 a.out
檔案嘗試相同的命令,則會看到其型別為 EXEC (Executable file)
(可執行檔案)。
[testdir]# readelf -h a.outELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00Class: 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/lsELF Header:Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00Class: ELF64Data: 2's complement, little endianVersion: 1 (current)OS/ABI: UNIX - System VABI Version: 0Type: EXEC (Executable file)
使用 ldd
命令了解 ls
命令所依賴的系統庫,如下所示:
[testdir]# ldd /bin/lslinux-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.6ELF Header:Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00Class: ELF64Data: 2's complement, little endianVersion: 1 (current)OS/ABI: UNIX - GNUABI Version: 0Type: DYN (Shared object file)
size
命令僅適用於目標檔案和可執行檔案,因此,如果嘗試在簡單的 ASCII 檔案上執行它,則會丟擲錯誤,提示“檔案格式無法識別”。
[testdir]# echo "test" > file1[testdir]# cat file1test[testdir]# file file1file1: ASCII text[testdir]# size file1size: file1: File format not recognized
現在,在上面的練習中,對目標檔案和可執行檔案執行 size
命令。請注意,根據 size
命令的輸出可以看出,可執行檔案(a.out
)的資訊要比目標檔案(hello.o
)多得多:
[testdir]# size hello.otext data bss dec hex filename89 0 0 89 59 hello.o[testdir]# size a.outtext data bss dec hex filename1194 540 4 1738 6ca a.out
但是這裡的 text
、data
和 bss
節是什麼意思?
text
節是指二進位制檔案的程式碼部分,其中包含所有可執行指令。data
節是所有初始化資料所在的位置,bss
節是所有未初始化資料的儲存位置。(LCTT 譯註:一般來說,在靜態的映像檔案中,各個部分稱之為節,而在執行時的各個部分稱之為段,有時統稱為段。)
比較其他一些可用的系統二進位制檔案的 size
結果。
對於 ls
命令:
[testdir]# size /bin/lstext data bss dec hex filename103119 4768 3360 111247 1b28f /bin/ls
只需檢視 size
命令的輸出,你就可以看到 gcc
和 gdb
是比 ls
大得多的程式:
[testdir]# size /bin/gcctext data bss dec hex filename755549 8464 81856 845869 ce82d /bin/gcc[testdir]# size /bin/gdbtext data bss dec hex filename6650433 90842 152280 6893555 692ff3 /bin/gdb
在 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!^BUlibc.so.6puts__libc_start_main__gmon_start__GLIBC_2.2.5UH-0UH-0=([]A\A]A^A_Hello World;*3$"
另一個可以從二進位制檔案中轉儲機器語言指令的 binutils 工具稱為 objdump
。使用 -d
選項,可從二進位制檔案中反組合出所有組合指令。
回想一下,編譯是將原始碼指令轉換為機器程式碼的過程。機器程式碼僅由 1 和 0 組成,人類難以閱讀。因此,它有助於將機器程式碼表示為組合語言指令。組合語言是什麼樣的?請記住,組合語言是特定於體系結構的;由於我使用的是 Intel(x86-64)架構,因此如果你使用 ARM 架構編譯相同的程式,指令將有所不同。
[testdir]# objdump -d hello.ohello.o: file format elf64-x86-64Disassembly of section .text:0000000000000000:0: 55 push %rbp1: 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,%eax13: 5d pop %rbp14: 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 %rbp40051e: 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,%eax400530: 5d pop %rbp400531: 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.outHex dump of section '.rodata':0x004005c0 01000200 00000000 00000000 00000000 ....0x004005d0 48656c6c 6f20576f 726c6400 Hello World.
你可以在右側看到文字 Hello World
,在左側可以看到其二進位制格式的地址。它是否與你在上面的 mov
指令中看到的地址匹配?是的,確實匹配。
該命令通常用於在將二進位制檔案交付給客戶之前減小二進位制檔案的大小。
請記住,由於重要資訊已從二進位制檔案中刪除,因此它會妨礙偵錯。但是,這個二進位制檔案可以完美地執行。
對 a.out
可執行檔案執行該命令,並注意會發生什麼。首先,通過執行以下命令確保二進位制檔案沒有被剝離(not stripped
):
[testdir]# file a.outa.out: ELF 64-bit LSB executable, x86-64, [......] not stripped
另外,在執行 strip
命令之前,請記下二進位制檔案中最初的位元組數:
[testdir]# du -b a.out8440 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
工具只是在二進位制檔案中查詢地址,並將其與 C 原始碼程式中的行進行匹配。很酷,不是嗎?
為此編寫另一個測試程式;只是這一次確保使用 gcc
的 -g
標誌進行編譯,這將為二進位制檔案新增其它偵錯資訊,並包含有助於偵錯的行號(由原始碼中提供):
[testdir]# cat -n atest.c1 #include <stdio.h>23 int globalvar = 100;45 int function1(void)6 {7 printf("Within function1\n");8 return 0;9 }1011 int function2(void)12 {13 printf("Within function2\n");14 return 0;15 }1617 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.outWithin function1Within function2Within main
現在使用 objdump
來標識函數開始的記憶體地址。你可以使用 grep
命令來過濾出所需的特定行。函數的地址在下面突出顯示(55 push %rbp
前的地址):
[testdir]# objdump -d a.out | grep -A 2 -E 'main>:|function1>:|function2>:'000000000040051d :40051d: 55 push %rbp40051e: 48 89 e5 mov %rsp,%rbp--0000000000400532 :400532: 55 push %rbp400533: 48 89 e5 mov %rsp,%rbp--0000000000400547:400547: 55 push %rbp400548: 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
的輸出也匹配。
使用上面的 C 程式測試 nm
工具。使用 gcc
快速編譯並執行它。
[testdir]# gcc atest.c[testdir]# ./a.outWithin function1Within function2Within main
現在執行 nm
和 grep
獲取有關函數和變數的資訊:
[testdir]# nm a.out | grep -Ei 'function|main|globalvar'000000000040051d T function10000000000400532 T function2000000000060102c D globalvarU __libc_start_main@@GLIBC_2.2.50000000000400547 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 工具為有興趣分析二進位制檔案的人提供了許多選項,這只是它們可以為你做的事情的冰山一角。請閱讀每種工具的手冊頁,以了解有關它們以及如何使用它們的更多資訊。