逆向工程实战:x86汇编语言速成指南(第一部分)

本文深入讲解x86指令集架构,涵盖算术运算、指针操作、条件分支和字符串处理等核心汇编概念,通过实际代码示例展示如何分析反汇编代码,为逆向工程打下坚实基础。

逆向工程实战:加速汇编学习 [第一部分]

概述

本文将引导您学习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 [操作码:24h | 指令:AND AL, imm8]

这也是一个新术语,助记符。在汇编中,助记符是识别指令的简单方法。它比阅读十六进制转储、确定指令边界然后手动将操作码翻译成人类可读形式要好。这些助记符是系统程序员、硬件工程师和我们这样的逆向工程师能够相对轻松地阅读和理解某些指令序列正在做什么的设备。在上面的示例中,逻辑AND操作的助记符是AND,后跟op1和op2——操作数。

注意:它的发音像nehmonik,而不是memnomic。也许我只是个白痴,是唯一一个难以正确发音的人。

所有指令都遵循这种一般格式。如果您想要详细的技术细节,则需要查阅Intel SDM。否则,您已经足够开始学习并消化在此旅程中将遇到的指令。我们将从基础开始,逐渐增加指令的难度。如果您在理解本文的任何部分时遇到困难,请在Twitter上给我留言或发表评论,我会尽力回答。

算术运算

在本节中,我们将介绍简单的算术指令,如加、减、除、乘和取模。之后,我们将稍微提升难度,介绍指针算术以及如何用汇编修改指针。

简单数学

当执行数学表达式时,它通常分解为逻辑等效的块。以((2 + 4) * 6)为例——该表达式将2加到4,然后将结果乘以6。该表达式可以在C语言中单行完成,但在汇编中,它将分解为一些加载和存储,然后是一条加法指令,再是一条乘法指令。就像我提到的,逻辑等效的块。我构建了一些具有逐渐更复杂表达式的示例,并提供了它们的C和汇编列表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
static __declspec( noinline ) uint32_t simple_math( void )
{
    volatile uint32_t x = 0;
    volatile uint32_t y = 0;
    volatile uint32_t z = 0;

    x = 4;
    y = 12;
    z = 7;

    return ( x + y ) * z;
}

这个函数相当简单。我使用__declspec( noinline )修饰符告诉编译器永远不要内联这个特定函数。我这样做主要是为了可以获取与该函数相关的汇编,而不会有其他指令污染示例。我们还看到使用volatile来防止本地存储被优化掉,然后将变量设置为随机值。那么在汇编中这会是什么样子?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
sub     rsp, 38h
xor     eax, eax
mov     [rsp+20h], eax
mov     [rsp+24h], eax
mov     [rsp+28h], eax
mov     dword ptr [rsp+20h], 4
mov     dword ptr [rsp+24h], 0Ch
mov     dword ptr [rsp+28h], 7
mov     eax, [rsp+20h]
mov     edx, [rsp+24h]
add     eax, edx
mov     ecx, [rsp+28h]
imul    eax, ecx
add     rsp, 38h
retn

这首先在栈上为我们的溢出空间(之前称为影子存储)和本地存储分配空间。溢出空间只需要32字节,然后为我们的3个本地变量分配12字节。

为什么栈分配了56字节存储而不是44字节? 根据System V AMD64 ABI的定义,我们的栈必须始终是16字节对齐的,其中N模16 = 8。44模16是12。栈未对齐,因此我们必须向栈添加额外的4字节以到达下一个16字节边界。然而,这仍然没有正确对齐,因为48模16是0。这通过向我们的分配添加额外的8字节来解决,以确保我们的栈根据N模16 = 8规则对齐。如果我们要在此函数中进行任何类型的WinAPI调用,它将使用未对齐的栈调用函数,很可能会破坏执行。

分配栈空间后,我们注意到一条xor指令,操作数是相同的32位寄存器eax。这是清零寄存器的简单方法,因为任何数字与自身异或都是0。现在到了记住栈文章信息会有用的部分。我们看到三条与源代码1:1的指令。不过在继续之前有一些细节要提及。mov指令被认为是加载/存储指令,其中第一个操作数是目标,第二个操作数——在这种情况下是eax——是要存储的值。您看到的包裹[rsp+offset]的大括号表示内存访问。您可以将其视为[eax]意味着访问地址eax处的内存内容。思考它的最简单方式就像在汇编中解引用指针。

1
*(cast*)(rsp+0x20) = eax

您可能还想知道偏移量20h是什么意思。20h是从栈顶到该变量存储所在地址的偏移量。如果我们要查看此应用程序的栈,它将如下面的图表所示。

在我们的栈空间分配之前,首先推入栈的是调用者的返回地址,然后为我们的本地存储和对齐填充元素分配空间。请记住,执行填充是因为地址必须16字节对齐,因此填充元素被赋予栈空间,因为所有其他地址值都不是16字节对齐的。但是18h(24)呢?24模16是8,因此遵循规则,然而,我们还没有为溢出空间分配存储。为溢出空间分配存储后,我们不再对齐,需要添加填充元素。您可能还注意到x和y在同一个栈元素中,这是因为这些分配是8字节大小,而我们的变量是4字节大小。这意味着我们可以将x和y变量放入栈上的一个存储点。我们的z变量也是如此。您会注意到它是填充然后是z存储,这只是我想展示的方式,因为[rsp+28h]的高32位是0,低32位是z的值。

深入理解很重要!

如果您想知道为什么对这个特定示例有所有细节,是因为我想以最详细的方式覆盖它,以便在未来的示例中,您具备良好的能力来阅读和理解它们。这可能是最长的部分,因为最初关于汇编有很多要覆盖。一旦我们向前推进,其他示例将只是理解指令细微差别的问题。

让我们继续并将汇编示例带回视图。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
sub     rsp, 38h
xor     eax, eax
mov     [rsp+20h], eax
mov     [rsp+24h], eax
mov     [rsp+28h], eax
mov     dword ptr [rsp+20h], 4
mov     dword ptr [rsp+24h], 0Ch
mov     dword ptr [rsp+28h], 7
mov     eax, [rsp+20h]
mov     edx, [rsp+24h]
add     eax, edx
mov     ecx, [rsp+28h]
imul    eax, ecx
add     rsp, 38h
retn

我们现在知道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指令取第二个操作数并将其加到第一个操作数。在这种情况下,它将执行:

1
x += y;

很简单。下一行我们看到z被加载到ecx,然后执行imul,eax作为第一个操作数,ecx作为第二个操作数。该指令取第二个操作数乘以第一个操作数,并将结果存储在第一个操作数中。这将转换为:

1
x *= z;

原始源代码在return语句中执行所有这些操作。这有些奇特,因为我们知道它返回一个整数,但是如何返回?通过使用eax。通用寄存器rax是返回值寄存器。这意味着如果任何东西要使用System V AMD64 ABI返回给调用者,返回值将存储在rax中。它可能随不同架构而变化,但对于Intel和AMD,它始终是rax。指令add rsp, 38h是我们回收为本地存储分配的栈空间的方法。这将调用函数的返回地址留在栈顶,这意味着当最后一条指令retn执行时,rip将被设置为该地址,处理器将跳转到该位置并继续执行。 这就是这个函数的全部内容。当我们继续接下来的四千五百万条指令时,我将只处理无法轻易推断的细节并解释新行为。我们为这第一个示例覆盖了很多内容,但随着我们向前推进,它将使生活变得容易得多。接下来的部分将快速进行,但请务必注意 quirks 和附加信息对话框。完全理解这些内容很重要。

操作顺序

在评估数学表达式时,遵循一组规则以获得正确结果。如果您上过数学课,您遇到过关于操作顺序的信息。在这种情况下,我们有用括号包围的第一个要解决的表达式,这意味着它首先被评估。编译器考虑到了这一点,否则您会得到不正确的结果。如果您从提供的源代码中移除括号,imul将在add指令之前发生。PEMDAS。请记住这一点。

指针算术

如果您用C或C++编写过代码,您可能自己做过一些指针算术。有时在高级别上会令人困惑,当剥离高级语言的抽象时,它肯定会令人困惑。在本小节中,我们将查看对两种不同数据结构执行的指针算术的两个示例:数组和链表。如前所述,只有重要或新信息将在本小节和其他小节中处理,因此如果您在记住某些内容时遇到困难,请参考上面的部分。如果现在没有提到,我之前已经提到过。我们将从C中的另一个示例开始,这只是数组访问在汇编中的样子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
static __declspec( noinline ) uint32_t pointers( void )
{
	uint64_t a[10];
	
    // looped access
    for ( volatile uint32_t it = 0; it < 10; it++ )
        a[ it ] = it + 2;

    // direct access
    a[ 0 ] = 1337;
    a[ 4 ] = 1995;
    
    // quik maffs
    *( uint64_t* ) ( a + 6 ) = 49;

    for ( volatile uint32_t it = 0; it < 10; it++ )
        printf( "%d\n", a[ it ] );

    return 0;
}

这个示例相当直接。汇编呢?没那么直接。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
                sub     rsp, 78h
                pxor    xmm0, xmm0
                movdqu  xmmword ptr [rsp+20h], xmm0
                movdqu  xmmword ptr [rsp+30h], xmm0
                movdqu  xmmword ptr [rsp+40h], xmm0
                movdqu  xmmword ptr [rsp+50h], xmm0
                movdqu  xmmword ptr [rsp+60h], xmm0
                mov     dword ptr [rsp+70h], 0
                mov     eax, [rsp+70h]
                cmp     eax, 0Ah
                jnb     short loc_140001084

loc_140001067:                          
                mov     eax, [rsp+70h]
                mov     edx, [rsp+70h]
                add     eax, 2
                mov     [rsp+rdx*8+20h], rax
                inc     dword ptr [rsp+70h]
                mov     ecx, [rsp+70h]
                cmp     ecx, 0Ah
                jb      short loc_140001067

loc_140001084:                          
                mov     qword ptr [rsp+20h], 539h
                mov     qword ptr [rsp+40h], 7CBh
                mov     dword ptr [rsp+74h], 0
                mov     eax, [rsp+74h]
                mov     qword ptr [rsp+50h], 31h
                cmp     eax, 0Ah
                jnb     short loc_1400010D2

loc_1400010B0:                         
                mov     eax, [rsp+74h]
                lea     rcx, aD         ; "%d\n"
                mov     rdx, [rsp+rax*8+20h]
                call    sub_1400010E0
                inc     dword ptr [rsp+74h]
                mov     eax, [rsp+74h]
                cmp     eax, 0Ah
                jb      short loc_1400010B0

loc_1400010D2:                       
                xor     eax, eax
                add     rsp, 78h
                retn

我们立即注意到与上一个示例相比复杂性有显著差异。我们想先处理困难的内容,所以为什么不呢?您可能可以根据先前的经验猜出第一条指令的作用。如果您进行计算以确定栈分配的正确大小,该值是有意义的。溢出空间是四个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说明符的使用与之前的示例类似,并

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计