9 个必不可少的 GNU binutils 工具

二进制分析是计算机行业中最被低估的技能。
155 位读者喜欢这个。
Tools for the sysadmin

opensource.com

想象一下,如果您无法访问软件的源代码,但仍然能够理解软件是如何实现的,找到其中的漏洞,并且——更棒的是——修复错误。所有这些都以二进制形式存在。听起来像拥有超能力,不是吗?

您也可以拥有这样的超能力,而 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 命令。

软件开发 101

在开源世界中,我们许多人专注于源代码形式的软件;当软件的源代码很容易获得时,只需获取源代码的副本,打开您最喜欢的编辑器,喝杯咖啡,然后开始探索即可。

但是,在 CPU 上执行的不是源代码;而是在 CPU 上执行的二进制或机器语言指令。二进制文件或可执行文件是您编译源代码时得到的文件。精通调试的人通常通过理解这种差异来获得优势。

编译 101

在深入研究 binutils 包本身之前,最好先了解编译的基础知识。

编译是将程序从某种编程语言 (C/C++) 的源代码或文本形式转换为机器代码的过程。

机器代码是 CPU(或一般硬件)可以理解的 1 和 0 的序列,因此可以由 CPU 执行或运行。此机器代码以特定格式保存到文件中,该格式通常称为可执行文件或二进制文件。在 Linux(和 BSD,当使用 Linux 二进制兼容性时),这称为 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 汇编程序 as


[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 文件的信息

上面的练习提到了术语 目标文件可执行文件。使用该练习中的文件,输入 readelf 并使用 -h(标头)选项将文件的 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可执行位,否则您将收到 Permission denied 错误。


[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 命令提供了有关二进制文件的丰富信息。在这里,它告诉您它是 ELF64 位格式,这意味着它只能在 64 位 CPU 上执行,而不能在 32 位 CPU 上工作。它还告诉您,它旨在在 X86-64(Intel/AMD)架构上执行。二进制文件的入口点位于地址 0x400430,这只是 C 源代码程序中 main 函数的地址。

在您知道的其他系统二进制文件(如 ls)上尝试 readelf 命令。请注意,由于出于安全原因而进行的与位置无关的可执行文件 (PIE) 更改,您的输出(尤其是 Type:)在 RHEL 8 或 Fedora 30 及更高版本的系统上可能会有所不同。


[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)

了解 ls 命令依赖于哪些 系统库,如下所示使用 ldd 命令


[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 文件上运行它,它将抛出一个错误,提示 File format not recognized


[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

但是 textdatabss 节是什么意思?

text 节指的是二进制文件的代码节,其中包含所有可执行指令。data 节是所有已初始化数据所在的位置,而 bss 是所有未初始化数据存储的位置。

size 与其他一些可用的系统二进制文件进行比较。

对于 ls 命令


[testdir]# size /bin/ls
text data bss dec hex filename
103119 4768 3360 111247 1b28f /bin/ls

您可以通过查看 size 命令的输出来看到 gccgdb 是比 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:打印文件中可打印字符的字符串

通常,将 -d 标志添加到 strings 命令以仅显示来自数据节的可打印字符非常有用。

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$"

回想一下,编译是将源代码指令转换为机器代码的过程。机器代码仅由 1 和 0 组成,人类难以阅读。因此,将机器代码表示为汇编语言指令很有帮助。汇编语言是什么样的?请记住,汇编语言是特定于架构的;由于我使用的是 Intel 或 x86-64 架构,因此如果您使用 ARM 架构来编译相同的程序,指令将有所不同。

objdump:显示来自目标文件的信息

另一个可以从二进制文件中转储机器语言指令的 binutils 工具称为 objdump
使用 -d 选项,该选项会反汇编二进制文件中的所有汇编指令。


[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 节包含所有机器代码指令。汇编指令可以在第四列中看到(即,pushmovcallqpopretq)。这些指令作用于寄存器,寄存器是内置于 CPU 中的内存位置。此示例中的寄存器是 rbprspedieax 等,每个寄存器都有特殊的含义。

现在在可执行文件 (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 输出到屏幕。

查看 puts 上方行的指令

  • 目标文件 hello.o 具有指令 movmov $0x0,%edi
  • 可执行文件 a.out 的指令 mov 具有实际地址 ($0x4005d0) 而不是 $0x0mov $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 可执行文件上运行它,并注意会发生什么。首先,通过运行以下命令确保二进制文件 未被剥离


[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 命令来过滤掉您想要的特定行。您的函数的地址在下面突出显示


[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 的起始大括号 ({) 开始的行。匹配 function2main 的输出。

nm:列出目标文件中的符号

使用上面的 C 程序来测试 nm 工具。使用 gcc 快速编译并执行它。


[testdir]# gcc atest.c
[testdir]# ./a.out
Within function1
Within function2
Within main

现在运行 nmgrep 以获取有关您的函数和变量的信息


[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 工具为任何有兴趣分析二进制文件的人提供了许多选项,而这仅仅是它们可以为您做的事情的惊鸿一瞥。阅读每个工具的 man 手册以了解有关它们的更多信息以及如何使用它们。

标签
User profile image.
经验丰富的软件工程专业人士。主要兴趣是安全、Linux、恶意软件。喜欢在命令行上工作。对底层软件和了解事物的工作原理感兴趣。此处表达的观点仅代表我个人,不代表我的雇主

2 条评论

这是一篇非常有帮助的文章。感谢您的撰写。

这对我是非常有帮助的文章。我向读者推荐这篇文章。
谢谢 Gaurav

Creative Commons License本作品根据知识共享署名-相同方式共享 4.0 国际许可协议获得许可。
© . All rights reserved.