应用逆向工程:加速汇编学习指南 [第一部分]
概述
本文引导读者系统学习x86指令集,解决学习汇编时不知从何入手的难题。我们将简要介绍指令格式,然后深入讲解具体指令。学习汇编就像学习另一门语言,初期可能难以理解,但只要坚持练习,阅读汇编列表将变得自然而然。您将能够通过简短代码片段解读功能块的作用。本文还可作为后续文章的参考,因为这里介绍的所有指令在逆向工程软件时经常遇到。如果忘记指令功能或兼容的操作数类型,可以回查本文或Intel SDM第二卷。
本文假设读者已有编译型编程语言经验。任何具有循环、比较等结构的语言都符合要求。分析的指令集是最流行的x86 ISA,所有示例都针对Intel或AMD处理器编写。让我们抓紧时间开始,内容很多…
简介
在继续之前,建议忘记通用寄存器及其用法的读者回顾"基本架构"文章。通用寄存器在加载/存储操作中频繁使用,将在各种示例中遇到。必须熟练掌握它们。请花点时间回顾通用寄存器部分,然后再继续。
微码与汇编
阅读汇编和底层开发参考资料时,常见问题是术语误用,特别是微码和机器码。微码被认为是机器码之上的抽象层。为便于理解,我们将要研究的机器码是x86指令集。所谓机器码之上的抽象,是指CPU主动将机器码(汇编指令)转换为微码供CPU执行。这样做有多重原因——主要是为了更容易创建具有向后兼容性的复杂处理单元。本文研究的是x86指令集,该指令集包含数千条指令,用于多种不同操作,有些用于加载和存储字符串或浮点值。这些指令被转换为微码并在CPU上执行,而不是明确定义执行路径。这保持了向后兼容性,并实现了更小更快的处理器。
区分这两者对技术准确性和理解都很重要。此外,微码和机器码并不总是1:1映射。但由于没有关于Intel或AMD微码的公开文档,很难推断微码与机器码的内部架构和映射。
例如,以popf指令为例。该指令将栈顶字弹出到EFLAGS寄存器。但在执行之前,它会检查EFLAGS寄存器中的某些位、当前特权级别和IO特权级别。这些操作不太可能塞进一条指令,其微码可能不是单条指令。它必须检查EFLAGS、当前特权级别等,然后获取栈顶字。您可能看到的是该指令转换时执行的多个微操作。
注意:微码是机器码之上的抽象层。机器码是这些微操作的高级表示。
指令简化
本节不会详细分解x86指令的完整格式(Intel SDM第二卷有专门章节),但需要了解基本格式。
汇编指令大小各异(字面意思),但遵循相似形状。格式通常是指令前缀、操作码和操作数。可能并不总是有指令前缀(将来会介绍),但只要指令有效且受支持,就总是有操作码。这些操作码映射到指令集中的特定指令,有些指令有多个操作码,根据操作的操作数而变化。例如,逻辑AND指令有一个操作码,使用rax寄存器的低字节al,并对8位立即值执行逻辑AND。立即数只是数值。下面是操作码和指令助记符的简单摘要。
|
|
这也是一个新术语——助记符。在汇编中,助记符是识别指令的简单方法。它比阅读十六进制转储、确定指令边界然后手动将操作码翻译成人类可读形式要好。这些助记符让系统程序员、硬件工程师和我们这样的逆向工程师能够相对轻松地阅读和理解指令序列的功能。在上例中,逻辑AND操作的助记符是AND,后跟op1和op2——操作数。
注意:发音是"nehmonik",不是"memnomic"。可能只有我这么笨,难以正确发音。
所有指令都遵循这种通用格式。如果需要详细技术细节,请查阅Intel SDM。否则,您已具备足够知识开始学习和消化在此旅程中将遇到的指令。我们将从基础开始,逐步提高指令难度。如果在阅读任何部分时遇到困难,请在Twitter上留言或评论,我会尽力解答。
算术运算
本节介绍简单的算术指令,如加、减、除、乘和取模。然后稍微提升难度,介绍指针算术以及如何用汇编修改指针。
简单数学
执行数学表达式时,通常分解为逻辑等效块。以((2 + 4) * 6)为例——该表达式将2加4,然后将结果乘以6。在C语言中可以用单行完成,但在汇编中会分解为若干加载和存储指令,然后是指令加法和乘法。如我所言,逻辑等效块。我构建了几个具有逐渐复杂表达式的示例,并提供了它们的C和汇编列表。
|
|
这个函数相当简单。我使用__declspec(noinline)修饰符告诉编译器永远不要内联此特定函数。主要是为了获取与该函数相关的汇编,而不让其他指令污染示例。我们还使用volatile防止本地存储被优化掉,然后将变量设置为随机值。那么在汇编中会是什么样子?
|
|
首先为栈上的溢出空间(先前称为影子存储)和本地存储分配空间。溢出空间只需要32字节,然后为3个本地变量分配12字节。
为什么栈分配56字节存储而不是44字节?
根据System V AMD64 ABI的定义,栈必须始终16字节对齐,其中N模16 = 8。44模16是12。栈未对齐,因此必须分配足够空间到下一个16字节边界,在栈上额外添加4字节。但这仍然没有正确对齐,因为48模16是0。通过向分配添加额外8字节解决,确保栈根据N模16 = 8规则对齐。如果在此函数中进行任何WinAPI调用,将使用未对齐栈调用函数,很可能破坏执行。
分配栈空间后,注意到xor指令的操作数是相同的32位寄存器eax。这是清零寄存器的简单方法,因为任何数字与自身异或都是0。现在到了记住栈文章信息的部分。我们看到三条与源代码1:1对应的指令。但在继续之前有些细节需要提及。mov指令被认为是加载/存储指令,其中第一个操作数是目标,第二个操作数——此处是eax——是要存储的值。看到的括号包裹[rsp+offset]表示内存访问。可以认为[eax]表示访问地址eax处的内容。最简单的方式是像在汇编中解引用指针。
|
|
您可能还想知道偏移20h的含义。20h是从栈顶到该变量存储地址的偏移。如果查看此应用程序的栈,将如下所示。
栈图显示调用者返回地址首先压入栈,然后为本地存储和对齐填充元素分配空间。记住执行填充是因为地址必须16字节对齐,因此填充元素被赋予栈空间,因为所有其他地址值都不是16字节对齐。但18h(24)呢?24模16是8,遵循规则,但我们尚未为溢出空间分配存储。分配溢出空间存储后,我们不再对齐,需要添加填充元素。您可能还注意到x和y在同一栈元素中,因为这些分配是8字节大小,变量是4字节大小。这意味着可以将x和y变量放入栈上的一个存储点。z变量也是如此。您会注意到先是填充然后是z存储,这只是我想展示的方式,因为[rsp+28h]的高32位是0,低32位是z的值。
深入理解很重要! 如果想知道为什么对此特定示例如此详细,是因为我想以最详细的方式介绍,以便在将来的示例中您能很好地阅读和理解。这可能是最长的部分,因为汇编初期有很多内容要介绍。一旦向前推进,其他示例将只是理解指令细微差别的问题。
让我们继续并重新查看汇编示例。
|
|
现在知道mov [rsp+20h], eax指令正在清零x分配的存储。y和z也是如此,只是从rsp的偏移不同。可以看到y在[rsp+24h],z在[rsp+28h]被设置为0。之后的几行是源代码中预设值的存储。您可能注意到mov与上次略有不同,使用了某种说明符:dword ptr。dword ptr说明符简单表示目标操作数是32位大小;双字大小。这将只写入栈元素的低32位。这也使我们能够在两个32位变量之间共享栈元素。接下来的两条指令现在很容易理解。
将值存储到适当的栈元素后,将这些元素加载到寄存器中用于计算。
寄存器与内存访问 处理器的内存访问执行缓慢,因为指令生成必须由MMU转换为物理内存地址的虚拟地址,然后处理器必须使用此转换地址访问主内存。这就是为什么拥有与CPU关联的缓存层次结构有益,但使用芯片上的CPU寄存器比访问主内存快几个数量级。编译器通常倾向于在执行计算时使用寄存器以 favor 执行速度。
现在知道x加载到eax,y加载到edx,然后立即遇到add指令,操作数分别是eax和edx。add指令取第二个操作数加到第一个。此时,将执行:
|
|
很简单。下一行看到z加载到ecx,然后执行imul,eax作为第一个操作数,ecx作为第二个。该指令取第二个操作数乘以第一个,结果存储在第一个操作数。这将转换为:
|
|
原始源代码在return语句中执行所有这些操作。这有些奇特,因为我们知道它返回整数,但如何返回?通过使用eax。通用寄存器rax是返回值寄存器。这意味着如果使用System V AMD64 ABI向调用者返回任何内容,返回值将存储在rax中。不同架构可能变化,但对Intel和AMD始终是rax。指令add rsp, 38h是我们回收为本地存储分配的栈空间的方法。这将调用函数的返回地址留在栈顶,意味着当最后指令retn执行时,rip将设置为该地址,处理器将跳转到该位置继续执行。
这就是此函数的全部内容。随着我们继续接下来的四千五百万条指令,我将只处理无法轻易推导的细节并解释新行为。我们对第一个示例涵盖了很多内容,但随着推进将使生活轻松得多。接下来的部分将快速进行,但务必注意特殊情况和附加信息对话框。完全理解此内容很重要。
操作顺序 计算数学表达式时,遵循一组规则以获得正确结果。如果上过数学课,遇到过操作顺序信息。此时,括号包围我们想首先求解的表达式,意味着它首先计算。编译器考虑这一点,否则会得到不正确结果。如果从提供的源代码中删除括号,imul将在add指令之前执行。PEMDAS。记住这一点。
指针算术
如果写过C或C++,可能自己做过一些指针算术。在高级别有时令人困惑,当剥离高级语言的抽象时肯定令人困惑。在本小节中,我们将看两个在不同数据结构上执行指针算术的示例:数组和链表。如前所述,只在此小节和其他小节中处理重要或新信息,如果难以记住某些内容,请参考上文。如果现在未提及,我之前已提及。我们将从C中的另一个示例开始,这只是数组访问在汇编中的样子。
|
|
这个示例相当直接。汇编呢?没那么直接。
|
|
立即注意到与上一个示例相比复杂性显著不同。我们想先处理困难内容,所以为什么不呢?您可能根据先前经验猜测第一条指令的作用。如果计算确定栈分配的正确大小,值有意义。溢出空间是四个8字节元素,数组是10元素所以10 * 8 = 80,80 + 32 = 112字节,112模16 = 0,需要对齐所以添加8字节,得到120或78h。120模16 = 8!没问题。
处理复杂反汇编或未知反汇编的最佳方法是一次一行,并将相似操作分组。看下一条指令,看到pxor。该指令是像m128i这样的SIMD结构的逻辑异或。其作用与先前实例相同,但将16字节寄存器xmm0清零。XMM寄存器是随着SIMD指令出现而添加的其他CPU寄存器。它们是128位(16字节)SIMD浮点寄存器,命名为XMM0到XMM15。可以在推荐阅读部分阅读更多关于它们的信息。您可能想知道当我们尚未执行任何浮点操作或 anywhere 使用SSE时,为什么使用这些寄存器。使用这些寄存器是因为编译器希望产生最高性能代码并优化了我们的函数。您会注意到movdqu指令,猜对了,将xmm0的值加载到该栈位置。xmmword ptr说明符的使用与先前示例类似,告诉处理器我们将对[rsp+20h]处的16字节数据执行写入。这5条指令的序列是将分配的栈空间快速初始化为0的方法。思考一下:70h – 20h是50h,十进制是80字节,数组是10元素每个8字节大小,因此此序列是清零内存的快捷方式。如果因为看到60h而不是70h感到困惑,请记住这是在[rsp+60h]到[rsp+(60h + 10h)]写入零,其中10h是16字节,因为那是xmm寄存器的大小。这意味着直到70h的所有内容都是零!
继续注意到对[rsp+70h]的内存访问并将其初始化为0,随后是将[rsp+70h]移动到eax的mov。关于此指令序列及其与示例的关系,我们知道什么?首先应注意它使用eax而不是rax(eax的64位对应物)。我们在哪里使用32位变量?在第一个for循环中作为迭代器!如果之后看,注意到有cmp指令。cmp指令是比较指令,将第一个操作数与第二个比较。它设置RFLAGS寄存器的某些位以指示结果。我们将在下一节更详细地介绍。现在,只知道它与0Ah比较。这感觉熟悉…我们的