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

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

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 练习解答与拓展

以下是对前面练习的进一步解答和拓展:

  1. 手动执行列表中执行循环的指令 :通过手动模拟指令的执行过程,可以深入理解循环的工作原理和迭代次数。在执行过程中,需要注意寄存器的值变化和内存的读写操作。
  2. 查看Linux内核库函数 memchr.S :该函数用于在内存块中查找指定字符的第一次出现位置。代码中可能会使用一些优化技巧,如寄存器的合理使用和循环的优化,需要仔细分析才能理解。
  3. 分析 memcmp.S 文件 :该文件实现了内存比较功能,由于不假设内存对齐,需要处理非对齐字节和对齐主块的情况。可以通过绘制流程图和手动模拟执行,逐步理解其复杂的逻辑。
  4. 重写大写转换例程的循环 :使用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
  1. 编译Ghidra反编译代码并比较 :编译Ghidra生成的C代码,然后使用 objdump 工具查看生成的汇编代码,与原始汇编代码进行比较。可以发现,虽然功能相同,但代码的结构和指令的使用可能会有所不同。
  2. 在Ghidra中检查 /usr/bin 中的可执行文件 :在Ghidra中打开 head 等可执行文件,通过查看函数调用图、反编译代码等方式,尝试理解其工作原理和找到主代码块。这需要对汇编语言和程序结构有一定的了解。

通过这些练习,可以加深对汇编代码分析和逆向工程的理解,提高编程和调试能力。在实际应用中,这些技能对于软件安全、漏洞分析和性能优化都具有重要意义。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值