20、汇编代码分析与逆向工程实践

汇编代码分析与逆向工程实践

1. 复制页面函数分析

在汇编代码中,有如下一段关于复制页面的代码:

stnp   x2, x3, [x0, #-256]
stnp   x4, x5, [x0, #16 - 256]
stnp   x6, x7, [x0, #32 - 256]
stnp   x8, x9, [x0, #48 - 256]
stnp   x10, x11, [x0, #64 - 256]
stnp   x12, x13, [x0, #80 - 256]
stnp   x14, x15, [x0, #96 - 256]
stnp   x16, x17, [x0, #112 - 256]
ret
SYM_FUNC_END(copy_page)
EXPORT_SYMBOL(copy_page)

这个函数每次通过加载16个64位(8字节)寄存器的数据来复制128字节。选择这种方式而非每次使用重复的LDP/STP指令复制16字节,主要有两个原因:
- 循环展开 :代码仅循环31次,减少了与循环相关指令的执行次数。
- 并行处理 :代码在STP指令之前完成所有LDP指令,这样指令流水线可以并行执行相当多的指令,因为数据在很久之后才会被使用。如果特定的ARM处理器具有较深的指令流水线,这将非常有帮助。

这个循环有些特别,它使用TST指令而非CMP指令来判断是否完成操作。TST与CMP类似,只是它使用ANDS而非SUBS进行比较。关于这个循环有以下几点说明:
- 它立即给X0加上256,然后目标指针必须使用负偏移来解引用值。这是因为起始地址在页面边界上,如果不先加一个值,测试会立即终止循环。加256而非128是因为第一组LDP在循环前完成,最后一组STP在循环后完成,这样能得到正确的迭代次数。
- 该例程使用了除一个寄存器外的所有可破坏寄存器,这意味着它不需要将任何寄存器压入或弹出堆栈。寄存器X19仍然可用,可以存储原始地址,这样我们可以使用CMP来测试何时到达页面末尾,或者将其用作常规计数器,这可能会使代码更易读,且无需额外开销。
- TST指令在代码中与使用其结果的B.NE指令距离较远,这可能会造成混淆,因为当看到B.NE时,不清楚是谁设置了条件标志。
- 它依赖于指针是页面对齐的(这是指定的)。
- 它使用1b作为标签,而不是更具描述性的标签。也许它曾经是一个宏,但目前它是一个函数,所以使用描述性标签是合适的。

2. 宏与内核选项

SYM_FUNC_START SYM_FUNC_END EXPORT_SYMBOL include/linux/linkage.h 中定义,它们包含GNU汇编器指令,以确保例程正确对齐且函数名是全局的。宏 alternative_if alternative_else_nop_endif arch/arm64/include/asm/alternative.h 中定义,它们提供了一种可配置的机制,用于根据给定处理器的具体特性来配置Linux内核。

如果ARM处理器具有内存预取功能,会包含如下指令:

prfm   pldl1strm, [x1, #128]

这条指令要求处理器将存储在该地址的数据加载到L1缓存中,目的是当执行LDP指令时,数据已经在缓存中,从而加快执行速度。字符串 pldl1strm 的含义如下:
- pld:预加载数据。
- l1:加载到L1缓存。
- strm:从指定地址开始流式传输数据,这也意味着数据只会使用一次,之后可以丢弃。

同样,例程使用STNP来存储寄存器对,该指令与STP相同,N是非临时提示,表示不再使用缓存值。处理器也可以将此作为一个提示,即附近的内存地址很快会被保存,如果这样有助于提高性能,它可以将内存操作批量处理。

3. GCC生成的代码

我们用C语言编写了一个大写转换例程,并使用 -O3 选项进行最大程度的优化,以比较GCC生成的汇编代码与我们手动编写的代码。以下是C代码实现:

#include <stdio.h>
int mytoupper(char *instr, char *outstr)
{
      char cur;
      char *orig_outstr = outstr;
      do
      {
            cur = *instr;
            if ((cur >= 'a') && (cur <='z'))
            {
                 cur = cur - ('a'-'A');
            }
            *outstr++ = cur;
            instr++;
      } while (cur != '\0');
      return( outstr - orig_outstr );
}

#define BUFFERSIZE 250
char *tstStr = "This is a test!";
char outStr[BUFFERSIZE];
int main()
{
      mytoupper(tstStr, outStr);
      printf("Input: %s\nOutput: %s\n", tstStr, outStr);
      return(0);
}

可以使用以下命令编译代码并查看生成的汇编代码:

gcc -O3 -o upper upper.c
objdump -d upper >od.txt

生成的汇编代码如下:

0000000000000690 <main>:
 690:    a9bf7bfd    stp    x29, x30, [sp, #-16]!
 694:    b0000080    adrp   x0, 11000  <__cxa_finalize 
@GLIBC_2.17>
 698:    90000082    adrp   x2, 10000 <__FRAME_END__+0xf588>
 69c:    910003fd    mov    x29, sp
 6a0:    f9401c01    ldr    x1, [x0, #56]
 6a4:    f947dc44    ldr    x4, [x2, #4024]
 6a8:    aa0103e5    mov    x5, x1
 6ac:    384014a0    ldrb   w0, [x5], #1
 6b0:    91000484    add    x4, x4, #0x1
 6b4:    51018403    sub    w3, w0, #0x61
 6b8:    51008006    sub    w6, w0, #0x20
 6bc:    12001c63    and    w3, w3, #0xff
 6c0:    7100647f    cmp    w3, #0x19
 6c4:    54000128    b.hi   6e8 <main+0x58>  // b.pmore
 6c8:    381ff086    sturb  w6, [x4, #-1]
 6cc:    91000484    add    x4, x4, #0x1
 6d0:    384014a0    ldrb   w0, [x5], #1
 6d4:    51018403    sub    w3, w0, #0x61
 6d8:    51008006    sub    w6, w0, #0x20
 6dc:    12001c63    and    w3, w3, #0xff
 6e0:    7100647f    cmp    w3, #0x19
 6e4:    54ffff29    b.ls   6c8 <main+0x38>  // b.plast
 6e8:    381ff080    sturb  w0, [x4, #-1]
 6ec:    35fffe00    cbnz   w0, 6ac <main+0x1c>
 6f0:    f947dc42    ldr    x2, [x2, #4024]
 6f4:    90000000    adrp   x0, 0 <_init-0x600>
 6f8:    91242000    add    x0, x0, #0x908
 6fc:    97ffffe1    bl     680 <printf@plt>
 700:    52800000    mov    w0, #0x0         // #0
 704:    a8c17bfd    ldp    x29, x30, [sp], #16
 708:    d65f03c0    ret

关于这段汇编代码,有以下几点需要注意:
- 编译器自动内联了 mytoupper 函数,就像我们的宏版本一样。 mytoupper 函数在列表的其他地方,以防它从另一个文件中被调用。
- 编译器了解范围优化并对范围进行了偏移,因此只需要进行一次比较。偏移通过 sub w3, w0, #0x61 实现。
- 编译器设置了一个栈帧,但没有使用它,因为所有变量都能放入可破坏寄存器中。因此,它只保存和恢复LR和FP寄存器。
- 编译器使用ADRP指令来加载指针的值。ADRP与ADR类似,但它加载到4K页面边界,这意味着它的范围比ADR更大,但对人类来说更难使用。编译器必须将其设置为页面边界,在这种情况下指向C运行时数据,然后使用繁琐的偏移来获取正确的数据,这对编译器有利,但对人类编程不太友好。
- 编译器使用了CBNZ指令,我们稍后会详细讨论。
- 代码中多次出现 and w3, w3, #0xff ,这是为了在C中保持类型正确性。C的char数据类型是无符号8位数字,当进行减法时可能会得到负数,导致W3的高8位被设置为1,这条指令将其纠正为无符号数。我们之前没有这样做,因为我们知道只会使用STRB将其保存为8位,所以无论高8位是什么都会被忽略。
- 编译器通过 sub w6, w0, #0x20 进行大小写转换,然后根据比较结果,根据是否需要转换来保存W6或W0。

总体而言,编译器对我们的代码进行了合理的编译,但有一些指令可以移除。这就是为什么许多汇编语言程序员从C代码开始,然后移除任何额外的指令。当C代码无法将所有变量放入寄存器,必须开始在堆栈和寄存器之间交换数据时,效率会降低,这种情况通常在复杂度较高且对速度要求较高时发生。

4. 使用CBNZ和CBZ指令

考虑以下一组指令:

SUB   W1, W1, #1
CMP   W1, #0
B.NE  mylabel

这是许多循环中的典型代码。我们可以通过使用SUBS指令来消除CMP指令:

SUBS   W1, W1, #1
B.NE   mylabel

另一种优化方法是使用CBNZ指令:

SUB   W1, W1, #1
CBNZ  W1, mylabel

CBNZ是比较并在非零时分支,它将W1与0进行比较,如果还不为0,则进行分支。并非所有指令都有像SUBS这样的S版本,在这种情况下可以使用CBNZ指令。CBZ则相反,当寄存器为0时进行分支。这是仅有的两种选择,没有针对其他条件标志的版本。编译器在生成代码时似乎没有使用SUBS指令,它本可以通过在SUB指令末尾加上S来消除CMP指令。

5. 逆向工程与Ghidra

在Linux世界中,大多数遇到的程序都是开源的,可以轻松下载源代码并进行研究,还有关于其工作原理的文档,并且鼓励用户为程序做出贡献,比如修复漏洞或添加新功能。但如果遇到一个没有源代码的程序,想了解它的工作原理,例如想研究它是否包含恶意软件,担心隐私问题想知道程序在互联网上发送了哪些信息,或者是一个游戏,想知道是否有可以进入无敌模式的秘密代码,该怎么办呢?

我们可以使用 objdump gdb 来检查任何Linux可执行文件的汇编代码,凭借对汇编的了解,我们可以理解遇到的指令。但这无法帮助我们形成程序结构的整体概念,而且非常耗时。

有一些工具可以帮助解决这个问题。直到最近,只有昂贵的商业产品可用,但美国国家安全局(NSA)发布了他们的黑客用于分析代码的工具版本,名为Ghidra,以哥斯拉对抗的三头怪物命名。这个工具可以分析编译后的程序,并具有将程序反编译回C代码的能力,还包括显示函数调用图的工具以及在学习过程中进行注释的功能。

可以从https://ghidra-sre.org/ 下载Ghidra。在Linux上安装时,先解压,然后运行 ghidraRun 脚本。Ghidra需要Java运行时环境,如果尚未安装,需要为操作系统安装。需要注意的是,Ghidra需要64位版本的Oracle Java。一些64位的Linux发行版安装的是32位版本的Java,如果在32位Java下运行Ghidra,在尝试反汇编代码时,反汇编器将无法运行。目前ARM没有64位版本的Java,所以需要在基于Intel或AMD的计算机上进行操作。

反编译一个经过优化的C程序是很困难的。如前所述,GCC优化器在将我们的原始代码转换为汇编语言时进行了大量重写。我们将之前用C编译的 upper 程序交给Ghidra进行反编译,看看结果是否与我们的原始源代码相似。具体步骤如下:
1. 在Ghidra中创建一个项目,导入 upper 程序,会出现一个信息对话框,显示 upper 可执行文件的高级信息。
2. 出现另一个包含更详细数据的信息窗口,点击OK进入主窗口。
3. 右键单击 upper 可执行文件,选择“使用默认工具打开”,这将打开代码分析窗口。当询问是否要分析代码时,点击Yes,并在接下来的提示中接受默认设置。点击符号树中的 main 以显示源代码。

Ghidra生成的C代码如下:

#include <stdio.h>
#define BUFFERSIZE 250
char *tstStr = "This is a test!";
char outStr[BUFFERSIZE];
typedef unsigned char byte;
#define true 1
int main(void)
{
  char cVar1;
  char *pcVar2;
  char *pcVar3;
  char *pcVar4;
  char *pcVar5;
  pcVar2 = tstStr;
  pcVar3 = outStr;
  pcVar5 = tstStr;
  do {
    cVar1 = *pcVar5;
    pcVar4 = pcVar3;
    while( true ) {
      pcVar3 = pcVar4 + 1;
      pcVar5 = pcVar5 + 1;
      if (0x19 < (byte)(cVar1 + 0x9fU)) break;
      *pcVar4 = cVar1 + -0x20;
      cVar1 = *pcVar5;
      pcVar4 = pcVar3;
    }
    *pcVar4 = cVar1;
  } while (cVar1 != '\0');
  printf("Input: %s\nOutput: %s\n",pcVar2,outStr);
  return 0;
}
  1. 运行该程序,预期输出如下:
smist08@kali:~/asm64/Chapter 15$ make
gcc -O3 -o upperghidra upperghidra.c
smist08@kali:~/asm64/Chapter 15$ ./upperghidra
Input: This is a test!
Output: THIS IS A TEST!
smist08@kali:~/asm64/Chapter 15$

生成的代码并不美观,变量名是自动生成的。它知道 tstStr outStr ,因为它们是全局变量。逻辑被拆分成更小的步骤,通常每个C语句相当于一条汇编指令。当试图弄清楚一个没有源代码的程序时,从多个不同的角度看问题会很有帮助。需要注意的是,这种技术只适用于像C、Fortran或C++这样的真正编译型语言,不适用于像Python或JavaScript这样的解释型语言,也不适用于像Java或C#这样使用虚拟机架构的部分编译型语言。对于这些语言,有其他工具,而且通常这些工具更有效,因为编译步骤的处理相对较少。

6. 总结与练习

通过对汇编代码的分析和逆向工程实践,我们了解了Linux内核和GCC运行时库中的一些汇编源代码示例,分析了Linux内核的 copy_page 函数的工作原理,比较了C编译器生成的汇编代码与我们手动编写的代码。还使用了强大的Ghidra程序进行反编译,虽然它能从汇编代码生成可运行的C代码,但代码可读性较差。

为了进一步巩固所学知识,我们可以完成以下练习:
1. 手动执行列表中执行循环的指令,确保理解其工作原理并确认迭代次数是否正确。
2. 查看位于 arch/arm64/lib 中的Linux内核库函数 memchr.S ,看看是否能轻松理解这段代码。
3. copy_page 例程相对简单,因为页面是保证对齐的。查看 arch/arm64/lib 中的 memcmp.S 文件,这个例程更复杂,因为它不假设对齐,但希望利用对齐带来的效率。它需要处理最初的未对齐字节、对齐的主要块以及任何剩余的字节,理解这个例程更具挑战性。
4. 重写某个版本的大写转换例程中的循环,使用CBNZ或CBZ指令作为主循环。
5. 编译Ghidra反汇编器生成的C代码,然后对输出运行 objdump ,并将其与原始汇编代码进行比较,看看是否符合预期。
6. 使用Ghidra检查 /usr/bin 中的一个较小的可执行文件,如 head ,尝试弄清楚它的工作原理并找到主代码块。

通过这些练习,我们可以更深入地理解汇编代码的工作原理,提高逆向工程和代码分析的能力。

汇编代码分析与逆向工程实践

7. 各代码模块的技术要点总结
代码模块 技术要点
复制页面函数 使用TST指令判断循环结束,依赖指针页面对齐,通过特定偏移量实现正确迭代次数,利用循环展开和并行处理提高效率
GCC生成代码 自动内联函数,进行范围优化,使用ADRP加载指针值,使用CBNZ指令,通过特定操作保持类型正确性
CBNZ和CBZ指令 CBNZ用于比较非零分支,CBZ用于比较零分支,可优化循环中的比较操作
Ghidra逆向工程 能将编译后的程序反编译回C代码,但生成代码可读性差,仅适用于部分编译型语言
8. 代码优化与性能提升思路

在前面的分析中,我们发现编译器生成的代码存在一些可以优化的地方。下面是一些代码优化和性能提升的思路:
- 寄存器使用优化 :在 copy_page 函数中,虽然使用了大部分可破坏寄存器,但可以更合理地利用剩余的寄存器(如X19),使代码更易读且减少额外开销。例如,可以用X19存储关键信息,避免复杂的偏移计算。
- 指令选择优化 :编译器在生成代码时未使用SUBS指令消除CMP指令,我们可以手动优化,使用CBNZ和CBZ指令替代部分比较和分支操作,减少指令数量,提高执行效率。
- 循环结构优化 :对于循环部分,如 mytoupper 函数的循环,可以考虑使用更高效的循环结构,减少不必要的嵌套和跳转,提高代码的执行速度。

9. 逆向工程流程

使用Ghidra进行逆向工程的流程如下:

graph LR
    A[下载并安装Ghidra] --> B[创建项目]
    B --> C[导入可执行文件]
    C --> D[查看信息对话框]
    D --> E[进入主窗口]
    E --> F[右键选择打开方式]
    F --> G[打开代码分析窗口]
    G --> H[确认分析代码并接受默认设置]
    H --> I[查看源代码]
10. 不同指令的性能对比

为了更直观地了解不同指令在性能上的差异,我们可以对比以下几种常见的循环控制指令组合:
|指令组合|指令数量|优点|缺点|
| ---- | ---- | ---- | ---- |
|SUB + CMP + B.NE|3条|逻辑清晰,易于理解|指令数量相对较多,执行效率较低|
|SUBS + B.NE|2条|减少了CMP指令,提高了执行效率|部分指令可能没有S版本,适用范围有限|
|SUB + CBNZ|2条|同样减少了CMP指令,且适用范围更广|对于习惯传统比较方式的开发者来说,理解成本稍高|

11. 总结与展望

通过对汇编代码的深入分析和逆向工程实践,我们掌握了多种技术和工具的使用方法。从复制页面函数的特殊循环结构,到GCC生成代码的优化潜力,再到Ghidra的逆向工程能力,每个部分都让我们对程序的底层运行机制有了更深刻的理解。

在未来的开发和研究中,我们可以进一步探索以下方向:
- 更复杂程序的逆向分析 :尝试使用Ghidra对大型、复杂的程序进行逆向工程,深入了解其内部逻辑和功能实现。
- 代码优化的自动化 :研究如何开发自动化工具,对编译器生成的代码进行智能优化,减少手动优化的工作量。
- 跨平台的逆向工程 :探索在不同操作系统和硬件平台上进行逆向工程的方法和技巧,提高逆向工程的通用性。

通过不断地学习和实践,我们可以更好地应对各种编程和安全挑战,提升自己的技术水平和解决问题的能力。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值