GNU binutils 裡的九種武器

GNU binutils 裡的九種武器

二進制分析是計算機行業中最被低估的技能。

-- Gaurav Kamathe(作者)

想象一下,在無法訪問軟件的源代碼時,但仍然能夠理解軟件的實現方式,在其中找到漏洞,並且更厲害的是還能修復錯誤。所有這些都是在只有二進制文件時做到的。這聽起來就像是超能力,對吧?

你也可以擁有這樣的超能力,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 ( 可執行和可鏈接格式(Executable and Linkable Format))。

在生成給定的源文件的可執行文件或二進制文件之前,編譯過程將經歷一系列複雜的步驟。以這個源程序(C 代碼)為例。打開你喜歡的編輯器,然後鍵入以下程序:

#include <stdio.h>
int main(void)
{
printf("Hello World\\n");
return 0;
}
/<stdio.h>

步驟 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]#
/<stdio.h>

步驟 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 格式的文件;但是,還不能執行它。稍後,你將看到“ 目標文件(object file)”和“ 可執行文件(executable file)”之間的區別。

[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 及更高版本的系統上,由於安全原因改用了 位置無關可執行文件(position independent executable)( 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 譯註:一般來說,在靜態的映像文件中,各個部分稱之為 節(section),而在運行時的各個部分稱之為 段(segment),有時統稱為段。)

比較其他一些可用的系統二進制文件的 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> 上面的彙編指令正在調用 puts 函數。請記住,你在源代碼中使用了一個 printf 函數。編譯器插入了對 puts 庫函數的調用,以將 Hello World 輸出到屏幕。/<puts>

查看 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 }
/<stdio.h>

用 -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

本文由 LCTT 原創編譯, Linux中國 榮譽推出

"


分享到:


相關文章: