汇编代码分析与逆向工程实践
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;
}
- 运行该程序,预期输出如下:
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对大型、复杂的程序进行逆向工程,深入了解其内部逻辑和功能实现。
-
代码优化的自动化
:研究如何开发自动化工具,对编译器生成的代码进行智能优化,减少手动优化的工作量。
-
跨平台的逆向工程
:探索在不同操作系统和硬件平台上进行逆向工程的方法和技巧,提高逆向工程的通用性。
通过不断地学习和实践,我们可以更好地应对各种编程和安全挑战,提升自己的技术水平和解决问题的能力。
超级会员免费看

被折叠的 条评论
为什么被折叠?



