汇编代码分析与逆向工程实践
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进行比较。关于这个循环有以下几点:
1. 立即给X0加上256,目的指针必须使用负偏移来解引用值。这是因为起始地址在页面边界上,如果不先加一个值,测试会立即终止循环。加256而非128是因为第一组LDP在循环前完成,最后一组STP在循环后完成,这样能得到正确的迭代次数。
2. 该例程使用了除一个之外的所有可破坏寄存器,这意味着不需要在栈上压入或弹出任何寄存器。寄存器X19仍可使用,可用于存储原始地址,以便用CMP测试何时到达页面末尾,或者用作普通计数器,这样可能会使代码更易读,且无需额外开销。
3. TST指令在代码中与使用其结果的B.NE指令距离较远,这可能会造成混淆,因为看到B.NE时,不清楚是谁设置了条件标志。
4. 它依赖于指针是页面对齐的(这是指定的要求)。
5. 使用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语言实现:
#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程序
upper
交给Ghidra进行反编译,步骤如下:
1. 在Ghidra中创建一个项目,导入
upper
程序,会出现一个信息对话框。
2. 出现另一个包含更详细数据的信息窗口,点击OK进入主窗口。
3. 右键点击
upper
可执行文件,选择“Open with default tool”,打开代码分析窗口。当询问是否要分析代码时点击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版本的大写转换程序,比较了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
,能否弄清楚它的工作原理并找到主代码块?
汇编代码分析与逆向工程实践(续)
5. 逆向工程与Ghidra(深入探讨)
5.1 Ghidra反编译代码的特点分析
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;
}
-
变量命名
:
cVar1、pcVar2等变量名没有明确的含义,难以直接看出其在程序中的作用。 -
逻辑结构
:代码中嵌套了多层循环和条件判断,使得逻辑变得复杂。例如,
while (true)循环的使用,增加了代码的理解难度。
为了更清晰地展示代码的执行流程,下面是一个mermaid流程图:
graph TD;
A[开始] --> B[初始化变量];
B --> C[进入do-while循环];
C --> D[获取当前字符];
D --> E[进入while (true)循环];
E --> F[更新指针];
F --> G{是否满足条件};
G -- 是 --> H[转换字符大小写];
H --> I[获取下一个字符];
I --> E;
G -- 否 --> J[保存当前字符];
J --> K{是否到达字符串末尾};
K -- 否 --> C;
K -- 是 --> L[输出结果];
L --> M[结束];
5.2 不同语言类型对Ghidra反编译的适用性
Ghidra的反编译技术主要适用于真正的编译型语言,如C、Fortran或C++。这是因为这些语言在编译过程中会将源代码转换为机器码,反编译工具可以根据机器码的特征还原出大致的源代码结构。
| 语言类型 | 是否适用Ghidra反编译 | 原因 |
|---|---|---|
| 编译型语言(C、Fortran、C++) | 是 | 编译过程将源代码转换为机器码,可根据机器码特征还原 |
| 解释型语言(Python、JavaScript) | 否 | 代码由解释器逐行执行,没有明确的机器码对应关系 |
| 部分编译型语言(Java、C#) | 否 | 使用虚拟机架构,编译过程生成中间字节码,与机器码差异较大 |
对于解释型语言和部分编译型语言,有其他更适合的工具。例如,Python有
dis
模块可以进行字节码分析,Java有
javap
工具可以反汇编字节码。
6. 总结与练习
6.1 总结
本文全面探讨了汇编代码分析和逆向工程的相关内容。从Linux内核的
copy_page
函数入手,分析了其汇编代码的实现原理和优化策略,包括循环展开和并行处理。接着,研究了GCC生成的汇编代码,了解了编译器在代码优化和寄存器使用方面的特点。同时,介绍了CBNZ和CBZ指令的使用,以及它们在循环优化中的作用。最后,深入研究了Ghidra工具在逆向工程中的应用,虽然它能从汇编代码生成可运行的C代码,但代码的可读性有待提高。
6.2 练习解答与拓展
以下是对前面练习的进一步解答和拓展:
- 手动执行列表中执行循环的指令 :通过手动模拟指令的执行过程,可以深入理解循环的工作原理和迭代次数。在执行过程中,需要注意寄存器的值变化和内存的读写操作。
-
查看Linux内核库函数
memchr.S:该函数用于在内存块中查找指定字符的第一次出现位置。代码中可能会使用一些优化技巧,如寄存器的合理使用和循环的优化,需要仔细分析才能理解。 -
分析
memcmp.S文件 :该文件实现了内存比较功能,由于不假设内存对齐,需要处理非对齐字节和对齐主块的情况。可以通过绘制流程图和手动模拟执行,逐步理解其复杂的逻辑。 - 重写大写转换例程的循环 :使用CBNZ或CBZ指令可以简化循环结构,提高代码的执行效率。以下是一个示例:
#include <stdio.h>
int mytoupper(char *instr, char *outstr)
{
char cur;
char *orig_outstr = outstr;
while (*instr) {
cur = *instr;
if ((cur >= 'a') && (cur <= 'z')) {
cur = cur - ('a' - 'A');
}
*outstr++ = cur;
instr++;
}
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;
}
将上述代码中的循环部分重写为使用CBNZ指令:
// 伪代码示例
loop:
ldrb w0, [x0], #1
cbnz w0, process_char
b end_loop
process_char:
cmp w0, #'a'
blt not_lowercase
cmp w0, #'z'
bgt not_lowercase
sub w0, w0, #('a' - 'A')
not_lowercase:
strb w0, [x1], #1
b loop
end_loop:
ret
-
编译Ghidra反编译代码并比较
:编译Ghidra生成的C代码,然后使用
objdump工具查看生成的汇编代码,与原始汇编代码进行比较。可以发现,虽然功能相同,但代码的结构和指令的使用可能会有所不同。 -
在Ghidra中检查
/usr/bin中的可执行文件 :在Ghidra中打开head等可执行文件,通过查看函数调用图、反编译代码等方式,尝试理解其工作原理和找到主代码块。这需要对汇编语言和程序结构有一定的了解。
通过这些练习,可以加深对汇编代码分析和逆向工程的理解,提高编程和调试能力。在实际应用中,这些技能对于软件安全、漏洞分析和性能优化都具有重要意义。
超级会员免费看
748

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



