gnu binutils
想象一下,虽然无法访问软件的源代码,但仍然能够理解软件的实现方式,在其中找到漏洞,并且可以更好地修复错误。 所有这些都是二进制形式。 听起来像是拥有超级大国,不是吗?
您也可以拥有这样的超级能力,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(和BSD,使用Linux Binary Compatibility )上,这称为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 -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]#
在此阶段,无需创建目标文件即可将步骤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]#
汇编程序的目的是将汇编语言指令转换为机器语言代码,并生成扩展名为.o的目标文件。 使用GNU汇编程序,因为在所有Linux平台上默认都可用。
[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
当链接目标文件以创建可执行文件时,这是编译的最后阶段。 可执行文件通常需要外部函数,这些外部函数通常来自系统库( libc )。
您可以使用ld命令直接调用链接器; 但是,此命令有些复杂。 相反,您可以将gcc编译器与-v (详细)标志一起使用以了解链接的发生方式。 (使用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版本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(可重定位文件) :
[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(可执行文件) 。
[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源程序中主要功能的地址。
在其他已知的系统二进制文件上尝试使用readelf命令,例如ls 。 请注意,由于安全原因对位置无关可执行文件( PIE )进行了更改,因此在RHEL 8或Fedora 30及更高版本的系统上,您的输出(尤其是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(共享对象文件) ,这意味着它不能直接直接执行。 必须由内部使用库提供的任何功能的可执行文件使用它。
[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命令仅适用于目标文件和可执行文件,因此,如果您尝试在简单的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]# sizehello.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部分是什么意思?
文本部分引用二进制文件的代码部分,其中包含所有可执行指令。 数据段是所有初始化数据所在的位置, bss是所有未初始化数据存储的位置。
将大小与其他一些可用的系统二进制文件进行比较。
对于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
在字符串命令中添加-d标志以仅显示数据部分中的可打印字符通常很有用。
hello.o是一个目标文件,其中包含打印出Hello World文本的说明。 因此, 字符串命令的唯一输出是Hello World 。
[testdir]# strings -d hello.o
Hello World
另一方面,在a.out (可执行文件)上运行字符串会显示在链接阶段二进制文件中包含的其他信息:
[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架构编译相同的程序,说明将有所不同。
另一个可以从二进制文件中转出机器语言指令的binutils工具称为objdump 。
使用-d选项,该选项可从二进制文件中反汇编所有汇编指令。
[testdir]# objdump -dhello.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命令将其缩小到了主要功能:
[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相似,但是其中包含一些其他信息:
callq e
callq 400400 <puts@plt>
上面的汇编指令正在调用puts函数。 请记住,您在源代码中使用了printf函数。 编译器插入了对puts库函数的调用,以将 Hello World输出到屏幕。
看一下puts上面的一行的说明:
mov $0x0,%edi
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指令中看到的地址匹配? 是的,它确实。
此命令通常用于在将二进制文件运送给客户之前减小二进制文件的大小。
请记住,由于重要信息已从二进制文件中删除,因此它会妨碍调试过程。 但是,二进制文件可以完美执行。
在您的a.out可执行文件上运行它,并注意会发生什么。 首先,通过运行以下命令确保二进制文件未被剥离 :
[testdir]# filea.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工具只是在二进制文件中查找地址,并将其与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.out40051d
/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,其是用于开始功能1行其中起始括号({)。 匹配function2和main的输出。
使用上面的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 ,代表符号,而变量在D标记为D ,代表初始化数据部分的符号。
想象一下,在没有源代码的二进制文件上运行此命令有多大用处? 这使您可以窥视内部并了解使用了哪些函数和变量。 当然,除非二进制文件已被剥离,否则它们将不包含任何符号,因此nm命令不会很有用,如您在此处看到的:
[testdir]# strip a.out
[testdir]# nm a.out | grep -Ei 'function|main|globalvar'
nm: a.out: no symbols
GNU binutils工具为有兴趣分析二进制文件的任何人提供了许多选项,这只是它们为您可以做什么的一瞥。 阅读每种工具的手册页,以了解有关它们以及如何使用它们的更多信息。
gnu binutils