精通汇编语言编程(三)
原文:
annas-archive.org/md5/615c1868845695f8399bbdf3f670718e译者:飞龙
第九章:操作系统接口
在准备写这章内容时,我想起了大学时上的一门实时系统课程。当然不是整个课程,而是我们被分配的一个任务——如果说最有趣的任务之一,那可能就是这个了,甚至可以说是最有趣的任务。我们必须写一个小程序,使得文本字符串在屏幕上从左到右再返回,直到按下某个键盘上的按键为止。另外两个按键可以控制字符串移动的速度。那是 2001 年或 2002 年,我们仍然使用 DOS 来进行与汇编相关的练习,而这个任务看起来相当简单。
我个人觉得使用 DOS 中断来做这个事情非常无聊(当时我还不知道奥卡姆剃刀原则,此外,我还想显得聪明),于是我决定完全不使用任何操作系统。我的笔记本有一个软盘驱动器,所以我唯一缺少的是一个能够将原始扇区写入软盘的程序,而我自己写了这个程序(猜猜我用的是什么编程语言)。
这个程序由两部分组成:
-
引导加载程序:这是一个小程序(编译后必须适应一个 512 字节的扇区),只负责一件事——从软盘加载我的程序并设置它以便运行
-
程序:这实际上是一个用于显示移动字符串的程序
拥有适当的文档并不是实现整个程序包的难点。然而,我不得不处理一些通常我们不太接触的事情。其中之一就是一个原始的显卡驱动程序,它负责切换到图形模式,正确位置显示字符串,并在程序终止之前切换回文本模式。另一个则是编写一个原始的键盘驱动程序,基本上是一个中断处理程序,用来监听键盘输入,调整字符串的移动速度,或者指示程序终止。简单来说,我必须自己处理硬件接口(哦,那是“实模式”的好时光……一切既简单又复杂)。
在现代,除非你是驱动程序开发者,否则你完全不需要直接访问硬件——操作系统会为我们处理所有的脏活,我们可以专注于实现自己的想法。
到目前为止,我们能够用汇编语言实现任何算法,甚至在拥有适当文档的情况下,我们可以编写自己的驱动程序,但这么做会在编写用户空间应用程序时引入冗余工作。更不用说,硬件供应商已经提供了所有硬件的驱动程序,而奥卡姆剃刀原则告诉我们不要无谓地增加复杂性。现代操作系统擅长管理这些驱动程序并提供更简单、无缝的硬件访问,使我们能够专注于创造的过程。
本章中,我们将看到如何轻松无痛地使用操作系统和其他人已创建的众多库所赋予我们的功能。我们将首先将第三方目标文件链接到我们的代码,接着通过导入 DLL/SO 的 API,最后通过动态加载 DLL/SO,在运行时导入 API。
环的概念
几乎所有现代平台(除了少数嵌入式平台)都采用相同的安全原则——通过安全级别和权限划分执行环境;在这种情况下,这意味着能够访问某些资源。在基于英特尔的平台上,有四个安全级别,称为保护环。这些环的编号从 0 到 3,数字越大,权限越低。显然,在权限较低的级别运行的代码不能直接访问具有更高权限级别的内存。我们将很快看到数据是如何在不同权限级别之间传输的。
以下图示说明保护环的概念:
下面是保护环的不同权限描述:
-
Ring 0 是权限最高的级别,所有指令都可以使用,所有硬件都可以访问。这里是内核所在的灰色区域,伴随内核空间的驱动程序。
-
Ring 1 和 Ring 2 主要用于作为驱动程序执行环境,但几乎没有被使用。
-
Ring 3 是用户空间。这是普通软件被授予的权限级别,也是我们唯一关注的权限级别。虽然深入了解可能会非常有趣,但对于本书的目的来说,这并不实用,因为我们所有的代码只需要权限级别 3。
系统调用
如果用户空间的应用程序无法向内核发起服务请求,那么它毫无价值,因为它甚至无法在不请求内核终止它正在运行的进程的情况下正常终止。所有系统调用可以按如下方式分类:
-
进程控制:属于此类别的系统调用负责进程/线程的创建和管理,以及内存分配/释放。
-
文件管理:这些系统调用负责文件的创建、删除和 IO。
-
设备管理:此类别包含用于设备管理/访问的系统调用。
-
维护:此类别包含用于管理日期、时间、文件或设备属性的系统调用。
-
通信:管理通信通道和远程设备
系统调用硬件接口
在硬件级别,处理器为我们提供了几种方式来调用内核过程以处理系统调用:
-
通过中断 (32 位系统上的 INT 指令):操作系统为具有特定编号的中断分配描述符,指向内核空间中的一个过程,该过程根据中断的参数处理该中断(参数通过寄存器传递)。其中一个参数是指向系统调用表的索引(粗略地说,是指向特定系统调用处理程序的指针表)。
-
使用 SYSENTER 指令 (32 位系统,不包括 WOW64 进程):从 Pentium II 开始,我们可以使用
SYSENTER指令快速调用 ring 0 程序。该指令伴随有SYSEXIT指令,用于从系统调用返回。 -
使用 SYSCALL 指令 (64 位系统):此指令由 x86_64 架构引入,仅在长模式下可用。该指令允许更快速地转移到系统调用处理程序,并且不会访问中断描述符表。
直接系统调用
使用前述指令之一将意味着进行直接的系统调用,并绕过所有的系统库,如下图所示。然而,这不是最佳实践,我们稍后会看到为什么:
在 Linux 上使用直接系统调用方法很可能会成功,因为 Linux 系统调用有良好的文档支持,并且其调用号是众所周知的(可以在 32 位系统的 /usr/include/asm/unistd_32.h 和 64 位系统的 /usr/include/asm/unistd_64.h 中找到),而且这些调用号一般不会发生变化。例如,以下代码在 32 位 Linux 系统上将一个 msg 字符串打印到标准输出:
mov eax, 4 *; 4 is the number of sys_write system call*
mov ebx, 1 *; 1 is the stdout file number*
lea ecx, [msg] *; msg is the label (address) of the string to write*
mov edx, len *; length of msg in bytes*
int 0x80 *; make syscall*
这是其 64 位版本:
mov rax, 1 *; 1 is the number of sys_write on 64-bit Linux*
mov rdi, 1 *; 1 is the stdout file number*
mov rsi, msg *; msg is the label (address) of the string*
mov rdx, len *; length of msg in bytes*
syscall *; make the system call*
然而,在 Windows 上,尽管思想相同,但实现方式却不同。首先,Windows 系统调用没有公开的官方文档,这不仅需要一定的反向工程技术,还无法保证通过反向工程 ntdll.dll 获得的信息在下次更新后仍然保持不变,更不用说系统调用号会在不同版本之间发生变化。然而,为了普及教育,下面是 32 位 ntdll.dll 的系统调用调用过程:
_KiIntSystemCall:
lea edx, [esp + 8] *; Load EDX with pointer to the parameter block*
int 0x2e *; make system call*
此外,如果 SYSENTER 指令可用,那么我们将得到以下情况:
_KiFastSystemCall:
mov edx, esp *; Load EDX with stack pointer so that kernel*
*; may access the parameter block on stack*
sysenter *; make system call*
尽管第二种变体更具潜力,但仍无法保证参数块的格式不会发生变化(尽管这种情况不太可能)。总结本小节,值得一提的是,除非绝对必要,否则强烈不建议使用直接系统调用。
间接系统调用
更常见的利用系统服务的方式是通过支持库,无论是 Windows 上的系统 DLL 还是 Linux 上的 libc,它们提供了比原始系统调用接口更便捷的 API。过程如下面的图所示:
尽管看起来引入另一层可能会带来冗余的复杂性,但实际上情况正好相反,更不用说在这种情况下我们的代码将变得更具可移植性。
使用库
正如之前所述,从汇编语言编写的程序与操作系统交互的最佳方式是通过系统 API —— Windows 上的系统 DLL 和 Linux 上的 libc,本章的其余部分将专注于此主题,因为它将大大简化你作为汇编开发者的工作。
本章的其余部分将专注于如何使用外部库和 DLL 文件(如果在 Windows 上),或者在 Linux 上使用外部库和共享对象。我们将一举两得,不仅学习如何将 DLL 或系统库文件链接到代码中,还将涵盖如何将其他对象文件与代码链接。
为了举例说明,我们将创建一个小程序,该程序将消息打印到标准输出,并使用我们在第八章中开发的模块,混合汇编语言编写的模块和高级语言编写的模块,用于消息的加密和解密。
Windows
在 Windows 上,有两种方法可以访问外部功能:一种是将代码编译为对象文件,并将其与其他对象文件或库链接,另一种是创建可执行文件并导入由不同 DLL 导出的函数。我们将分别研究这两种方法,以便你能在需要时选择最合适的方法。
与对象文件和/或库文件的链接
对象文件是一个包含可执行代码和/或数据的文件。它不能单独执行(即使它包含了可执行文件的所有代码),因为存储在此类文件中的信息仅供链接器在构建最终可执行文件时使用;否则,文件中的所有代码和数据都没有绑定到任何地址,仅提供提示。关于 Microsoft 对象文件格式的详细信息以及 PE 可执行格式的规格,可以在www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx获取。访问此 URL,点击下载按钮,选择pecoff.docx。
对象文件
让我们开始写代码,创建我们的对象文件 obj_win.asm:
*; First we need to tell the assembler that*
*; we expect an object file compatible with MS linker*
format MS COFF
*; Then specify the external API we need*
*; extrn means that the procedure is not in this file*
*; the 4 after the '@' specifies size of procedure parameters*
*; in bytes*
*; ExitProcess is a label and dword is its size*
extrn '__imp__ExitProcess@4' as ExitProcess:dword
extrn '__imp__GetStdHandle@4' as GetStdHandle:dword
extrn '__imp__WriteConsoleA@20' as WriteConsole:dword
*; And, of course, our "crypto API"*
extrn '_GetPointers' as GetPointers:dword
*; Define a constant for GetStdHandle()*
STD_OUTPUT_HANDLE equal -11
*; and a structure to ease the access to "crypto functions"*
struc crypto_functions
{
.f_set_data_pointer dd ?
.f_set_data_length dd ?
.f_encrypt dd ?
.f_decrypt dd ?
}
*; The following structure makes it a bit easier*
*; to manipulate strings and sizes thereof*
struc string [s]
{
common
. db s
.length = $ - .
}
在我们实现代码之前,让我们先创建数据段,以便代码更容易理解:
section '.data' data readable writeable
*; We will store the STDOUT handle here*
stdout dd ?
*; This buffer contains the message we will operate on*
buffer string 'Hello from object file!', 0x0a, 0x0d
*; Progress messages*
msg1 string 'Encrypted', 0x0a, 0x0d
msg2 string 'Decrypted', 0x0a, 0x0d
*; This one is required by the WriteConsole procedure*
bytesWritten dd ?
数据段非常自明,我们现在终于可以写代码了:
section '.text' code readable executable
*; We need the entry point to be accessible to the linker,*
*; therefore we make it "public"*
public _start
_start:
*; The first step would be obtaining the STDOUT handle*
push STD_OUTPUT_HANDLE
*; Since we are linking against a DLL, the GetStdHandle*
*; label would refer to a location in the import section*
*; which the linker will create for us. Hence we make an*
*; indirect call*
call [GetStdHandle]
*; Store the handle*
mov [stdout], eax
*; Print the message*
push 0 bytesWritten buffer.length buffer eax
call [WriteConsole]
*; Let's play with encryption a bit*
*; First get the procedure pointers. Since the GetPointers()*
*; is in another object file, it would be statically linked,*
*; therefore we make a direct call*
call GetPointers
*; Store the pointer to the crypto_functions structure in EBX*
mov ebx, eax
记得virtual指令吗?
我们程序员有时是懒惰的,喜欢事物变得更加方便,尤其是在编写代码一周后查看自己写的代码时。因此,我们更愿意按名称处理我们的加密程序,而不是根据crypto_functions结构的地址偏移来处理。这时,virtual指令派上用场,它允许我们像下面的代码片段所示那样将由 EBX 寄存器指向的位置标记为虚拟标签:
virtual at ebx
funcs crypto_functions
end virtual
funcs是一个虚拟标签,指向由ebx寄存器指向的位置,它将在编译时被替换为ebx。任何通过funcs引用的crypto_functions结构的成员都将被其在结构中的偏移量所替换。现在,让我们设置加密引擎,对存储在buffer中的消息进行加密和解密:
*; Set the pointer to data and its length*
push buffer
call [funcs.f_set_data_pointer] *; Equivalent to 'call [ebx]'*
push buffer.length
call [funcs.f_set_data_length]
*; We have to restore the stack pointer due to the*
*; fact that the above two procedures are in accordance*
*; with the cdecl calling convention*
add esp, 8
*; Encrypt the content of the buffer*
call [funcs.f_encrypt]
*; Print progress message*
push 0 bytesWritten msg1.length msg1 [stdout]
call [WriteConsole]
*; Decrypt the content of the buffer*
call [funcs.f_decrypt]
*; Print another progress message*
push 0 bytesWritten msg2.length msg2 [stdout]
call [WriteConsole]
*; Print the content of the buffer in order to verify*
*; decryption*
push 0 bytesWritten buffer.length buffer [stdout]
call [WriteConsole]
*; All is fine and we are free to exit*
push 0
call [ExitProcess]
生成可执行文件
编译这个源文件会生成obj_win.obj文件,我们将把它链接到kernel32.lib和crypto_w32.obj。但是我们该去哪里找到kernel32.lib文件呢?这个任务有时可能并不简单,尽管它并不困难。所有的系统库都可以在c:\Program Files\Microsoft SDKs\Windows\vX.X\Lib目录中找到,其中vX.X代表版本号(很可能会有多个版本)。对于 64 位 Windows,目录应为c:\Program Files (x86)\Microsoft SDKs\Windows\vX.X\Lib。所以,让我们把crypto_w32.obj文件复制到工作目录中,然后尝试链接它。打开 VS 2017 的开发者命令提示符窗口,如下图所示,并导航到你的工作目录:
输入以下命令:
link /entry:start /subsystem:console obj_win.obj "c:\Program Files\Microsoft SDKs\Windows\v7.0A\Lib\kernel32.lib" crypto_w32.obj
一旦按下回车键,如果一切顺利,控制台中将显示 Microsoft (R)增量链接器的徽标消息,随后会出现新的提示符,并且会生成obj_win.exe文件。尝试运行它,应该会得到以下输出:
Hello from object file!
Encrypted
Decrypted
Hello from object file!
Voilà!我们刚刚在汇编代码中使用了外部功能。
从 DLL 导入过程
Flat Assembler 为我们提供了另一种使用外部功能的方法。虽然在其他汇编器中我们需要一个链接器来链接 DLL 文件,Flat Assembler 使得我们可以生成一个包含所有在源代码中定义的导入项的可执行文件,这样我们只需编译源代码并运行可执行文件。
运行时将动态链接库链接到我们代码的过程相当简单,可以通过以下图示来说明:
一旦加载器加载了一个可执行文件,它将解析其导入表(如果存在),并识别请求的库(有关导入表格式规格,请参阅PECOFF.docx)。对于在导入部分找到的每个库引用,加载器尝试加载该库,然后解析可执行文件的导入部分,查找该库所导出的过程名称,并扫描库的导出部分以寻找匹配项。一旦找到匹配项,加载器计算该条目的虚拟地址,并将其写回可执行文件的导入部分。此过程对于每个请求的库的每个导入条目都会重复。
对于我们的例子,我们将使用与链接对象相同的代码(只需将其重命名为dll_win.asm),并做一些微小的修改,将crypto_w32.obj替换为crypto_w32.dll。首先,删除所有的extrn和public声明,然后通过将format MS COFF更改为format PE CONSOLE,告诉汇编器这次我们期待的是一个控制台可执行文件,而不是一个对象文件。
由于我们将创建自己的导入表,因此需要包含win32a.inc文件,该文件包含我们可能需要的所有宏。将这一行添加到格式声明之后:
include 'win32a.inc'
我们快完成了;将以下代码附加到源文件中:
section '.idata' import data readable writeable
*; Tell the assembler which libraries we are interested in*
library kernel,'kernel32.dll',\
crypto,'crypto_w32.dll'
*; Specify procedures we need from kernel32.dll*
import kernel,\
GetStdHandle, 'GetStdHandle',\
WriteConsole, 'WriteConsoleA',\
ExitProcess, 'ExitProcess'
*; And, finally, tell the assembler we are also*
*; interested in our crypto engine*
import crypto,\
GetPointers, 'GetPointers'
我们需要做的最后一项修改是将call GetPointers改为call [GetPointers],因为这次GetPointers过程不会静态链接到我们的可执行文件中,而是将从动态链接库中导入,这意味着GetPointers标签将引用内存中的一个地址,该地址存储着GetPointers过程的地址。
尝试编译该文件并在控制台中运行。你应该得到与我们从多个对象链接的可执行文件相同的输出。
如果你遇到一个错误消息,显示可执行文件未启动,而不是预期的输出,尝试在platform.inc文件的TARGET_W32_DLL部分中添加section '.reloc' fixups data readable discardable这一行,然后重新编译crypto_w32.dll。这对于构建 DLL 是正确的,尽管在某些情况下没有这个也可能能正常工作。
当然,可以使用LoadLibrary() Windows API 手动加载 DLL,并通过GetProcAddress()解析所需过程的地址,但这与链接 DLL 或导入 API 没有区别,因为我们仍然需要导入这两个 API。然而,有一种方法可以让我们以所谓的隐形方式导入 API 地址。
在构建 64 位可执行文件时,完全适用相同的规则。唯一的区别是kernel32.lib的位置,它将位于c:\Program Files\Microsoft SDKs\Windows\vX.X\Lib\x64,以及指针的大小。另外,非常重要的一点是,记住在 x86_64 Windows 上使用的调用约定既不是cdecl也不是stdcall!
Linux
在 Linux 中,就像在 Windows 中一样,我们支持静态和动态链接(也支持手动导入)。主要区别在于,在 Linux 中(这是我个人的看法),构建软件要容易得多,因为所有开发工具都集成在系统中。嗯,除了 Flat Assembler,但它的集成并不成问题——我们只需将 fasm 可执行文件复制到用户 PATH 环境变量中包含的某个 bin 目录即可。
幸运的是,Flat Assembler 内置支持生成目标文件和可执行文件,它能够像在 Windows 上一样在 Linux 上导入库中的程序。稍后我们将看到,在 Linux 上这些方法与 Windows 上几乎相同,只要我们不深入研究 ELF 规范和格式的细节。
如果你想深入探索 ELF 格式,相关规范可以在以下链接查看:
refspecs.linuxbase.org/elf/elf.pdf 了解 32 位 ELF 规范。
和
ftp.openwatcom.org/devel/docs/elf-64-gen.pdf 了解 64 位 ELF 规范。
如果这些链接出现失效,你也可以通过 Google 或其他搜索引擎找到这些规范。
就像我们在 Windows 中做的那样,我们将首先将几个目标文件链接到一个可执行文件中,然后创建一个带有动态依赖链接的可执行 ELF 文件。
链接目标文件和/或库文件
Microsoft 公共对象文件格式(MS COFF)和 ELF(可执行与可链接格式,以前称为 可扩展链接格式)的结构差异很大,但对我们来说,这种差异完全不重要。ELF 由 UNIX 系统实验室开发,并于 1997 年发布。它后来被选为 32 位 Intel 架构的便携式目标文件格式。直到今天,32 位系统使用 ELF,64 位系统使用 ELF64。
然而,从我们的角度来看,Linux 的代码与 Windows 的代码非常相似。更准确地说,正是 FASM 让它们非常相似。
目标文件
就像 Windows 的目标文件源代码一样,我们一如既往地首先告诉汇编器我们期望什么样的输出,哪些程序是公开的,哪些是外部的:
format ELF
*; As we want GCC to take care of all the startup code*
*; we will call our procedure "main" instead of _start,*
*; otherwise we would be using LD instead of GCC and*
*; would have to specify all runtime libraries manually.*
public main
*; The following function is linked from libc*
extrn printf
*; And this one is from our crypto library*
extrn GetPointers
接下来我们进行便捷宏定义,建议将便捷宏放入一个单独的包含文件中,这样可以方便地在不同代码中使用,而无需重新编写它们:
struc crypto_functions
{
.f_set_data_pointer dd ?
.f_set_data_length dd ?
.f_encrypt dd ?
.f_decrypt dd ?
}
struc string [s]
{
common
. db s
.length = $ - .
.terminator db 0
}
数据段几乎与 Windows 目标文件中的相同,不同之处在于我们不需要一个变量来保存 stdout 句柄:
section '.data' writeable
buffer string 'Hello from ELF linked from objects!', 0x0a
msg1 string 'Encrypted', 0x0a
msg2 string 'Decrypted', 0x0a
最后是代码部分。从逻辑上来说,这段代码和之前一样,唯一的不同是使用了printf()代替WriteConsoleA(),在这种情况下,libc中的printf()实现将为我们处理所有的安排,并调用一个SYS_write的 Linux 系统调用。由于从 GCC 的角度看,我们仅仅是实现了main()函数,因此我们不需要自己终止进程,也就没有引入exit()过程——运行时代码会自动添加并链接,GCC 会处理其余的部分,而我们只需要从main()函数返回:
section '.text' executable
*; Remember that we are using GCC for linking, hence the name is*
*; main, rather than _start*
main:
*; Print the content of the buffer to stdout*
*; As all procedures (except crypto procedures) would be*
*; statically linked, we are using direct calls*
push buffer
call printf
*; Restore stack as printf() is a cdecl function*
add esp, 4
*; Get pointers to cryptographic procedures*
call GetPointers
mov ebx, eax
*; We will use the same trick to ease our access to cryptography*
*; procedures by defining a virtual structure*
virtual at ebx
funds crypto_functions
end virtual
*; Right now we will push parameters for all subsequent procedure*
*; calls onto the stack in reverse order (parameter for the last*
*; call is pushed first*
push 0 buffer msg2 msg1 buffer.length buffer
*; Set crypto library's data pointer*
*; Crypto procedures are not available at link time, hence not*
*; statically linked. Instead we obtain pointers thereof and this*
*; is the reason for indirect call*
call [funcs.f_set_data_pointer]
*; Restore stack*
add esp, 4
*; Set size of the data buffer*
call [funcs.f_set_data_length]
add esp, 4
*; Encrypt the buffer. As this procedure has no parameter, there*
*; is no reason to do anything to stack following this call*
call [funcs.f_encrypt]
*; Print msg1*
call printf
add esp, 4
*; Decrypt the buffer back*
call [funcs.f_decrypt]
*; Print msg2*
call printf
add esp, 4
*; Print the content of the buffer to ensure correct decryption*
call printf
add esp, 4
*; All is done, so we may safely exit*
pop eax
ret
生成可执行文件
将文件保存为o_lin.asm,并使用终端中的fasm o_lin.asm命令将其编译为目标文件。下一步将使用以下命令将o_lin.o与crypto_32.o链接:
gcc -o o_lin o_lin.o crypto_32.o
***# If you are on a 64-bit system then***
gcc -o o_lin o_lin.o crypto_32.o -m32
这将生成一个5KB o_lin可执行文件——相比我们曾经生成的代码大小,这个文件相当庞大。如此巨大的体积是由于 GCC 将 C 运行时库链接其中。尝试运行它,你应该在终端看到以下内容:
ELF 的动态链接
并不是总是适合将目标文件静态链接成单一可执行文件,Linux 提供了一个机制,可以生成一个 ELF 可执行文件,该文件在运行时与所需的库(共享对象)动态链接。Flat Assembler 曾经对 ELF 的支持相对基础,这意味着只能创建直接使用系统调用的可执行文件,或者创建一个目标文件以与其他文件链接(正如我们所做的那样)。
Flat Assembler 对 ELF 的支持在版本 1.69.05 发布时得到了扩展——添加了一些段属性,并引入了几个便捷宏,使我们能够手动在 ELF 可执行文件中创建导入表。这些宏位于 Linux 包中的examples/elfexe/dynamic目录下(在以下截图中有下划线标出):
这些宏可以在本章随附代码中的linux_include文件夹下找到。
代码
动态链接 ELF 的代码几乎与 ELF 目标文件的代码相同,只有一些微小的差别。首先,formatter指令必须告诉汇编器生成可执行文件,而不是目标文件:
format ELF executable 3 *; The 3 may be omitted if on Linux*
*; Include this in order to be able to create import section*
include 'linux_include/import32.inc'
*; We have to specify the entry point for the executable*
entry _start
本章中使用的便捷结构(crypto_functions 和 string)依然有效,并应被放置在文件中。虽然没有严格规定它们应放置的位置,但它们应当在使用之前出现:
*; The content of the data section is the same as in object file*
*; source. The section itself is declared in a different way (in*
*; fact, although, an ELF file is divided into sections, it is*
*; treated a bit differently when in memory - it is divided into*
*; segments)*
segment readable writeable
buffe string 'Hello from dynamically linked ELF!', 0x0a
msg1 string 'Encrypted', 0x0a
msg2 string 'Decrypted', 0x0a
为了增强 Flat Assembler 对 ELF 的支持,引入了一个新的段,其中一个是包含可与可执行文件一起使用的加载器名称的解释器:
segment interpreter writeable
db '/lib/ld-linux.so.2',0
另一个是动态的,作为一个导入索引。然而,我们不会自己声明这个段;相反,我们将使用两个宏——其中一个会创建所需库的列表,另一个则指定要导入的程序。在我们的例子中,它将如下所示:
*; In our example we only need to libraries - libc for*
*; printf() and exit() (and we will use exit() this time)*
*; and crypto_32.so for our cryptographic core.*
needed\
'libc-2.19.so',\
'crypto_32.so'
*; Then we specify requested procedures*
import\
printf,\
exit,\
GetPointers
其余的代码只需要做一些小的修改。首先,代码段声明如下:
segment executable readable
_start:
这次所有的程序都被间接调用:
push buffer
call [printf]
add esp, 4
call [GetPointers]
mov ebx, eax
virtual at ebx
funcs crypto_functions
end virtual
push 0 buffer msg2 msg1 buffer.length buffer
*; All procedures are cdecl, so we have to adjust*
*; the stack pointer upon return from procedures*
*; with parameters*
call [funcs.f_set_data_pointer]
add esp, 4
call [funcs.f_set_data_length]
add esp, 4
call [funcs.f_encrypt]
call [printf]
add esp, 4
call [funcs.f_decrypt]
call [printf]
add esp, 4
call [printf]
add esp, 4
call [exit]
我们替换掉的最后两条指令是:
pop eax
ret
和:
cal [exit]
将文件保存为so_lin.asm。
现在,你可以构建并运行新创建的可执行文件了:
fasm so_lin.asm
./so_lin
如果一切都做得正确,你应该看到这个:
总结
在这一章中,你了解了系统调用——操作系统的服务网关。你学到了,使用现有的库来间接调用系统调用,既更实用也更方便,而且更加安全。
本章故意没有提供 64 位的示例,因为我希望你能自己尝试编写这些简单的可执行文件的 64 位版本,作为一个小练习来测试自己。
现在我们是大师了。我们有了坚实的基础,能够用纯粹的 Intel 汇编实现任何算法,甚至能够直接调用系统调用(至少在 Linux 上可以,因为在 Windows 上这样做是强烈不推荐的)。然而,作为真正的大师,我们知道还有更多需要学习和探索的东西,因为单单一个基础是远远不够的。
第十章:修补遗留代码
几年前,我有机会参与一个有趣的项目——我接到了一个商家老板的电话,他因一个可悲的开发者锁死了可用的可执行文件,而该开发者拿了钱就消失了。由于没有源代码,唯一的选择是修补可执行文件,以更改执行流程并绕过锁定。
不幸的是,这并不是一个孤立的案例。老旧工具经常出现需要稍微更改的情况(即使已经存在多年,甚至几十年),然后……嗯,至少有两个选择:
-
源代码丢失,无法在应用更改后重新构建可执行文件。
-
源代码存在,但似乎已经老旧到无法用现代编译器编译,几乎需要从头重写。在这种情况下,即使重写不是大问题,但与软件一起使用的库可能与现代编译器或其输出不兼容,这将使整个项目变得更加复杂,问题依然存在。
根据需要应用的更改复杂度,直接用新代码修补二进制可执行文件可能是一个足够的选择,因为将几个字节放入十六进制编辑器要比逆向工程一个工具(无论是其二进制形式还是已经不再被编译器支持的旧源代码)并从头重写它更简单。
在本章中,我们将考虑一个非常简单的可执行文件示例,目标是进行安全修复。我们将分别为 Windows 和 Linux 创建可执行文件,并首先研究可用的选项,然后应用二进制补丁。由于我们将面向两个平台,我们将在需要时讨论 PE 和 ELF 文件格式。
可执行文件
如前所述,我们必须首先创建可执行文件。寻找一个足够简单、贴合本章内容的现实示例似乎是一个相对困难的任务,因此我们决定采用一个现实中的问题,并用简化的代码进行封装。我们将用 C 语言编写可执行文件的代码,并在 Windows 上使用 Visual Studio 2017 编译,在 Linux 上使用 GCC 编译。代码将简单如以下所示:
如我们所见,这段代码唯一能够做的,就是将用户输入作为字符串读取到一个 128 字节的缓冲区中,为输入字符串分配一个内部缓冲区,将输入字符串复制到其中,并从内部缓冲区打印它。
在 Visual Studio 2017 中创建一个新的解决方案,命名为Legacy,并将前面展示的代码填入其main.cpp文件。个人来说,我更喜欢在编写 C 代码时使用 .c 扩展名,并将“编译方式”选项(可以在项目属性窗口中通过导航到配置属性 | C/C++ | 高级找到)设置为 C。
将前面的代码构建成可执行文件的过程非常简单,除了一个关于 Visual Studio 2017 的细节。当我们尝试伪造一个Legacy可执行文件时,我们需要禁用链接器的动态基址选项。在 Visual Studio 中,右键点击项目并选择“属性”。以下截图展示了动态基址选项的位置:
一旦禁用此选项,只需点击“构建”或“全部构建”即可。
然而,在 Linux 上,我们可以通过在终端输入以下命令之一,像往常一样构建可执行文件(现在先忽略警告):
*# As we are interested in 32-bit executable*
*# on a 32-bit platform we will type:*
gcc -o legacy legacy.c
*# and on a 64-bit platform we will type:*
gcc -o legacy legacy.c -m32
在本章中,我们将首先修补 Windows 可执行文件,然后继续修补 Linux 可执行文件,并查看如何在 ELF 的情况下解决问题。哦,最重要的是;忘记 C 源代码,假装我们没有它们。
问题
无论我们尝试在 Windows 还是 Linux 上运行我们的可执行文件,都几乎不会发现任何问题,因为程序会要求输入我们的名字并将其打印出来。只要程序没有遇到超过 127 个 ASCII 字符的名字(第 128 个字符是结束的 NULL 值),这种方式将稳定工作,然而,确实存在这样的长名字。我们来试着运行这个可执行文件(我们指的是为 Windows 构建的那个,但相同的原理也适用于 Linux 可执行文件),并输入一长串文本,远远超过 127 个字符。结果会是这样:
这个消息的原因是 gets() 函数。如果 C 不是你首选的语言,你可能不知道这个函数不会检查输入的长度,这可能导致堆栈破坏(至少像前面那条消息的出现一样),在最坏的情况下,这也是一个漏洞,容易受到精心制作的攻击。幸运的是,解决 gets() 问题的方法非常简单;必须将对 gets() 的调用替换为对 fgets() 函数的调用。如果我们有源代码,这将是一个一分钟的修复,但我们没有(至少我们假装没有它们)。
然而,我们稍后实现的解决方案并不复杂。我们只需要一个反汇编器(最好是 IDA Pro)、一个十六进制编辑器,当然还有 Flat Assembler。
PE 文件
为了成功地实现补丁,我们需要了解 PE 文件格式(PE 代表便携式可执行文件)。虽然可以通过此 URL 获取详细的规格:www.microsoft.com/whdc/system/platform/firmware/PECOFF.mspx,但我们只需要了解格式的几个关键点,并能够手动解析其基本结构。
头文件
一个 PE 文件包含多个头部,第一个我们遇到的是 DOS 头部,它仅包含对我们有用的两个内容;第一个是MZ签名,第二个是文件头的偏移量,也就是 PE 头(因为它之前有PE\x0\x0签名)。文件头包含关于文件的基本信息,例如节的数量。
紧随 PE 头之后的是可选头部,它包含更有趣的信息,如ImageBase——即图像(文件)应加载的首选地址——和NumberOfRvaAndSizes,后者对我们特别重要。NumberOfRvaAndSizes字段表示紧随可选头部之后的IMAGE_DATA_DIRECTORY条目数组中的条目数。IMAGE_DATA_DIRECTORY结构定义如下:
struct IMAGE_DATA_DIRECTORY
{
DWORD VirtualAddress;
DWORD Size;
}
每个结构描述了 PE 文件的特定区域。例如,Import IMAGE_DATA_DIRECTORY,我们特别感兴趣的那个,指的是关于文件中没有的函数的信息,而这些函数是从动态链接库中导入的。
接下来是一个IMAGE_SECTION_HEADER结构数组,其中描述了每个 PE 节(我们会得到节的文件偏移量和大小,以及它的虚拟地址和虚拟大小,即内存中的大小,通常与文件中的大小不同)。
尽管我强烈建议你阅读官方规格,我还建议下载并安装我见过的最好的十六进制编辑器——010 Editor(可以在www.sweetscape.com/010Editor/下载)。这个强大的应用程序除了支持 Windows、macOS 和 Linux 版本,还支持不同二进制格式的模板解析,并且有一个解析 PE 文件的模板。看看模板的输出——它使理解 PE 格式变得更加简单。以下是 010 Editor 中 PE 文件的显示方式:
导入
我们正在寻找的gets()函数是从ucrtbased.dll文件动态链接的,因此我们应该在导入表中查找它。使用 010 Editor 来查找并解析导入表,就像我们在下面的截图中看到的那样,并不困难:
尽管手动解析 PE 可执行文件可能是一个有趣的过程(事实上确实如此),但使用现有工具会更方便、更轻松。例如,IDA Pro 可以为我们完成所有繁琐的工作。
收集信息
将Legacy.exe文件加载到 IDA Pro 或任何你选择的反汇编工具中,我们将开始收集关于如何修补Legacy.exe文件的信息,并强制它使用fgets()代替gets()。
定位gets()调用
我们很幸运,因为在我们的案例中,只有一个对gets()的调用,而且我们知道它应该出现在调用printf的附近,而printf打印出Enter your name:这段字符串。然而,让我们看看 IDA Pro 中的 Strings 窗口:
在最坏的情况下,找到感兴趣的字符串只需要一秒钟,一旦找到,我们只需双击它,进入可执行文件的.rdata部分,在那里我们看到如下内容:
双击DATA XREF:会带我们到代码中字符串被访问的位置:
向下滚动五行,我们看到对j_gets的调用……你可能会问,为什么是j_gets?我们不是在寻找gets()函数的地址,而是跳转到它吗?当然,我们是在寻找gets();然而,由于可能有多个gets()的调用,编译器为此函数创建了一个单独的“调用中心”,这样任何其他调用gets()的代码实际上都会调用j_gets,然后被引导到实际的gets()函数地址,在导入表中。这就是我们在j_gets地址看到的内容:
现在,我们只需要注意call j_gets指令的地址,它是0x4117Ec。
为补丁做准备
不幸的是,我们不能简单地将调用重定向到fgets(),而不是j_gets,因为我们根本没有导入fgets()(因为我们在 C 源代码中没有使用它),而且由于gets()只接受一个参数(如我们在地址0x4117EB处看到的cdecl传递的参数),而fgets()需要三个参数。尝试在原地修补代码,以使其传递三个参数是不可能的,这样会损坏可执行文件并使其无法使用。这意味着我们需要为 shim 代码找到一个位置,该代码将添加两个额外的参数并实际调用fgets()(一旦我们将其添加为导入函数)。
幸运的是,对于我们来说,内存中的 PE 段(实际上,在文件中也是如此)占用的空间比它们的实际内容要大得多。在我们的例子中也是如此,我们需要找到.text段结束的位置;因此,首先我们查看下一个段的开始位置,如下图所示:
正如我们在前面的截图中看到的,下一个段是.rdata,其内容的开始已被高亮显示。一旦我们到达那里,我们开始向上滚动,直到看到非零或0xcc字节的内容,如下图所示:
我们看到实际内容的最后一个字节位于文件偏移 0x4196,因此从文件偏移 0x4197 开始有一些剩余空间;然而,从未对齐的地址开始执行一个过程似乎不太合适,所以我们决定从文件偏移 0x4198 开始。为了确保我们在正确的位置,让我们将这些字节与 IDA Pro 中看到的内容进行对比:
最终,我们看到字节相同,并且可以使用文件偏移 0x4198(虚拟地址 0x414d98)来放置我们的 shim 代码。
导入 fgets()
在我们开始实现补丁之前,我们还需要使可执行文件导入 fgets() 而不是 gets()。这看起来相当简单。让我们看看导入表中 gets() 函数的内容:
找到字符串后,我们可以安全地用 fgets 覆盖它。从以下截图可以看出,为什么在这种特定情况下覆盖是安全的:
前面的截图显示了 gets 被替换为 fgets。我们在这里再次幸运,因为从文件偏移 0x7EF0 开始的 gets 字符串并未以偶数边界结束,因此我们在 0x7EF5 处有一个额外的零,留出了足够的空间来将 gets 替换为 fgets,并且终止的 NULL 保持不变。
补丁调用
下一步将是补丁 gets() 的调用,并将其重定向到我们的 shim。由于我们只有一个 gets() 的调用(现在是一个带有无效参数数量的 fgets() 调用),我们将直接补丁这个调用。如果我们有多个 fgets() 调用,我们将补丁 jmp fgets 指令,而不是对每一个调用进行补丁。
正如我们之前所看到的,调用是相对于 EIP 的,因此我们需要计算一个新的偏移量,使其调用我们位于 0x414d98 的代码。公式相当简单:
new_offset = 0x414d98 - 0x4117EC - 5
这里,0x4117EC 是调用指令的地址,5 是其字节长度。我们需要使用该调用指令的长度,因为在执行时,EIP 已经指向紧接着调用后的指令。计算得到的偏移量为 0x35A7。
然而,在我们应用这个补丁之前,我们必须在十六进制编辑器中找到正确的位置,并使用一些字节表示这个调用指令以及后面的几个字节,如以下截图所示:
我们使用了 0xe8 0xf3 0xfa 0xff 0xff 0x83 0xc4 0x04 字节进行搜索。这样做时,必须确保这样的字节序列在搜索结果中只出现一次。这里的 0xe8 是调用指令,0xf3 0xfa 0xff 0xff 字节是下一条指令的偏移量——0xfffffaf3。以下截图展示了偏移补丁的应用:
偏移量被0x000035a7覆盖。现在,0x4117ec处的指令将调用我们的 Shim 代码。但我们仍然需要实现 Shim 代码。
Shim 代码
我们即将编写的代码看起来会与我们通常编写的代码略有不同,因为我们并不期望从中生成一个可执行文件;相反,我们将生成一个包含假定会加载到特定地址的 32 位过程的二进制文件,这也是我们将在patch.asm源文件的前两行中告诉编译器的内容:
*; Tell the assembler we are writing 32-bit code*
use32
*; Then specify the address where the procedure*
*; is expected to be loaded at*
org 0x414d98
然后,我们将定义两个标签,指向我们过程外的地址。幸运的是,Flat Assembler 允许我们在任意地址定义一个标签,像这样:
*; Assign label to the code where jump*
*; to fgets is performed*
label fgets at 0x414bd8
*; We will discuss this label in just a few seconds*
label __acrt_iob_func at 0x41b180
完成之后,我们就可以开始实现实际的 Shim 代码,作为一个常规的cdecl过程:
fgets_patch:
* ; Standard cdecl prolog*
push ebp
mov ebp, esp
*; Ooops... We need to pass a pointer to*
*; the stdin as one of the fgets' parameters,*
*; but we have no idea what this pointer is...*
Windows 上的标准 C 库实现提供了一个根据流的编号来确定指针的函数。这个函数是__iob_func(int)。幸运的是,我们的目标可执行文件正在从ucrtbased.dll中导入这个函数,正如我们在 IDA Pro 的 Imports 标签(或者在 010 Editor 中)看到的:
尽管名称有些不同(前面加了__acrt_),但这就是我们感兴趣的函数,它位于虚拟地址0x41b180。这也是我们几分钟前添加__acrt_iob_func标签的原因。访问这个地址后,我们可以看到在动态链接后,真正的__acrt_iob_func的地址会被放在那里:
为了调用这个外部函数以获取stdin流的指针,我们必须记住stdin的编号是0,并且导入的函数是间接调用的:
*; Get the stdin stream pointer*
push 0
call dword[__acrt_iob_func]
*; The result is in the EAX register*
*; Do not forget to fix the stack pointer*
*; after calling a cdecl procedure*
add esp, 4
现在,我们已经准备好将执行流转发到fgets(),我们这样做:
*; Forward the call to fgets()*
push eax *; stdin*
push 128 *; max input length*
push dword [ebp + 8] *; forward pointer to the*
* ; input buffer*
call fgets
add esp, 12
*; Standard cdecl epilog*
mov esp, ebp
pop ebp
ret
补丁的代码已经准备好。就这么简单(在这个特定的案例中)。编译这段代码会生成一个包含原始二进制代码的 35 字节二进制文件。这是十六进制编辑器中看到的代码:
应用补丁
在本章的准备补丁小节中,我们已经在十六进制编辑器中找到了补丁应用的位置,即文件偏移量0x4198。应用补丁非常简单——我们将patch.bin文件中的字节复制到可执行文件中的上述位置,并得到以下结果:
现在保存文件,我们就完成了。可执行文件已经打上补丁,从现在开始将使用fgets()代替gets()。我们可以通过运行可执行文件并输入一个非常长的字符串代替名字来检查这一点:
如我们所见,这种输入不再像fgets()那样导致错误,因为最多只会读取 127 个字符,从而保持了栈的安全性,我们在前面的截图中看到了结果;--输出被截断了。
复杂场景
我们刚刚经历了一个简单的 PE 可执行文件打补丁的场景;然而,现实生活中的情况很少如此简单,修改通常比简单地导入不同的函数复杂得多。在这种情况下,有没有办法静态地打补丁到可执行文件呢?当然有。实际上,不止一种方法。例如,可以对文件中的某个过程进行补丁,从而改变它实现的算法。然而,只有当现有过程占用了足够的空间来容纳新代码时,这种方法才可行。另一个选项是向 PE 文件中添加一个可执行部分,这个过程相当简单,值得在这里进行检查。整个过程包含五个简单的步骤(如果修改patch.asm文件算作第六步的话),我们将一一讲解。
准备补丁
这是最简单的一步,因为我们几乎不需要做任何操作。我们已经有一个工作中的补丁代码,唯一的重要区别是从汇编角度来看,代码将放置在内存中的位置。我们将在目标可执行文件的末尾添加一个新部分,因此,代码的加载地址(即Virtual Address)是通过将当前最后一部分的Virtual Address和Virtual Size相加,并将结果四舍五入到最接近的SectionAlignment的倍数来计算的。在我们的情况下,0x1D000 + 0x43C = 0x1d43C,四舍五入到0x1e000。然而,尽管它被称为虚拟地址,但实际上这个值是ImageBase的偏移量,而ImageBase是0x400000,因此真实的虚拟地址应为0x41e000。
简单来说,我们只需要修改patch.asm中的一行——第 2 行,将org 0x414d98改为org 0x41e000。其余代码保持不变。
调整文件头
由于我们打算将部分附加到一个可执行文件中,我们需要对其头部进行一些更改,以便它们能够反映新的实际情况。让我们在 010 编辑器或任何你喜欢的十六进制编辑器中打开Legacy.exe文件,并查看所有头部,在必要的地方进行修改。
在我们更新文件之前,我们必须根据FileAlignment和SectionAlignment的值分别决定文件中新部分的大小(SizeOfRawData)和内存中的大小(VirtualSize)。查看IMAGE_OPTIONAL_HEADER32结构中的这些值,我们发现FileAlignment的值是0x200,SectionAlignment的值是0x1000。由于我们要插入的新代码非常小(只有 35 字节),因此可以使用最小的大小,设定部分的SizeOfRawData = 0x200,VirtualSize = 0x1000。
然而,让我们一步步进行,作为第一步,调整IMAGE_FILE_HEADER下IMAGE_NT_HEADERS的NumberOfSections字段,如下图所示:
原本,文件有七个节,随着我们将增加另一个节,我们将WORD NumberOfSections的值更改为8h。
一旦更新了NumberOfSections字段,我们接着更新IMAGE_OPTIONAL_HEADER32头中的SizeOfImage字段(这是内存中可执行镜像的大小)。SizeOfImage字段的原始值是0x1E000,由于我们的新节应该占用0x1000字节的内存,我们简单地将SizeOfImage设置为0x1F000,如下面的截图所示:
现在进入一个更加有趣的部分——添加一个节头。节头位于IMAGE_DATA_DIRECTORY条目数组之后,在我们的例子中,位于文件偏移量0x1F0。最后一个节头(针对.rsrc节)位于文件偏移量0x2E0,我们将把我们的节头插入在其之后,起始于文件偏移量0x308。对于这个可执行文件,我们有足够的空闲字节,因此可以安全地继续。
节头的前八个字节包含节的名称,我们将节命名为.patch。关于节名称字段的一个有趣的事实是,名称不必以 0(NULL字符串终止符)结尾,并且可以占用所有八个字节。
接下来的四个字节是描述节的虚拟大小的整数(它在内存中将占用多少字节),如我们之前决定的,虚拟大小是0x1000字节(另一个有趣的事实是——我们可以将此字段设置为 0,它仍然能够正常工作)。
接下来的字段是一个四字节整数,描述节的VirtualAddress字段(该节应该被加载到哪里)。该字段的值是之前SizeOfImage字段的值,即0x1E000。
紧随VirtualAddress字段之后的是SizeOfRawData字段(也是 4 个字节),我们将其设置为0x200——即文件中新节的大小——以及
PointerToRawData,我们将其设置为文件之前的大小——0x8E00。
其余字段填充为零,除了最后一个字段Characteristics,我们将其设置为0x60000020,表示该节包含代码并且是可执行的。
你添加的节头应该像下图所示:
添加新节
还有两个步骤,首先是将实际的节数据追加到文件中。在十六进制编辑器中滚动文件到末尾,我们会看到第一个可用的文件偏移量是0x8e00,这正是我们设置的PointerToRawData字段的值。
我们应该将0x200字节附加到文件中,从而将其大小设置为0x9000,并用我们的代码填充这0x200字节的前 35 个字节,如下图所示:
只剩下最后一步,就可以实际运行可执行文件了,别犹豫了。
修复调用指令
剩下的工作就是修复call gets()指令,使其指向我们的新代码。我们使用相同的二进制字符串0xE8 0xF3 0xFA 0xFF 0xFF 0x83 0xC4 0x04来定位我们感兴趣的调用,并将0xF3 0xFA 0xFF 0xFF字节替换为0x0F 0xC8 0x00 0x00,这是从调用后的指令到我们新部分的精确偏移。以下截图准确地展示了这一过程:
最后,保存文件并尝试启动它。如果修补正确,你将看到与之前方法相同的结果。
ELF 可执行文件
修补 ELF 可执行文件比修补 PE 可执行文件要困难一些,因为 ELF 文件通常在其节区中没有空闲空间,因此我们只能选择添加一个节区,这不像 PE 文件那样简单,或者注入共享对象。
添加节区需要对 ELF 格式有很好的了解(可以在www.skyfree.org/linux/references/ELF_Format.pdf中找到相关规范),尽管这一内容非常有趣,但在本书的范围之外。最显著的问题是 ELF 可执行文件中节区和头部的排列方式,以及 Linux 如何处理 ELF 结构,这使得像我们在 PE 修补中那样附加数据变得非常困难。
另一方面,注入共享对象要简单得多,实施起来也容易,因此我们将采用这种方式。
LD_PRELOAD
LD_PRELOAD环境变量由 Linux 动态链接器/加载器ld.so使用,如果设置了它,变量中将包含一个共享对象列表,这些共享对象会在任何其他共享对象之前与可执行文件一起加载,包括libc.so。这意味着我们可以创建一个共享对象,导出一个名为gets的符号,并将这个共享对象指定给LD_PRELOAD,这样如果我们尝试运行的可执行文件导入了一个同名符号,我们的gets实现就会被链接,而不是之后加载的libc.so中的实现。
一个共享对象
现在,我们将实现我们自己的gets()过程,它实际上会将调用转发给fgets(),就像我们之前修补 PE 文件时做的那样。不幸的是,Flat Assembler 对 ELF 的支持目前还无法让我们简单地创建共享对象;因此,我们将创建一个目标文件,并稍后使用 GCC 将其作为 32 位系统的共享对象进行链接。
源代码通常非常简单直观:
*; First the formatter directive to tell*
*; the assembler to generate ELF object file*
format ELF
*; We want to export our procedure under*
*; the name "gets"*
public gets as 'gets'
*; And we need the following symbols to be*
*; imported from libc*
*; As you may notice, unlike Windows, the*
*; "stdin" is exported by libc*
extrn fgets
extrn stdin
*; As we want to create a shared object*
*; we better create our own PLT (Procedure*
*; Linkage Table)*
section '.idata' writeable
_fgets dd fgets
_stdin dd stdin
section '.text' executable
*; At last, the procedure*
gets:
*; Standard cdecl prolog*
push ebp
mov ebp, esp
*; Forward the call to fgets()*
mov eax, [_stdin]
push dword [eax] ; FILE*
push 127 ; len
push dword [ebp + 8] ; Buff*
call [_fgets]
add esp, 12
*; Standard cdecl epilog*
mov esp, ebp
pop ebp
ret
将前面的代码保存为 fgets_patch.asm,并使用 fasm 或 fasm.x64 编译;这将生成 fgets_patch.o 目标文件。将此目标文件构建为共享对象,方法就是在终端运行以下命令之一:
*# On a 32-bit system*
gcc -o fgets_patch.so fgets_patch.o -shared
*# and on a 64-bit system*
gcc -o fgets_patch.so fgets_patch.o -shared -m32
现在让我们在没有补丁的情况下测试并运行旧版可执行文件,并使用一个长字符串(140 字节)进行输入。结果如下:
如我们所见,栈被破坏,导致了段错误(无效的内存访问)。现在我们可以尝试运行相同的可执行文件,但将 LD_PRELOAD 环境变量设置为 "./fgets_patch.so",从而在启动 legacy 可执行文件时强制加载我们的共享对象。命令行将如下所示:
LD_PRELOAD=./fgets_patch.so ./legacy
这次,我们得到了预期的输出——被截断到 127 个字符——这意味着我们的 gets() 实现通过动态链接过程进行了链接:
总结
修改现有可执行代码和/或正在运行的进程是一个相当广泛的主题,十分难以在单一章节中涵盖,因为这个主题本身可能值得独立成书。然而,它与编程技术和操作系统的关系更为紧密,而我们试图专注于汇编语言。
本章几乎只是触及了所谓的二进制代码修改(即补丁)的冰山一角。目的在于展示这个过程是多么简单和有趣,而不是详细讨论每一种方法。然而,我们已经获得了一个大致的方向,当涉及到那些无法简单重建的代码修改时,应该去哪里。
代码分析的方法仅被表面性地涵盖,目的是为你提供一个大致的概念,应用程序补丁过程的大部分内容也是如此,因为重点是补丁的实现。我的个人建议是——去了解 Windows PE 可执行文件和目标文件格式规范,以及 Linux ELF。即使你永远不需要修改任何可执行文件,了解这些内容也能帮助你理解在高级语言编程时,底层发生了什么。
第十一章:哦,差点忘了
我们的旅程接近尾声。然而,需要明确的是,这本书仅仅涵盖了名为汇编语言编程的冰山一角,前方还有更多的内容等待你去学习。本书的主要目的是向你展示如何在汇编语言中创建强大而简便的软件,以及它的可移植性和便利性。
在本书的过程中,我们还有一些话题没有涉及,但这些话题仍然值得关注。一个这样的主题是我们如何防止我们的代码被偷偷窥探。我们将简要介绍如何通过 Flat Assembler 实现一些保护代码的方法,而不需要第三方软件的支持。
另一个在我看来很有趣且值得探讨的话题是如何编写可以在内核空间中执行的代码。我们将为 Linux 实现一个小型可加载的内核模块。
保护代码
有很多书籍、文章和博客帖子讨论如何更好地保护代码。它们中的一些确实有用且实际;然而,大多数都是专门针对某些第三方工具或它们的组合。我们不会对这些内容进行评审,无论是书籍还是工具。相反,我们将看看我们自己能利用现有工具做些什么。
首先,我们必须接受一个事实,那就是没有任何东西能为我们的代码提供 100%的保护。不管我们做什么,只要我们的代码越有价值,就越可能被逆向工程。我们可以使用打包工具、保护工具以及任何其他我们能想到的工具,但最终它们都是众所周知的,并且总有一种方法可以绕过它们。因此,最终的防线就是代码本身。更准确地说,就是代码呈现给潜在攻击者的方式。这就是混淆的作用所在。
混淆这个词的字典定义是使某物变得模糊、不清楚或难以理解。它可能是一种非常强大的技术,无论是与其他方法结合使用还是单独使用。我曾有机会逆向工程一个广泛使用加密的程序。这个程序没有使用任何第三方工具进行保护,而是采用了一种非常巧妙且模糊(乍一看)的比特操作,我不得不承认——这比如果使用像Themida这样的工具,逆向工程会更加困难。
在本章的这一部分,我们将看到一个混淆的简单示例,通过稍微增强我们为 Windows 可执行文件使用gets()时做的补丁。由于混淆不是本书的主要话题;我们不会深入细节,而是展示一些简单而微小的改变是如何使理解代码的基本逻辑变得稍微困难,而不必在调试器中动态观察它。
原始代码
让我们先快速浏览一下我们作为补丁一部分植入到可执行文件中的原始代码。代码非常简单,考虑到我们已经了解的内容,阅读起来很容易:
*; First of all we tell the assembler*
*; that this is a 32-bit code*
use32
*; Tell the assembler that we are expecting*
*; this code to appear at 0x41e000*
org 0x41e000
*; Define labels for "external" procedures*
*; we are about to use*
label fgets at 0x414bd8
label __acrt_iob_func at 0x41b180
*; Implement the procedure*
fgets_patch:
*; We begin the procedure with the standard*
*; prolog for cdecl calling convention*
push ebp
mov ebp, esp
*; As we need the pointer to the stdin stream*
*; we call the __acrt_iob_func procedure*
push 0 *; This is the number of the stream*
call dword[__acrt_iob_func]
add esp, 4 *; Restore the stack pointer
; Forward the parameter (char*) and
; invoke fgets()* push eax *; Contains pointer to the stdin stream*
push 128 *; Maximum input length*
push dword[ebp + 8] *; Pointer to the receiving buffer*
call fgets
add esp, 4 * 3 *; Restore the stack pointer
; Standard epilog for procedures using cdecl
; calling convention* mov esp, ebp
pop ebp
ret
代码相当简单,而且很难从中找到任何有价值的保护内容。鉴于这种情况,我们将使用这个例子来展示如何简单地用其他指令实现call指令,使得它既不指向被调用的函数,也完全不像一个过程调用。
调用
有几种方法可以用一系列指令替换call指令,这些指令会执行完全相同的操作,但会被反编译器以不同的方式处理。例如,以下代码将完全执行call指令的功能:
*; Preceding code*
push .return_address *; Push the return address on stack*
push .callee *; Redirect the execution flow to*
ret *; callee*
.return_address:
*; the rest of the code*
我们也可以替换以下序列:
push callee
ret
例如:
lea eax, [callee]
jmp eax
这样仍然会产生相同的结果。然而,我们希望我们的混淆更强一些;因此,我们继续并创建一个宏。
调用混淆宏
在开始混淆call指令之前,我们将定义一个名为random的实用宏:
*; The %t below stands for the current*
*; timestamp (at the compile time)*
random_seed = %t
*; This macro sets 'r' to current random_seed*
macro random r
{
random_seed = ((random_seed *\
214013 +\
2531011) shr 16) and 0xffffffff
r = random_seed
}
random宏生成一个伪随机整数,并将其返回到参数变量中。我们需要这一小段随机化代码来为我们的call实现添加一些多样性。该宏本身(我们称之为f_call)使用了 EAX 寄存器;因此,我们要么在调用f_call之前保存这个寄存器,要么只在返回值存放在 EAX 寄存器的过程中使用该宏,因为否则寄存器中的值将会丢失。此外,由于它处理参数的方式,它仅适用于直接调用。
最后,我们来看一下宏本身。由于理解代码的最佳方式是查看代码,让我们深入了解这个宏:
*; This macro has a parameter - the label (address)*
*; of the procedure to call*
macro f_call callee
{
*; First we declare a few local labels*
*; We need them to be local as this macro may be*
*; used more than once in a procedure*
local .reference_addr,\
.out,\
.ret_addr,\
.z,\
.call
*; Now we need to calculate the reference address*
*; for all further address calculations*
call .call
.call:
add dword[esp], .reference_addr - .call
*; Now the address or the .reference_addr label*
*; is at [esp]*
*; Jump to the .reference_addr*
ret
*; Add some randomness*
random .z
dd .z
*; The ret instruction above returns to this address*
.reference_addr:
*; Calculate the address of the callee:*
*; We load the previously generated random bytes into*
*; the .z compile time variable*
load .z dword from .reference_addr - 4
mov eax, [esp - 4] *; EAX now contains the address*
*; of the .reference_addr label*
mov eax, [eax - 4] *; And now it contains the four*
*; random bytes*
xor eax, callee xor .z *; EAX is set to the address of*
*; the callee*
*; We need to set up return address for the callee*
*; before we jump to it*
sub esp, 4 *; This may be written as*
*; 'add esp, -4' for a bit of*
*; additional obfuscation*
add dword[esp], .ret_addr - .reference_addr
*; Now the value stored on stack is the address of*
*; the .ret_addr label*
*; At last - jump to the callee*
jmp eax
*; Add even more randomness*
random .z
dd .z
random .z
dd .z
*; When the callee returns, it falls to this address*
.ret_addr:
*; However, we want to obfuscate further execution*
*; flow, so we add the following code, which sets*
*; the value still present on stack (address of the*
*; .ret_addr) to the address of the .out label*
sub dword[esp - 4], -(.out - .ret_addr)
sub esp, 4
ret
*; The above two lines are, in fact, an equivalent*
*; of 'jmp dword[esp - 4]'*
*; Some more randomness*
random .z
dd .z
.out:
}
如我们所见,这个混淆尝试并没有涉及复杂的计算,甚至代码仍然是可读且易于理解的,但让我们将patch_section.asm文件中的call fgets这一行替换为f_call fgets,重新编译并重新应用补丁到可执行文件。
新的补丁明显变大了——从 35 字节变成了 86 字节:
将这些字节复制并粘贴到Legacy.exe文件的0x8e00偏移位置,如下图所示:
运行可执行文件后,我们将获得与上一章相同的结果,因此在这一阶段没有明显的区别。不过,让我们来看一下代码在反汇编器中的样子:
我们不能说这里的代码被严重混淆了,但它应该能让你了解使用相对简单的宏与 Flat Assembler 配合时可以做些什么。前面的例子仍然可以通过一点努力读取,但应用更多的混淆技巧会让它变得几乎无法阅读,且在没有调试器的情况下几乎无法还原。
一点内核空间
直到现在,我们一直在处理用户空间的代码,编写小型应用程序。然而,在本章的这一部分,我们将为 Linux 实现一个小而简单的可加载内核模块(LKM)。
几年前,我参与了一个有趣的项目,目标是识别由某些内核模块处理的数据。由于我不仅无法访问内核源代码,还无法访问内核本身,更不用说这不是一个 Intel 平台,这个项目变得更加具有挑战性。我所知道的只有相关内核的版本,以及目标模块的名称和地址。
我经历了一个漫长而有趣的过程,直到我能够构建一个能够完成我需要的工作的 LKM。最终,我成功构建了一个用 C 编写的 LKM,但如果我不尝试用汇编语言编写一个,我就不会满足自己。这是一次难忘的经历,我必须承认。然而,一旦项目完成,我决定尝试在我的开发机器上实现一个简单的 LKM。由于第一个模块是为不同的平台编写的,且针对不同版本的内核,并且考虑到我决定假装自己没有当前内核的源代码,我不得不进行几乎同样多的研究和逆向工程,即使我编写的是我自己系统的模块。
LKM 结构
让我来为你省去同样漫长的挖掘信息、逆向其他内核模块的结构和检查内核源代码的过程,以便弄清楚模块是如何加载的。相反,我们直接进入 LKM 的结构。
可加载内核模块实际上是一个 ELF 对象文件,带有一些额外的部分和一些信息,这些信息在用户空间创建的目标文件和可执行文件中通常是看不到的。我们应该指出至少五个通常在常规文件中没有的部分:
-
.init.text:这一部分包含模块初始化所需的所有代码。以 Windows 为例,这部分内容可以与DllMain()函数及其引用的所有函数进行比较。对于 Linux 来说,它可以被看作是一个包含构造函数的部分(Windows 可执行文件也可能包含该部分)。 -
.exit.text:这一部分包含在模块卸载之前需要执行的所有代码。 -
.modinfo:这一部分包含有关模块本身的信息、它所写的内核版本等。 -
.gnu.linkonce.this_module:此部分包含this_module结构体,后者包含模块的名称以及指向模块初始化和去初始化过程的指针。尽管对于我们来说,这个结构体本身有点模糊,但我们只对某些特定的偏移量感兴趣,在没有源代码的情况下,可以使用逆向工程工具(如 IDA Pro)找到这些偏移量。不过,我们仍然可以通过在终端中运行readelf命令,查看.init.text和.exit.text指针在结构体中的偏移量,方法如下:readelf- sr name_of_the_mofule.ko然后,我们看到输出中的偏移量:
如我们所见,指向
.init.text的指针位于偏移量0x150,而指向.exit.text的指针则位于this_module结构体的偏移量0x248处。 -
__versions:此部分包含外部符号的名称,并附带其版本号。内核使用该表格来验证相关模块的兼容性。
LKM 源代码
LKM 的结构并不神秘。它可以从 Linux 内核源代码中获取,这些源代码是公开的,因此我们无需进一步探究;相反,根据奥卡姆剃刀原则,让我们继续实现模块。
如前所述,LKM 是一个目标文件;因此,我们首先创建一个 lkm.asm 文件,并按如下方式输入我们的代码:
format ELF64 *; 64-bit ELF object file*
extrn printk *; We are going to use this symbol,*
*; exported by the kernel, in order to*
*; have an indication of the module being*
*; loaded without problems*
紧接着,我们可以开始创建 LKM 的各个部分。
.init.text
本部分包含成功初始化 LKM 所需的代码。在我们的情况下,由于我们没有为模块添加任何功能,它可以直接返回,但由于我们需要表示我们的 LKM 成功加载,因此我们将实现一个小过程,向系统日志中打印一条字符串:
section '.init.text' executable
module_init:
push rdi *; We are going to use this register*
mov rdi, str1 *; Load RDI with the address of the string*
*; we want to print to system log (we will*
*; add it to the data section in a few moments)*
xor eax, eax
call printk *; Write the string to the system log*
xor eax, eax *; Prepare return value*
pop rdi *; Restore the RDI register*
ret
相当简单,是不是?我们只需打印字符串并从该过程返回。
.exit.text
该部分的内容将更加简单(在我们这个具体情况下)。我们只需从过程返回:
section '.exit.text' executable
module_cleanup:
xor eax, eax
ret
由于我们没有分配任何资源,也没有加载任何模块或打开任何文件,因此我们直接返回 0。
.rodata.str1.1
这是一个只读数据部分,唯一需要放入其中的内容是我们将写入系统日志的字符串:
section '.rodata.str1.1'
str1 db '<0> Here I am, gentlemen!', 0x0a, 0
.modinfo
在本节中,我们需要提供关于我们模块的某些信息,例如许可证、依赖项,以及内核版本和支持的选项:
section '.modinfo'
*; It is possible to specify another license here,*
*; however, some kernel symbols would not be*
*; available for license other than GPL*
db 'license=GPL', 0
*; Our LKM has no dependencies, therefore, we leave*
*; this blank*
db 'depends=', 0
*; Version of the kernel and supported options*
db 'vermagic=3.16.0-4-amd64 SMP mod_unload modversions ', 0
如果你不确定应该指定什么作为 vermagic,你可以在 ''/lib/modules/uname -r/'' 目录中的任何模块上运行 modinfo 命令。例如,我在我的系统上运行以下命令:
/sbin/modinfo /lib/modules/`uname -r`/kernel/arch/x86/crypto/aesni-intel.ko
输出将如下截图所示:
一旦你获得这些信息,你可以简单地复制 vermagic 字符串并将其粘贴到你的代码中。
.gnu.linkonce.this_module
这里没有什么特别要说的。此部分只包含一个结构体--this_module,它大部分都填充为零(因为它在 LKM 加载器内部使用),除了三个字段:
-
模块名称
-
指向初始化过程的指针--
module_init -
指向反初始化过程的指针--
module_cleanup
在这个内核版本和 Linux 发行版中,这些字段分别位于偏移量0x18、0x150和0x248的位置;因此,代码将如下所示:
section '.gnu.linkonce.this_module' writeable
this_module:
*; Reserve 0x18 bytes*
rb 0x18
*; String representation of the name of the module*
db 'simple_module',0
*; Reserve bytes till the offset 0x150*
rb 0x150 - ($ - this_module)
*; The address of the module_init procedure*
dq module_init
*; Reserve bytes till the offset 0x248*
rb 0x248 - ($ - this_module)
*; The address of the module_cleanup procedure*
dq module_cleanup
dq 0
这就是我们在这一部分需要处理的全部内容。
__versions
本节中的信息通过版本号和名称描述外部符号,并由加载器使用,以确保内核和 LKM 使用相同版本的符号,从而避免出现任何意外。你可以尝试在没有此部分的情况下构建模块,甚至可能加载它,但不建议这样做。加载器会拒绝加载版本无效的模块,这告诉我们这些信息并非只是为了好玩,而是为了防止失败。
当时,我找不到关于如何获取某些符号版本号的可靠信息,但这可能是一个不错的变通办法,这对我们的小 LKM 足够用,方法是简单地查找以 8 字节版本值(在 32 位系统上为 4 字节)为前缀的符号名,如下截图所示:
我们的 LKM 只需要两个外部符号,分别是module_layout和printk。正如你在前面的截图中看到的,module_layout符号的版本是0x2AB9DBA5。采用相同的方法获取printk符号的版本号,我们得到了(在我的系统上是如此,但在你的系统上可能不同)0x27E1A049。
这些条目作为结构体数组存储,其中每个结构体包含两个字段:
-
版本号:这是 8 字节版本标识符(在 32 位系统上为 4 字节) -
符号名称:这是一个变长字符串(最多 56 字节),表示符号的名称
由于我们在讨论的是固定大小的字段,因此定义一个结构体是自然的;但是,由于我们不想为每个符号命名每一个结构体,我们将使用宏:
macro __version ver, name
{
local .version, .name
.version dq ver
.name db name, 0
.name_len = $ - .name
rb 56 - .name_len
}
定义了__version宏后,我们准备好方便地实现__versions部分:
section '__versions'
__version 0x2AB9DBA5, 'module_layout'
__version 0x27E1A049, 'printk'
就这样。保存文件,试着编译并加载它。
测试 LKM
测试模块比编写模块要简单得多。编译与通常的方式没有不同;我们只需使用 Flat Assembler 进行编译:
*# It is just the name of the output file that differs*
*# The extension would be 'ko' - **k**ernel **o**bject, instead*
*# of 'o' for regular **o**bject*
fasm lkm.asm lkm.ko
一旦我们的内核模块被编译完成,我们需要确保它已经设置了可执行属性,可以通过在终端运行chmod +x lkm.ko命令来完成。
为了将 LKM 加载到当前运行的内核中,我们使用以下方式的insmode命令:
sudo /sbin/insmode ./lkm.ko
除非 LKM 的格式存在严重问题(例如,符号版本无效),否则不会出现任何错误。如果一切顺利,可以尝试在终端中运行 dmesg 命令,像这样:
dmesg | tail -n 10
你应该能看到 "<0> Here I am, gentlemen!" 字符串出现在系统日志的末尾。如果该字符串没有出现,那么很可能你需要重启系统,但首先可以尝试通过在终端中运行 rmmod 命令卸载模块,像这样:
sudo /sbin/rmmod simple_module
如果一切顺利,我们现在应该能够使用纯汇编语言创建 Linux LKM(加载内核模块)。
总结
我们已经走了很长一段路。从英特尔架构的概述开始,我们经历了不同算法的实现,尽管为了便于理解,大多数算法进行了简化,最后我们实现了一个适用于 Linux 的可加载内核模块。
本章最后部分的目的是引起你对一些超出本书范围的话题的兴趣,因此这些话题无法得到足够的关注,但它们仍然在某种程度上很重要。尽管本章开始时给出的混淆方法相对简单,但它应该让你对如何使用 Flat Assembler 提供的基本工具——宏引擎,提出更复杂的混淆方案有一个大致的了解。
我们在本章的第二部分投入了一些时间来讲解内核编程,尽管我们实现的内核模块可能是最基础的一个,但我们已经展示了即便是内核开发这些许多人认为非常复杂的编程领域,即使从高级语言的角度来看,实际上也没有什么值得害怕的,尤其是从被称为汇编语言的坚硬岩石的巅峰来看。
到目前为止,你应该已经有了足够扎实的基础,可以轻松继续前进并提高你的汇编编程技能和能力,祝你在这个过程中好运。
谢谢!