在本文中,你将学习在Linux操作系统上进行分析所需的基本工具。
除了向你简单展示工具列表,并解释它们的功能以外,我还将使用“夺旗”(Capture The Flag,CTF)挑战来说明它们的工作原理。在计算机安全与黑客攻防领域,CTF挑战经常以竞赛形式进行,目标是分析并利用指定的二进制文件,或者正在运行的进程/服务器,直至拿到隐藏在二进制文件中的“flag”为止。flag一般是十六进制的字符串,你可以用它来证明你已经完成了挑战,并解锁新挑战。
本次CTF里面,我们从一个名为payload的神秘文件开始分析,你可以在虚拟机的本章目录中找到该文件。我们的挑战目标是,找出隐藏在payload中的flag。在分析payload、查找flag的过程中,你需要学习使用各种二进制分析工具,这些工具几乎可以在任何Linux操作系统上使用,大多数工具通常作为GNU coreutils或binutils的一部分。
这里看到的大多数工具都有许多有用的选项,但是由于本章需要介绍的工具实在太多,因此,最好的方法是在虚拟机上使用man命令查询每个工具的手册页。在本章的最后,你需要用flag来解锁新挑战,我相信你可以独立完成该挑战!
5.1 使用file解决类型问题在分析二进制文件的时候,因为没有关于payload内容的提示,所以无从下手。例如,在进行逆向工程,或者取证的时候发生这种情况,第一步就是要弄清楚文件类型及其内容。file工具应运而生,它可以接收多个文件,然后返回文件类型。
使用file的好处是,它不受文件扩展名的影响,相反,它是通过搜索文件中的其他指示模式来识别文件类型的,如ELF二进制文件开头的0x7f序列的幻数字节。这是完美的选择,因为payload文件没有扩展名。以下是file返回的有关payload的消息。
$ file payloadpayload: ASCII text
如你所见,payload包含ASCII文本。为了详细检查文本,你可以使用head工具,head会将文本内容的前几行(默认是前10行)显示到stdout中。
$ head payloadH4sIAKiT61gAA+xaD3RTVZq/Sf9TSKL8aflnn56ioNJJSiktDpqUlL5o0UpbYEVI0zRtI2naSV5KYV0HTig21jqojH9mnRV35syZPWd35ZzZ00XHxWBHYJydXf4ckRldZRUxBRzxz2CFQvb77ru3ee81AZdZZ92z+XrS733fu993v/v/vnt/bqmVfNNkBlq0cCFyy6KFZiUHKi1buMhMLAvMi0oXWSzlZYtAv2hRWRkRzN94ZEChoOQKCAJp8fdcNt2V3v8fpe9X1y7T63Rjsp7cTlCKGq1UtjL9yPUJGyupIHnw/zoym2SDnKVIZyVWFR9hrjnPZeky4JcJvwq9LFforSo+i6XjXKfgWaoSWFX8mclExQkRxuww1uOzZe3x2U0qfpDFcUyvttMzuxFmN8LSc054er26fJns18D0DaxcnNtZOrsiPVLdh1ILPudey/xda1XxMpauTGN3L9hlk69PJsZXsPxS1YvA4uect8N3fN7m8rLv+Frm+7z+UM/8nory+eVlJcHOklIak4mlrbm7kabn9SiwmKcQuQ/g+3n/OJj/byfuqjv09uKVj8889O6TvxXM+G4qSbRbX1TQCZnWPNQVwG86/F7+4IkHl1a/eebY91bPemngU8OpI58YNjrWD16u3P3wuzaJ3kh4i6vpuhT6g7rkfs6k0DtS6P8lhf6NFPocfXL9yRTpS0ny+NtJ8vR3p0hfl8J/bgr9Vyn0b6bQkxTl+ixF+p+m0N+qx743k+wWmlT6
上述内容看起来让人难以理解,但仔细看,你会发现它只包含字母、数字以及+、/等字符,并且整齐地按行排列。当你看到一个像这样的文件的时候,通常可以确认这是一个Base64文件。
Base64是一种广泛使用的、将二进制数据编码为ASCII文本的方法。除了正常编码,Base64还常用于电子邮件和网页编码,以确保网络传输的二进制数据不会因为只能处理文本服务而意外变形。Linux操作系统自带了base64的小工具(通常作为GNU coreutils的一部分),这个工具可以对Base64进行编码和解码。默认情况下,base64会对提供的标准输入或者文件进行编码,但你也可以使用-d标志进行解码操作。我们现在来解码payload,看看会有什么结果。
$ base64 -d payload > decoded_payload
使用上述命令对payload进行解码,然后将解码的内容保存在一个名为decoded_ payload的新文件中。现在,你已经有了payload的解码内容,我们再次用file来检查解码后的文件类型。
$ file decoded_payloaddecoded_payload: gzip compressed data, last modified: Tue Oct 22 15:46:43 2019, from Unix
原来Base64编码后面的神秘文件,实际上是一个压缩文件,并使用gzip作为外部的压缩。这里将介绍file的另一个功能:查看压缩文件。将-z选项传递给file,查看压缩文件的内容而无须进行提取文件的操作,如下所示:
$ file -z decoded_payloaddecoded_payload: POSIX tar archive (GNU) (gzip compressed data, last modified: Tue Oct 22 19:08:12 2019, from Unix)
可以看到压缩文件里面还有一个压缩文件,外面用gzip压缩,里面用tar压缩(通常在里面包含文件)。为了显示存储在里面的文件,你可以使用tar解压缩提取decoded_payload里面的内容,如下所示:
$ tar xvzf decoded_payloadctf67b8601
如tar日志所示,从压缩文件中提取了两个文件:ctf和67b8601。我们再用file来看看这两个文件的类型。
$ file ctfctf: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked,interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32,BuildID[sha1]=29aeb60bcee44b50d1db3a56911bd1de93cd2030, stripped
第一个文件ctf,是一个动态链接的、64位的、剥离的ELF二进制文件。第二个文件67b8601,是一个512像素×512像素的位图(BitMap,BMP)文件。同样,你可以使用file查看此消息。
$ file 67b860167b8601: PC bitmap, Windows 3.x format, 512 x 512 x 24
如图5-1(a)所示,这个BMP文件中画的是一个黑色的正方形。如果仔细看,在图片底部会发现一些不规则的颜色像素,图5-1(b)显示了这些像素的放大片段。
在研究之前,我们先看看ctf,这个刚刚提取的ELF二进制文件。
(a)完整的图片
(b)底部某些颜色像素的放大图片
《二进制分析实战》图5-1 提取的BMP文件67b8601
5.2 使用ldd探索依赖性直接运行未知的二进制文件不是明智之举,但因为是在虚拟机中操作,所以直接运行ctf应该不会有什么大问题。
$ ./ctf./ctf: error while loading shared libraries: lib5ae9b7f.so: cannot open shared object file: No such file or directory
在执行程序代码之前,动态链接器会提示缺少一个名为lib5ae9b7f.so的库文件。这个库文件听起来不像是在系统上可以找到的库文件,那么在搜索这个库文件之前,很有必要检查一下ctf是否有更多未解析的依赖项。
Linux操作系统自带一个名为ldd的程序,你可以使用该程序找出文件依赖哪些共享库和依赖关系。你可以将ldd与-v选项一起使用,找出二进制文件期望的库文件版本,这对调试来说很有用。正如ldd手册页中描述的那样,ldd可能会通过运行二进制文件来找出依赖关系,所以除非你是在虚拟机或者其他隔离环境下运行,否则在不信任的二进制文件上使用ldd是不安全的。以下是ctf二进制文件的ldd输出:
$ ldd ctf linux-vdso.so.1 => (0x00007fff6edd4000) lib5ae9b7f.so => not found libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f67c2cbe000) libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f67c2aa7000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f67c26de000) libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f67c23d5000) /lib64/ld-linux-x86-64.so.2 (0x0000561e62fe5000)
幸运的是,除了刚刚发现的lib5ae9b7f.so库文件外,并不存在其他未解析的依赖项。现在我们可以集中精力来弄清楚这个神秘的库文件到底是什么了,以及通过它来拿到flag。
因为从库的名称可以明显知道,如果它不在任何标准的存储库中,它肯定就在附近某个位置。回想第2章的内容,所有的ELF二进制文件和库文件都以幻数序列0x7f开头,通过这个字符串查找缺少的库文件相当方便。只要库文件未被加密,你就可以通过这种方式找到ELF头部。我们可以用grep简单地搜索字符串“ELF”,如下所示:
$ grep 'ELF' *Binary file 67b8601 matchesBinary file ctf matches
正如我们的预期,字符串“ELF”出现在ctf中。这并不奇怪,因为你已经知道它就是一个ELF二进制文件。奇怪的是,你也可以在67b8601文件中发现该字符串,虽然乍一看该文件是一个无害的BMP文件,但共享库是否可以隐藏在BMP文件的像素数据中呢?这个问题的答案肯定可以向你解释为什么会看到图5-1(b)中所示的那些颜色奇怪的像素。我们现在来检查67b8601文件的内容,找出答案。
在将原始字节解释为ASCII码的时候,通常需要ASCII表,将字节值映射为ASCII符号。你可以使用名为man ascii的手册页来快速浏览该表,以下是该表的摘要:
Oct Dec Hex Char Oct Dec Hex Char
000 0 00 NUL '\0' (null character) 100 64 40 @001 1 01 SOH (start of heading) 101 65 41 A002 2 02 STX (start of text) 102 66 42 B003 3 03 ETX (end of text) 103 67 43 C004 4 04 EOT (end of transmission) 104 68 44 D005 5 05 ENQ (enquiry) 105 69 45 E006 6 06 ACK (acknowledge) 106 70 46 F007 7 07 BEL '\a' (bell) 107 71 47 G...
从上面我们可以看到,这个表可以轻松地查找映射关系,把八进制、十进制和十六进制编码到ASCII字符,比在浏览器里搜索ASCII值要快得多。
为了在不依赖任何标准的前提下,发现文件的内容,这里我们必须进行字节级别的分析。为此,我们需要在系统屏幕上显示位和字节内容。你可以使用二进制,显示出所有的1和0用于分析,但是因为这样做需要大量无用的运算,所以最好使用十六进制。在十六进制中,数字的范围是0~9,然后是a~f,其中a代表10,f代表15。另外,因为一字节有256种可能(28),所以正好适合表示两个十六进制值(16×16),十六进制编码可以简洁、方便地显示字节内容。
这里我们使用十六进制转储程序显示二进制文件的字节内容,该程序可以编辑文件中的字节内容。在第7章中,我会再次谈到十六进制编辑的内容,但现在,我们使用一款简单的十六进制转储工具xxd,这款工具默认安装在绝大多数Linux操作系统上。
以下内容是用xxd分析BMP文件的前15行得到的输出:
$ xxd 67b8601 | head -n 1500000000: 424d 3800 0c00 0000 0000 3600 0000 2800 BM8.......6...(.00000010: 0000 0002 0000 0002 0000 0100 1800 0000 ................00000020: 0000 0200 0c00 c01e 0000 c01e 0000 0000 ................00000030: 0000 0000 ?7f45 4c46 0201 0100 0000 0000 .....ELF........00000040: 0000 0000 0300 3e00 0100 0000 7009 0000 ......>.....p...00000050: 0000 0000 4000 0000 0000 0000 7821 0000 ....@.......x!..00000060: 0000 0000 0000 0000 4000 3800 0700 4000 ........@.8...@.00000070: 1b00 1a00 0100 0000 0500 0000 0000 0000 ................00000080: 0000 0000 0000 0000 0000 0000 0000 0000 ................00000090: 0000 0000 f40e 0000 0000 0000 f40e 0000 ................000000a0: 0000 0000 0000 2000 0000 0000 0100 0000 ...... .........000000b0: 0600 0000 f01d 0000 0000 0000 f01d 2000 .............. .000000c0: 0000 0000 f01d 2000 0000 0000 6802 0000 ...... .....h...000000d0: 0000 0000 7002 0000 0000 0000 0000 2000 ....p......... .000000e0: 0000 0000 0200 0000 0600 0000 081e 0000 ................
第一列输出的是以十六进制格式显示的文件偏移,接下来的8列显示的是文件中字节以十六进制表示的形式,在输出的最右侧,你可以看到相同字节对应的ASCII表示形式。
我们可以使用xxd工具的-c选项修改每行显示的字节数,如xxd-c 32会将每行显示为32字节。你还可以使用-b选项显示二进制文件而不是十六进制文件,并使用-i选项输出包含字节的C风格数组。你可以直接将其包含在C或者C++源代码中。为了只输出某些字节,我们可以使用-s(搜索)选项指定起始的文件偏移量,使用-l(长度)选项指定要转储的字节数。
在BMP文件的xxd输出中,ELF幻数字节出现在偏移量0x34处?,对应十进制的52。虽然输出告诉你了可疑的ELF库文件的起始位置,但要找出ELF库文件的结尾并不容易。因为没有幻数字节来界定ELF库文件的结尾,所以在提取整个ELF库文件之前,先把ELF头部提取出来,再通过检查ELF头部来找出整个ELF库文件的大小。
为了提取ELF头部,需要使用dd将BMP文件从偏移52开始,复制64字节到新的输出文件elf_header。
$ dd skip=52 count=64 if=67b8601 of=elf_header bs=164+0 records in64+0 records out64 bytes copied, 0.000404841 s, 158 kB/s
使用dd是一个意外,这里不进行过多解释。但dd确实是一款功能强大[1]的工具,所以如果你还不熟悉怎么使用它,可以阅读dd的官方手册页。
再用xxd查看elf_header的内容。
$ xxd elf_header00000000: ?7f45 4c46 0201 0100 0000 0000 0000 0000 .ELF............00000010: 0300 3e00 0100 0000 7009 0000 0000 0000 ..>.....p.......00000020: 4000 0000 0000 0000 7821 0000 0000 0000 @.......x!......00000030: 0000 0000 4000 3800 0700 4000 1b00 1a00 ....@.8...@.....
这看起来很像是ELF头部:你可以在起始位置清楚地看到幻数字节,并且还可以看到e_ident数组,其他字段看起来也很合理(有关这些字段的说明,请参阅第2章)。
5.4 使用readelf解析并提取ELF库文件为了查看ELF头部(elf_header)的详细信息,最好使用第2章介绍的readelf,因为readelf可以在损坏的ELF库文件中正常工作,如清单5-1所示。
清单5-1 使用readelf读取elf_header详细信息
? $ readelf -h elf_header 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: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x970 Start of program headers: 64 (bytes into file)? Start of section headers: 8568 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 7? Size of section headers: 64 (bytes)? Number of section headers: 27 Section header string table index: 26 readelf: Error: Reading 0x6c0 bytes extends past end of file for section headers readelf: Error: Reading 0x188 bytes extends past end of file for program headers
-h选项?告诉readelf只输出ELF头部,它仍然会告诉你节头表和程序头表指向文件外部,但没关系,重要的是现在你可以方便地表示elf_header。
现在要如何通过elf_header来确定整个ELF库文件的大小?在第2章的图2-1中,你知道ELF二进制文件的最后一部分是节头表,而节头表的偏移在elf_header中指定了?,elf_header头还告诉我们表中每个节头的大小?,以及节头的数量?。这意味着你可以通过以下公式计算隐藏在BMP文件中的完整ELF库文件的大小。
在这个公式中,size是整个库文件的大小,e_shoff是节头表的偏移,e_shnum是表中节头的数量,e_shentsize是每个节头的大小。
现在知道了库文件的大小应该是10 296字节,就可以使用dd完整地提取库文件了,如下所示。
$ dd skip=52 count=10296 if=67b8601 ?of=lib5ae9b7f.so bs=110296+0 records in10296+0 records out10296 bytes (10 kB, 10 KiB) copied, 0.0287996 s, 358 kB/s
由于lib5ae9b7f.so文件是ctf二进制文件缺少的库文件,dd会调用提取该文件。运行上述命令后,你将拥有一个功能齐全的ELF共享库。我们使用readelf查看该文件是否正常,如清单5-2所示。为了简化输出结果,我们只输出ELF头部(-h)和符号表(-s),后者让你对库文件提供的功能有所了解。
清单5-2 使用readelf读取lib5ae9b7f.so库文件的输出
$ readelf -hs lib5ae9b7f.so 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: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x970 Start of program headers: 64 (bytes into file) Start of section headers: 8568 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 7 Size of section headers: 64 (bytes) Number of section headers: 27 Section header string table index: 26 Symbol table '.dynsym' contains 22 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 00000000000008c0 0 SECTION LOCAL DEFAULT 9 2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__ 3: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _Jv_RegisterClasses 4: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _ZNSt7__cxx1112basic_stri@GL(2) 5: 0000000000000000 0 FUNC GLOBAL DEFAULT UND malloc@GLIBC_2.2.5 (3) 6: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_deregisterTMCloneTab 7: 0000000000000000 0 NOTYPE WEAK DEFAULT UND _ITM_registerTMCloneTable 8: 0000000000000000 0 FUNC WEAK DEFAULT UND __cxa_finalize@GLIBC_2.2.5 (3) 9: 0000000000000000 0 FUNC GLOBAL DEFAULT UND __stack_chk_fail@GLIBC_2.4 (4) 10: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _ZSt19__throw_logic_error@ (5) 11: 0000000000000000 0 FUNC GLOBAL DEFAULT UND memcpy@GLIBC_2.14 (6)? 12: 0000000000000bc0 149 FUNC GLOBAL DEFAULT 12 _Z11rc4_encryptP11rc4_sta? 13: 0000000000000cb0 112 FUNC GLOBAL DEFAULT 12 _Z8rc4_initP11rc4_state_t 14: 0000000000202060 0 NOTYPE GLOBAL DEFAULT 24 _end 15: 0000000000202058 0 NOTYPE GLOBAL DEFAULT 23 _edata? 16: 0000000000000b40 119 FUNC GLOBAL DEFAULT 12 _Z11rc4_encryptP11rc4_sta? 17: 0000000000000c60 5 FUNC GLOBAL DEFAULT 12 _Z11rc4_decryptP11rc4_sta 18: 0000000000202058 0 NOTYPE GLOBAL DEFAULT 24 __bss_start 19: 00000000000008c0 0 FUNC GLOBAL DEFAULT 9 _init? 20: 0000000000000c70 59 FUNC GLOBAL DEFAULT 12 _Z11rc4_decryptP11rc4_sta 21: 0000000000000d20 0 FUNC GLOBAL DEFAULT 13 _fini
正如我们所期望的那样,整个库文件被完整地提取出来了,尽管符号被剥离,但动态符号表确实显示了一些有趣的导出函数(从?到?)。然而,有些名称看起来“乱七八糟”,难以阅读,让我们看看能否对其进行修复。
5.5 使用nm解析符号C++运行函数重载,意味着可以存在多个具有相同名称的函数,只要它们有不同的签名即可。不幸的是,对链接程序来说,它对C++一无所知。如果有多个名称为foo的函数,链接器将不知道如何解析对foo的引用,也不知道要使用哪个版本的foo。为了消除重复的名称,C++编译器提出了符号修饰(mangled name)。符号修饰实质上是原始函数名称与函数参数编码的组合。这样,函数的每个版本都会获得唯一的名称,并且链接器也不会对重载的函数产生歧义。
对二进制分析员来说,符号修饰带来的是一种喜忧参半的感觉。一方面,正如在readelf对lib5ae9b7f.so的读取输出(见清单5-2)中看到的那样,这些符号修饰很难阅读;另一方面,符号修饰实质上是通过泄露函数的预期参数来提供自由的类型信息的,该信息在对二进制文件进行逆向工程时会很有用。
幸运的是,符号修饰的优点多于自身的缺点,因为符号修饰相对容易还原,我们可以使用几款工具来解析修饰过的名称。nm是最出名的工具之一,它可以列出二进制文件、对象文件或者共享库中的符号,在指定二进制文件的时候,nm默认会尝试解析静态符号表。
$ nm lib5ae9b7f.sonm: lib5ae9b7f.so: no symbols
但遗憾的是,如本例所示,你没有办法在lib5ae9b7f.so上使用nm的默认配置,因为文件已经被剥离了。此时你需要使用-D选项要求nm解析动态符号表,如清单5-3 所示。在清单中“…”表示已经截断了一行,并在下一行继续(符号修饰可能很长)。
清单5-3 nm对lib5ae9b7f.so的输出
$ nm -D lib5ae9b7f.so w _ITM_deregisterTMCloneTable w _ITM_registerTMCloneTable w _Jv_RegisterClasses0000000000000c60 T _Z11rc4_decryptP11rc4_state_tPhi0000000000000c70 T _Z11rc4_decryptP11rc4_state_tRNSt7__cxx1112basic_... ...stringIcSt11char_traitsIcESaIcEEE0000000000000b40 T _Z11rc4_encryptP11rc4_state_tPhi0000000000000bc0 T _Z11rc4_encryptP11rc4_state_tRNSt7__cxx1112basic_... ...stringIcSt11char_traitsIcESaIcEEE0000000000000cb0 T _Z8rc4_initP11rc4_state_tPhi U _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE9_... ...M_createERmm U _ZSt19__throw_logic_errorPKc0000000000202058 B __bss_start w __cxa_finalize w __gmon_start__ U __stack_chk_fail0000000000202058 D _edata0000000000202060 B _end0000000000000d20 T _fini00000000000008c0 T _init U malloc U memcpy
这次看起来好一点,能看到一些符号,但是符号名称仍然被修饰了。为了对其进行解析,我们要将--demangle选项传递给nm,如清单5-4所示。
清单5-4 使用nm对lib5ae9b7f.so进行符号解析
$ nm -D --demangle lib5ae9b7f.so w _ITM_deregisterTMCloneTable w _ITM_registerTMCloneTable w _Jv_RegisterClasses0000000000000c60 T ? rc4_decrypt(rc4_state_t * , unsigned char * , int)0000000000000c70 T ? rc4_decrypt(rc4_state_t * , std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)0000000000000b40 T ? rc4_encrypt(rc4_state_t * , unsigned char * , int)0000000000000bc0 T ? rc4_encrypt(rc4_state_t * , std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)0000000000000cb0 T ? rc4_init(rc4_state_t * , unsigned char * , int) U std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::_M_create(unsigned long&, unsigned long) U std::__throw_logic_error(char const * )0000000000202058 B __bss_start w __cxa_finalize w __gmon_start__ U __stack_chk_fail0000000000202058 D _edata0000000000202060 B _end0000000000000d20 T _fini00000000000008c0 T _init U malloc U memcpy
终于,函数名称变得易于阅读了。在清单5-4中你会看到5个有意思的函数,这些函数似乎是实现已知的RC4加密算法[2]的加密函数。这里有一个名为rc4_init的函数,该函数将rc4_state_t类型的数据结构、无符号字符串以及整数?作为输入参数。第一个参数大概是保存密码状态的数据结构,后面两个参数可能分别表示密钥的字符串,以及指定密钥长度的整数。上面还有几个加密和解密函数,每个函数都有一个指向加密状态的指针,以及指定用于加密或解密(?到?)的字符串参数(C和C++字符串)。
符号修饰名称还有一种方法,即使用c++filt工具。这个工具会将修饰的名称作为输入,然后输出解析后的名称。c++filt的优点是它支持多种修饰格式,可以自动检测并修正指定输入的修饰格式。以下是c++filt解析函数名称_Z8rc4_initP11rc4_state_tPhi的示例:
$ c++filt _Z8rc4_initP11rc4_state_tPhirc4_init(rc4_state_t * , unsigned char * , int)
现在我们来回顾一下到目前为止的进展:我们提取了神秘的payload,找到了一个名为ctf的二进制文件,该文件依赖lib5ae9b7f.so的库文件,然后你发现lib5ae9b7f.so隐藏在BMP文件里面,并且成功将其提取出来。与此同时,你还对该文件的功能有了大概的了解:这是一个加密库文件。现在我们再次运行ctf,这次没有提示丢失依赖项。
$ export LD_LIBRARY_PATH=`pwd`$ ./ctf$ echo $?1
运行成功!虽然运行后没有报错,但似乎没有提示任何功能,$?变量中包含的ctf退出状态为1,表示有错误。现在有了依赖文件,你可以继续研究,看看能否跳过错误提取到flag。
5.6 使用strings查看Hints为了弄清楚二进制文件的功能,以及程序期望的输入类型,我们可以检查二进制文件是否包含有用的字符串,进而通过字符串揭露其用途。例如,当你看到字符串包含HTTP请求或者URL的时候,你会猜测该二进制文件正在执行与网络相关的操作;当你分析“僵尸网络”等恶意软件的时候,如果没有代码混淆,你将有可能找出包含后门接收命令的字符串,甚至会发现程序员在调试时留下的、忘记删除的字符串——这的确在现实的恶意软件中出现过。
我们可以使用strings来查看二进制文件中的字符串,包括Linux操作系统上的任何其他文件。strings将单个或者多个文件作为输入参数,然后输出这些文件中找到的所有可输出字符串。要注意的是,strings不会检查找到的字符串是否真的是人类可读的,因此应用到二进制文件上的时候,由于某些二进制序列恰好可输出,导致输出的时候包含了一些假的字符串。
当然我们可以使用选项来调整输出字符串的行为,如strings与-d选项一起使用,只输出在二进制文件的数据节中发现的字符串。默认情况下,strings只输出4个或者4个字符以上的字符串,但是你可以使用-n选项指定最小字符串长度。目前来说,我们用默认选项就可以了。先来看看使用strings在ctf二进制文件中可以找到什么,如清单5-5所示。
清单5-5 找到ctf二进制文件中的字符串
$ strings ctf? /lib64/ld-linux-x86-64.so.2 lib5ae9b7f.so ? __gmon_start__ _Jv_RegisterClasses _ITM_deregisterTMCloneTable _ITM_registerTMCloneTable _Z8rc4_initP11rc4_state_tPhi ...?电脑 DEBUG: argv[1] = %s? checking '%s'? show_me_the_flag >CMb -v@P?: flag = %s guess again!? It's kinda like Louisiana. Or Dagobah. Dagobah - Where Yoda lives! ; * 3$" zPLR GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609? .shstrtab .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt.got .text .fini .rodata .eh_frame_hdr .eh_frame .gcc_except_table .init_array .fini_array .jcr .dynamic .got.plt .data .bss .comment
在清单 5-5 中你可以看到在大多数 ELF 二进制文件中都会遇到的字符串,如在.interp节中找到了程序解释器的名称?,在.dynstr节?找到一些符号名称。在strings输出的末尾,你可以在.shstrtab节?找到所有节的名称,但这些字符串对我们的分析来说似乎没多大用处。
幸运的是,上面还有一些更有用的字符串。如有一条调试消息,表明该程序需要提供命令行选项?。还有一些格式化检查,大概是在输入字符串?上执行的。虽然你暂时还不知道命令行参数应该是什么,但可以试着使用一些看起来有用的字符串,如show_me_the_flag?,以及“神秘”字符串?,里面包含一条目的不详的消息。虽然到目前为止还不知道消息的意思,但是通过对lib5ae9b7f.so的调查,我们知道该二进制文件用到了RC4加密操作,也许该消息是加密的密钥。
电脑既然已经知道二进制文件需要命令行参数,那么我们试着添加任意参数,看看能否让你找到flag。简单起见,这里我们采用字符串foobar,如下所示。
$ ./ctf foobarchecking 'foobar'$ echo $?1
二进制文件在做一些新的事情,它告诉你正在检查你输入的字符串,但检查失败,因为二进制文件在检查后仍然会退出并显示错误代码。我们来赌一把,输入发现的其他字符串,如show_me_the_flag。
$ ./ctf show_me_the_flagchecking 'show_me_the_flag'ok$ echo $?1
提示检查成功,但是退出状态仍然为1,所以这里肯定缺少了某些东西。更糟糕的是,字符串结果不再提供任何提示了。现在我们只能从ctf的系统调用、库文件调用开始,更加详细地研究ctf的行为,进一步确定下一步应该要做什么。
5.7 使用strace和ltrace跟踪系统调用和库文件调用为了取得进展,我们通过查看ctf退出前的行为来调查exit显示错误代码的原因。这里有多种方法,其中一种方法是使用名为strace和ltrace的两个工具。这两个工具分别显示二进制文件执行时的系统调用和库文件调用。知道了二进制文件有哪些系统调用和库文件调用以后,会让你对程序有更深入的了解。
首先我们使用strace跟踪ctf的系统调用行为。在某些情况下,你可能希望将strace附加到正在运行的进程中,为此你需要使用-p pid选项,其中pid是你要附加的进程ID。但是在这个示例里面,用strace运行ctf就足够了。清单5-6显示了ctf二进制文件的strace输出,某些内容被“…”截断。
清单5-6 ctf二进制文件的strace输出
$ strace ./ctf show_me_the_flag? execve("./ctf", ["./ctf", "show_me_the_flag"], 电脑 [/* 73 vars */]) = 0 brk(NULL) = 0x1053000 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f703477e000 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)? open("/ch3/tls/x86_64/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or ...) stat("/ch3/tls/x86_64", 0x7ffcc6987ab0) = -1 ENOENT (No such file or directory) open("/ch3/tls/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) stat("/ch3/tls", 0x7ffcc6987ab0) = -1 ENOENT (No such file or directory) open("/ch3/x86_64/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) stat("/ch3/x86_64", 0x7ffcc6987ab0) = -1 ENOENT (No such file or directory) open("/ch3/lib5ae9b7f.so", O_RDONLY|O_CLOEXEC) = 3? read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p\t\0\0\0\0\0\0"..., 832) = 832 fstat(3, 电脑 st_mode=S_IFREG|0775, st_size=10296, ...) = 0 mmap(NULL, 2105440, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7034358000 mprotect(0x7f7034359000, 2097152, PROT_NONE) = 0 mmap(0x7f7034559000, 8192, PROT_READ|PROT_WRITE, ..., 3, 0x1000) = 0x7f7034559000 close(3) = 0 open("/ch3/libstdc++.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3 fstat(3, st_mode=S_IFREG|0644, st_size=150611, ...) = 0 mmap(NULL, 150611, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f7034759000 close(3) = 0 access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)? open("/usr/lib/x86_64-linux-gnu/libstdc++.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0 \235\10\0\0\0\0\0"..., 832) = 832 fstat(3, st_mode=S_IFREG|0644, st_size=1566440, ...) = 0 mmap(NULL, 3675136, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7033fd6000 mprotect(0x7f7034148000, 2097152, PROT_NONE) = 0 mmap(0x7f7034348000, 49152, PROT_READ|PROT_WRITE, ..., 3, 0x172000) = 0x7f7034348000 mmap(0x7f7034354000, 13312, PROT_READ|PROT_WRITE, ..., -1, 0) = 0x7f7034354000 close(3) = 0 open("/ch3/libgcc_s.so.1", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) open("/lib/x86_64-linux-gnu/libgcc_s.so.1", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\0\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0p * \0\0\0\0\0\0"..., 832) = 832 fstat(3, st_mode=S_IFREG|0644, st_size=89696, ...) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034758000 mmap(NULL, 2185488, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f7033dc0000 mprotect(0x7f7033dd6000, 2093056, PROT_NONE) = 0 mmap(0x7f7033fd5000, 4096, PROT_READ|PROT_WRITE, ..., 3, 0x15000) = 0x7f7033fd5000 close(3) = 0 open("/ch3/libc.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) open("/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0P\t\2\0\0\0\0\0"..., 832) = 832 fstat(3, st_mode=S_IFREG|0755, st_size=1864888, ...) = 0 mmap(NULL, 3967392, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f70339f7000 mprotect(0x7f7033bb6000, 2097152, PROT_NONE) = 0 mmap(0x7f7033db6000, 24576, PROT_READ|PROT_WRITE, ..., 3, 0x1bf000) = 0x7f7033db6000 mmap(0x7f7033dbc000, 14752, PROT_READ|PROT_WRITE, ..., -1, 0) = 0x7f7033dbc000 close(3) = 0 open("/ch3/libm.so.6", O_RDONLY|O_CLOEXEC) = -1 ENOENT (No such file or directory) access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory) open("/lib/x86_64-linux-gnu/libm.so.6", O_RDONLY|O_CLOEXEC) = 3 read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\0V\0\0\0\0\0\0"..., 832) = 832 fstat(3, st_mode=S_IFREG|0644, st_size=1088952, ...) = 0 mmap(NULL, 3178744, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f70336ee000 mprotect(0x7f70337f6000, 2093056, PROT_NONE) = 0 mmap(0x7f70339f5000, 8192, PROT_READ|PROT_WRITE, ..., 3, 0x107000) = 0x7f70339f5000 close(3) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034757000 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034756000 mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034754000 arch_prctl(ARCH_SET_FS, 0x7f7034754740) = 0 mprotect(0x7f7033db6000, 16384, PROT_READ) = 0 mprotect(0x7f70339f5000, 4096, PROT_READ) = 0 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f7034753000 mprotect(0x7f7034348000, 40960, PROT_READ) = 0 mprotect(0x7f7034559000, 4096, PROT_READ) = 0 mprotect(0x601000, 4096, PROT_READ) = 0 mprotect(0x7f7034780000, 4096, PROT_READ) = 0 munmap(0x7f7034759000, 150611) = 0 brk(NULL) = 0x1053000 brk(0x1085000) = 0x1085000 fstat(1, st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...) = 0? write(1, "checking 'show_me_the_flag'\n", 28checking 'show_me_the_flag' ) = 28? write(1, "ok\n", 3ok ) = 3? exit_group(1) = ? +++ exited with 1 +++
strace会从头开始跟踪程序,包括程序解释器用来创建进程的所有系统调用,使得这里的输出相当长。输出的第一个系统调用是execve,它是由Shell为了启动程序而调用的?。然后程序解释器开始接管并设置执行环境,这里涉及使用mprotect创建内存区域,并且设置正确的内存访问权限。另外你还可以看到用来查找和加载所需动态链接库的系统调用。
回顾5.5节,我们通过设置LD_LIBRARY_PATH环境变量来告诉动态链接器将当前工作目录添加到搜索路径中,这就是你会看到动态链接器在当前目录的多个子文件夹中搜索lib5aw9b7f.so库文件,直到最终在工作目录的根目录找到该库文件的原因?。找到库文件以后,动态链接器读取该库文件并将其映射到内存中?。这里还会重复设置一些其他的库文件(如libstdc++.so.6)?,该过程占strace输出的绝大部分。
直到最后3个系统调用,你才能看到应用程序的特定行为。ctf的第一个系统调用是write,用于输出checking‘show_me_the_flag’到屏幕?。然后是一个write调用,用于输出字符串ok?。最后的一个调用是exit_group,该退出导致状态码1的错误?。
这些信息很有趣,但是它们能够帮助我们找到 flag 吗?不能。这个示例中,strace没有显示任何有用的信息,但还是有必要向你展示strace的工作原理,因为它对理解程序的行为很有帮助。观察程序执行的系统调用不仅对二进制分析有用,而且对调试也很有帮助。
查看ctf的系统调用行为没有太多帮助,所以我们将目光转向库文件调用。为了查看ctf执行的库文件调用,我们要用到ltrace。因为ltrace是strace的近亲,所以需要用到许多相同的命令行参数,包括将-p附加到现有进程。这里我们使用-I选项在每次调用库文件的时候输出指令指针(后面会用到),使用-C自动取消C++函数名称的修饰。如清单5-7所示,用ltrace运行ctf。
清单5-7 ctf二进制文件进行的库文件调用
$ ltrace -i -C ./ctf show_me_the_flag? [0x400fe9] __libc_start_main (0x400bc0, 2, 0x7ffc22f441e8, 0x4010c0 <unfinished ...>? [0x400c44] __printf_chk (1, 0x401158, 0x7ffc22f4447f, 160checking 'show_me_the_flag') = 28 ? [0x400c51] strcmp ("show_me_the_flag", "show_me_the_flag") = 0? [0x400cf0] puts ("ok"ok) = 3? [0x400d07] rc4_init (rc4_state_t * , unsigned char * , int) (0x7ffc22f43fb0, 0x4011c0, 66, 0x7fe979b0d6e0) = 0 ? [0x400d14] std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >:: assign (char const * ) (0x7ffc22f43ef0, 0x40117b, 58, 3) = 0x7ffc22f43ef0? [0x400d29] rc4_decrypt (rc4_state_t * , std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&) (0x7ffc22f43f50, 0x7ffc22f43fb0, 0x7ffc22f43ef0, 0x7e889f91) = 0x7ffc22f43f50? [0x400d36] std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >:: _M_assign (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) (0x7ffc22f43ef0, 0x7ffc22f43f50, 0x7ffc22f43f60, 0) = 0? [0x400d53] getenv ("GUESSME") = nil [0xffffffffffffffff] +++ exited (status 1) +++
如清单5-7所示,ltrace的输出比strace的输出更具可读性,因为它不会被进程设置的代码所“污染”。第一个库文件调用是_libc_start_main?,该函数从_start函数开始将控制权转移到程序的main函数,一旦main启动,第一个库文件调用会把字符串“checking…”?输出到屏幕,实际的检查过程是使用strcmp进行字符串比较,验证ctf的参数是否为show_me_the_flag?,如果是就把ok输出到屏幕?。
以上主要是你之前见过的行为,另外还有一些新的操作:通过调用rc4_init初始化RC4加密,该函数位于你之前提取的库文件中?;接着为一个C++字符串赋值,大概是用加密消息对其进行初始化?;然后调用rc4_decrypt?解密此消息,并将解密后的消息分配到新的C++字符串?。
最后调用getenv?,该函数是用于查找环境变量的标准库函数。你可以看到ctf需要一个名为GUESSME的环境变量,该名称很可能就是之前解密的字符串。这里我们试着将GUESSME环境变量设置为虚拟值,看看ctf的行为是否发生变化,如下所示。
$ GUESSME='foobar' ./ctf show_me_the_flagchecking 'show_me_the_flag'okguess again!
设置GUESSME环境变量会导致输出新的一行,提示你再猜一次。看来ctf希望将GUESSME设置为另一个特定值,如清单5-8所示,也许ltrace的再次运行会揭露该期望值是多少。
清单5-8 设置GUESSME环境变量后,通过ctf二进制文件进行库文件调用
$ GUESSME='foobar' ltrace -i -C ./ctf show_me_the_flag ... [0x400d53] getenv ("GUESSME") = "foobar"? [0x400d6e] std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >:: assign (char const * ) (0x7fffc7af2b00, 0x401183, 5, 3) = 0x7fffc7af2b00? [0x400d88] rc4_decrypt (rc4_state_t * , std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&) (0x7fffc7af2b60, 0x7fffc7af2ba0, 0x7fffc7af2b00, 0x401183) = 0x7fffc7af2b60 [0x400d9a] std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >:: _M_assign (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) (0x7fffc7af2b00, 0x7fffc7af2b60, 0x7700a0, 0) = 0 [0x400db4] operator delete (void * )(0x7700a0, 0x7700a0, 21, 0) = 0? [0x400dd7] puts ("guess again!"guess again!) = 13 [0x400c8d] operator delete (void * )(0x770050, 0x76fc20, 0x7f70f99b3780, 0x7f70f96e46e0) = 0 [0xffffffffffffffff] +++ exited (status 1) +++
调用getenv后,ctf继续分配?并解密?另一个C++字符串。遗憾的是,从解密操作到guess again输出到屏幕?的那一瞬间,看不到有关GUESSME的任何提示,这说明对GUESSME的输入值与期望值的比较操作不需要任何库函数,我们需要使用另一种方法。
5.8 使用objdump检查指令集行为由于我们知道GUESSME环境变量是在不使用任何已知库函数的情况下进行比较,那么下一步最合理的就是使用objdump,从指令级别上检查ctf到底发生了什么。[3]
从清单5-8的ltrace输出我们知道,guess again字符串是通过地址0x400dd7上的puts调用输出到屏幕上的,围绕该地址进行objdump调查,有助于了解字符串的地址以找出加载该字符串的第一条指令。为了找到该地址,我们可以使用objdump -s查看ctf文件的.rodata节,并输出完整的节内容,如清单5-9所示。
清单5-9 使用objdump –s查看ctf文件的.rodata节
$ objdump -s --section .rodata ctfctf: file format elf64-x86-64Contents of section .rodata: 401140 01000200 44454255 473a2061 7267765b ....DEBUG: argv[ 401150 315d203d 20257300 63686563 6b696e67 1] = %s.checking 401160 20272573 270a0073 686f775f 6d655f74 '%s'..show_me_t 401170 68655f66 6c616700 6f6b004f 89df919f he_flag.ok.O.... 401180 887e009a 5b38babe 27ac0e3e 434d6285 .~..[8..'..>CMb. 401190 55868954 3848a34d 00192d76 40505e3a U..T8H.M..-v@P?: 4011a0 00726200 666c6167 203d2025 730a00?67 .rb.flag = %s.. g 4011b0 75657373 20616761 696e2100 00000000 uess again!..... 4011c0 49742773 206b696e 6461206c 696b6520 It's kinda like 4011d0 4c6f7569 7369616e 612e204f 72204461 Louisiana. Or Da 4011e0 676f6261 682e2044 61676f62 6168202d gobah. Dagobah - 4011f0 20576865 72652059 6f646120 6c697665 Where Yoda live 401200 73210000 00000000 s!......
使用objdump检查ctf的.rodata节,可以在地址0x4011af处再次看到guess again字符串。我们来看一下清单5-10,该清单显示了调用puts的指令,以找出ctf对于GUESSME环境变量的期望输入。
清单5-10 检查GUESSME的指令
$ objdump -d ctf ... ? 400dc0: 0f b6 14 03 movzx edx,BYTE PTR [rbx+rax * 1] 400dc4: 84 d2 test dl,dl? 400dc6: 74 05 je 400dcd <_Unwind_Resume@plt+0x22d>? 400dc8: 3a 14 01 cmp dl,BYTE PTR [rcx+rax * 1] 400dcb: 74 13 je 400de0 <_Unwind_Resume@plt+0x240>? 400dcd: bf af 11 40 00 mov edi,0x4011af? 400dd2: e8 d9 fc ff ff call 400ab0 <puts@plt> 400dd7: e9 84 fe ff ff jmp 400c60 <_Unwind_Resume@plt+0xc0> 400ddc: 0f 1f 40 00 nop DWORD PTR [rax+0x0]? 400de0: 48 83 c0 01 add rax,0x1? 400de4: 48 83 f8 15 cmp rax,0x15? 400de8: 75 d6 jne 400dc0 <_Unwind_Resume@plt+0x220> ...
指令在0x400dcd处加载guess again?字符串,然后使用puts?将其输出,这是一个失败分支,从这里往回看。
失败分支是起始地址为0x400dc0的循环的一个分支,在每次循环中,它会将数组(可能是字符串)的字节加载到edx中?,rbx寄存器指向数组的基址,rax对其进行索引。如果载入的字节结果为NULL,那么地址0x400dc6的指令就会跳转到失败分支?。这里与NULL进行比较其实是对字符串结尾的检查,如果这里已经到达字符串的结尾,说明字符串太短,没办法进行匹配。如果载入的字节结果不为NULL,那么je跳转到下一条指令,地址0x400dc8的指令将edx中的低字节与另一个字符串中的字节进行比较,该字符串以rcx为基址、rax为索引?。
如果比较的两字节相匹配,那么程序将跳转到地址0x400de0。增加字符串索引rax?,并检查字符串索引是否等于字符串的长度0x15?,如果相等,那么字符串比较完成,否则程序跳转到另一个迭代中?。
通过上面的分析,现在我们知道字符串是以rcx寄存器作为基址的基本事实。ctf程序会将从GUESSME环境变量获得的字符串与rcx字符串进行比较,这意味着如果可以转储rcx字符串,那么就可以找到GUESSME的值。因为该字符串是在运行时解密的,而静态分析不可用,所以需要使用动态分析来恢复。
5.9 使用GDB转储动态字符串缓冲区GNU/Linux操作系统上最常用的动态分析工具可能是GDB,或者GNU Debugger。顾名思义,GDB主要用于调试,不过它也可用于各种动态分析。实际上,GDB是一种极为通用的调试工具,本章无法涵盖其所有功能,但是我会介绍GDB的一些最常用的功能,你可以使用这些功能来还原GUESSME的值。寻找GDB信息最好的地方不是Linux手册页,而是GNU官方网站手册,你可以在该网站找到所有支持GDB命令的详细内容。
与strace和ltrace一样,GDB可以附加到正在运行的进程,但由于ctf不是一个长时间运行的进程,因此可以一开始就使用GDB运行。因为GDB是一种交互式工具,所以在GDB启动二进制文件的时候,不会立即执行该二进制文件。在输出带有用法说明的启动消息后,GDB暂停并等待命令,通过命令提示符(gdb)声明GDB正在等待命令。
清单5-11显示了GUESSME环境变量的期望值所需的GDB命令序列。在讨论清单前,先解释一下每条命令的意思。
清单5-11 GUESSME环境变量的期望值所需的GDB命令序列
$ gdb ./ctf GNU gdb (Ubuntu 7.11.1-0ubuntu1~16.04) 7.11.1 Copyright (C) 2016 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "x86_64-linux-gnu". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.▓▓▓▓▓▓▓/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.▓▓▓▓▓▓▓/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from ./ctf...(no debugging symbols found)...done.? (gdb) b *0x400dc8 Breakpoint 1 at 0x400dc8? (gdb) set env GUESSME=foobar? (gdb) run show_me_the_flag Starting program: /home/binary/code/chapter3/ctf show_me_the_flag checking 'show_me_the_flag' ok? Breakpoint 1, 0x0000000000400dc8 in ?? ()? (gdb) display/i $pc 1: x/i $pc => 0x400dc8: cmp (%rcx,%rax,1),%dl? (gdb) info registers rcx rcx 0x615050 6377552? (gdb) info registers rax rax 0x0 0? (gdb) x/s 0x615050 0x615050: "Crackers Don't Matter"? (gdb) quit
调试器最基本的功能之一就是设置断点。断点是指调试器将要“中断”执行的地址或者函数名。每当调试器到达断点,它就会暂停执行并将控制权返回给用户,等待命令输入。为了转储GUESSME环境变量的“幻数”字符串,我们在发生比较的地址0x400dc8?设置一个断点。在GDB中,在地址处设置断点的命令是b*address(b是命令break的简短版本)。如果符号可用(本案例中不可用),可以使用函数名称在函数的入口点设置断点,例如在main的开始位置设置断点,可以使用命令b main。
设置断点后,还需要为GUESSME环境变量设置一个值,才能开始执行ctf,以防ctf过早退出。在GDB中,可以使用命令set env GUESSME = foobar?设置GUESSME环境变量。现在可以通过命令run show_me_the_flag?执行ctf。正如你所看到的,可以将参数传递给run命令,然后其会自动传递给正在分析的二进制文件,如ctf。现在ctf开始正常执行,并继续执行直到命中断点。
当ctf命中断点的时候,GDB会暂停ctf的执行,并将控制权返回给你,告知你断点已经被命中?。此时,可以使用display/i $pc命令在当前程序计数器($pc)上显示指令,确保在预期的指令上中断?。不出所料,GDB提示下一条要执行的指令是cmp (%rcx,%rax,1), %dl,该指令确实是我们感兴趣的比较指令(采用AT&T格式)。
现在已经来到ctf中将从GUESSME环境变量获得的字符串与预期字符串进行比较的位置,我们需要找到字符串的基址,并将其导出。为了查看rcx寄存器中包含的基址,使用命令info registers rcx?,通过该命令还可以查看rax的内容,以确保循环计数器为零。正如预期的那样?,我们也可以在不指定任何寄存器名称的情况下使用命令info registers,这个时候,GDB就会显示所有的通用寄存器内容。
现在我们得到了要转储的字符串的基址,地址从0x615050开始,剩下要做的就是将字符串转储到该地址。在GDB中转储内存的命令是x,它能够以各种编码和粒度转储内存,如x/d以十进制形式转储单字节,x/x以十六进制形式转储单字节,x/4xw以4个十六进制字(4字节整数)的形式进行转储。这个示例中,最有用的就是x/s命令,它会以C风格的形式转储字符串,直到遇见NULL字节为止。当你用x/s 0x615050命令来转储字符串的时候?,你会发现GUESSME环境变量的期望值是Crackers Don’t Matter,最后用quit?命令退出GDB。
$ GUESSME="Crackers Don't Matter" ./ctf show_me_the_flagchecking 'show_me_the_flag'okflag = 84b34c124b2ba5ca224af8e33b077e9e
如上所示,我们终于完成了所有的步骤,得到神秘的flag。在虚拟机的本章目录上,找到一个名为oracle的程序,并将flag提交给oracle(./oracle 84b34c124b2ba5ca224af8e33b077e9e)。现在我们已经成功解锁下一个挑战,你可以使用新学会的技能自行完成该练习。
5.10 总结在本章中,我向你介绍了成为一名二进制分析师需要用到的Linux二进制分析工具。虽然这些工具大多数都非常简单,但是你可以将它们组合起来实现功能强大的二进制分析。在第6章中,你将要探索一些主要的反汇编工具,以及其他更高级的分析技术。
本文摘自《二进制分析实战》
二进制分析是分析计算机二进制程序(称为二进制文件)及其包含的机器代码和数据属性的科学和艺术。二进制分析的目标是确定二进制程序的真正属性,以理解它们真正的功能。
本书是为安全工程师编写的,涉及二进制分析和检测的相关内容。本书首先介绍了二进制分析的基本概念和二进制格式,然后讲解了如何使用GNU/Linux二进制分析工具链、反汇编和代码注入这样的技术来分析二进制文件,最后介绍了使用Pin构建二进制插桩的方法以及使用libdft构建动态污点分析工具的方法等。
本书适合安全工程师、学术安全研究人员、逆向工程师、恶意软件分析师和对二进制分析感兴趣的计算机科学专业的学生阅读。
电脑