前言
这个系列的文章,旨在记录我对破解的理解、经验与思考。
所谓破解,就是通过改变程序的运行机制与逻辑,来达到自己想要的目的。
为了达到这个目标,首要的知识便是,程序是如何运行的?为了简单,我们这次就使用一个最简单的C语言程序test.c
:
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h>
#define NUM 1
int main(int argc, char **argv) { printf("Hello World!\n"); for(int i = 0; i < 10; ++i) { printf("%d\n", NUM); } return 0; }
|
这次的环境是WSL的官方Arch Linux。本文参考了我本科时期CSAPP课的大作业“Hello的一生”。
硬盘到内存
我们首先来回顾一下,一个程序的物理运行过程。
粗糙来说,一个程序的运行过程要经历这样几个阶段——硬盘阶段、内存阶段和CPU阶段。
硬盘阶段
磁盘中存储的并不是什么1和0,而是高电平和低电平的信息。拿基础的磁盘来举例子,磁盘上的存储介质是磁粉,依靠磁粉颗粒的磁极指向来记录数据的。传统的机械磁盘,是由若干盘片组成的,排列成一个圆柱体的样式:

相比于内存,硬盘——或者说绝大多数大容量存储介质,如光盘等——容量更大,但是读取更慢。如果直接依靠硬盘和CPU进行交互,会导致硬盘的读取速度跟不上CPU的运行速度,拖慢整体的运行速度。
这个时候,我们就需要一个“中介”,来桥接硬盘和CPU之间的连接。要使用/即将使用的数据,就让它先被读取到内存中,然后通过内存与CPU沟通,效率很高。
读取的时候,CPU根据调用读取命令的指令,进行运算,得到信号,通过电路转化为硬盘能够理解的格式,将数据读入到内存中。
内存阶段
内存中分为若干个部分,有一些部分存储纯数据,有一部分存储指令,CPU在执行程序时,是按顺序读取指令进行执行的,内存中存储的依旧是高低电平信息,通过电路转换、输入到CPU的引脚,进行运算。
具体的内存寻址将在后续的文章中讲述——或许不会,用不到就不会写了。
CPU阶段
CPU的执行过程,分为取指、译码、执行、访存、写回五个步骤。
首先,CPU根据程序计数器(PC)向内存发送信号。PC中记录的是当前指令在内存中的的地址,当CPU将PC中的内容发送给内存时(这一步要借助MAR,不过本文隐去大多数无关痛痒的细节),内存通过数据总线将对应的值发送给CPU。这便是取指的过程。
其次,CPU要处理获取到的指令。通过电路,获取到操作数以及操作码,并生成控制信号,让CPU内部的其他部件做好准备。这便是译码。
之后,CPU要执行指令。执行指令分为三类:运算指令、访存指令和跳转指令。对于运算指令来说,CPU则将必要的信息交付给ALU,ALU根据电信号进行运算;对于访存指令来说,CPU计算出一个地址,为后续访存步骤做准备;对于跳转指令来说,则修改PC的值,回到取指。
如果要访存,则要向内存发送请求,通过若干内存管理机制——这一部分的细节略去——处理信息。
最后,要将结果写入对应的寄存器中,即为写回步骤。
现代CPU往往通过一些优化方式,进一步加快运算,比如流水线机制、超标量机制等,这里不再赘述。
编译为二进制
既然我们已经大致知道了底层的运行逻辑,那么来看一看顶层时如何运行的吧。我们写好了一个程序,存储到磁盘中,但是这是一个文本文件,我们还需要将它转化为CPU能理解的指令集,这一个过程便是编译。
预处理
编译的第一步是预处理,所需要的工具是cpp
。预处理的目的是对文本格式的程序,进行基本的文本层面的处理。简单来说,这一步会处理C语言程序中所有以#开头的代码,并且删去注释。
废话少说,我们来实际操作一番。首先,我们来预处理test.c
:
然后,我们来观察一下发生了什么:
1 2
| wc -l test.i cat test.i
|
通过统计行数,我们发现,文件的行数变为了975行。打开文件,我们发现文件的最后几行变为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| int main(int argc, char **argv) {
printf("Hello World!\n");
for(int i = 0; i < 10; ++i) {
printf("%d\n", 1);
}
return 0;
}
|
很显然,#define NUM 1
这一行被处理了,代码中所有出现NUM
的地方,都被替换为了1。除此之外,我们的注释也被删除了。
通过文件中其它的内容,比如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| extern char *ctermid (char *__s) __attribute__ ((__nothrow__ , __leaf__)) __attribute__ ((__access__ (__write_only__, 1))); # 931 "/usr/include/stdio.h" 3 4 extern void flockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) __attribute__ ((__nonnull__ (1)));
extern int ftrylockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) __attribute__ ((__nonnull__ (1)));
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__)) __attribute__ ((__nonnull__ (1))); # 949 "/usr/include/stdio.h" 3 4 extern int __uflow (FILE *); extern int __overflow (FILE *, int); # 973 "/usr/include/stdio.h" 3 4
|
我们可以发现,预处理对于头文件的处理实际上极其简单,就是将头文件的部分粘贴过来,做一些标记。我们看到的以#
开头的部分,叫做linemarker[1],从左到右依次为:行数、源文件名和标记。1表示一个文件的开始,2表示返回到某一文件,3表示后面的内容来自于系统文件,4表示后面的内容其实呈现在了一个隐性的extern "C"
块里(当然,这个其实并不是很重要)。
比如,我们可以:
1
| cat /usr/include/stdio.h | head -n 950
|
得到的结果是:
1 2
| extern int __uflow (FILE *); extern int __overflow (FILE *, int);
|
这也印证了我们之前所说的,有关于标记3的内容,以及行号。
我们再来尝试一个文件b.c
:
1 2 3 4 5
| #include "a.h"
int main() { return 0; }
|
a.h
的内容是:
最终的预处理结果是:
1 2 3 4 5 6 7 8 9 10 11 12
| # 0 "b.c" # 0 "<built-in>" # 0 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 0 "<command-line>" 2 # 1 "b.c" # 1 "a.h" 1 # 2 "b.c" 2
int main() { return 0; }
|
我们尤其要关注最后两行linemarker,分别声明了a.h
的结束和b.c
的开始,这表明,预处理是按照顺序来的。
编译
编译是一个将预处理后C语言代码转化为汇编语言的过程。
我们不妨尝试:
最后输出了这么一段结果:
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
| .file "test.c" .text .section .rodata .LC0: .string "Hello World!" .LC1: .string "%d\n" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $32, %rsp movl %edi, -20(%rbp) movq %rsi, -32(%rbp) leaq .LC0(%rip), %rax movq %rax, %rdi call puts@PLT jmp .L2 .L3: leaq .LC1(%rip), %rax movl $1, %esi movq %rax, %rdi movl $0, %eax call printf@PLT addl $1, -4(%rbp) .L2: cmpl $9, -4(%rbp) jle .L3 movl $0, %eax leave .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (GNU) 15.1.1 20250729" .section .note.GNU-stack,"",@progbits
|
这一段我们就触及到了CPU能理解的代码范围——汇编代码。我们一行一行慢慢解释,首先指定了编译的文件名称,然后依靠.text
声明代码段的开始,存储了一些只读信息和具体要执行的代码,随后指定了.rodata
段,这一段存储字符串和宏信息。值得注意的是,后面跟了两个.LC
段,实际上,LC实际上是指Local Constant,存储了一些只读变量。.globl main
表明main是一个全局符号,需要链接器留意,后面的.type
声明了,main
是一个函数[2]。
随后,我们进入了主函数的函数体逻辑。.cfi_startproc
表明了,我们要开始处理Call Frame Information[3],也就是栈帧信息,所有和cfi
有关的信息,都是为了调试器而设计的,我们不做解释。随后是栈帧创建的三步,这里我们要解释rbp
和rsp
两个寄存器的作用,它们分别存储了BP(Base Pointer)和SP(Stack Pointer),分别存储了栈帧基址指针和栈指针,即当前函数的栈基址和栈顶地址。栈帧创建的三步分别是:
- 存储旧的栈基址。
- 用当前函数的栈基址替换旧的栈基址。
- 更新栈顶地址,分配了32字节的栈空间。
这便是主函数前三步的作用。
随后,是两个保存参数的步骤,将argc
和argv
保存到-20(%rbp)
和-32(%rbp)
的位置上。之后,是一步RIP相对寻址,利用当前指令的地址加上偏移量,能够获得全局变量的地址,亦即"Hello World!"这个字符串,随后将这个值传入rdi
寄存器,这个寄存器是用于函数传参的。
随后是一个调用过程链接表(PLT)的步骤,call
指令将下一条指令入栈,便于返回用,随后调用了puts
函数进行输出,因为,编译器自动认为puts
优于printf
——因为我们的直接输出,并没有占位符。随后的PLT的目的是,因为puts
不在test.c
中出现,所以需要链接过程来确定地址。
随后,通过movl $0, -4(%rbp)
进行循环的初始化,即i=0
这一步,之后跳入.L2
段,执行迭代的过程。
后面的过程就很好理解了,所谓循环,在汇编中,是依靠比较-跳转两个过程进行的,首先判断——即cmpl
这一步——是否不符合迭代条件(即jle
这一步),如果不符合,就跳出循环,否则就进入L3
段运行。
汇编
在Linux下,汇编是表示将文件打包为ELF文件格式[5]的过程:
通过readelf
工具可以读取它的内容:
1
| readelf -a test.o > test.elf
|
首先是ELF头的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 736 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 0 (bytes) Number of program headers: 0 Size of section headers: 64 (bytes) Number of section headers: 14 Section header string table index: 13
|
其中,Magic Number的前四位是0x7f,表示“ELF”,后面的02是一个flag,0表示无效ELF文件,1表示32位,2表示64位,后面的01是另一个flag,0表示无效格式,1是小端法,2是大端法,后面的01表示ELF格式。
后面一部分是节头:
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
| Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 0000000000000051 0000000000000000 AX 0 0 1 [ 2] .rela.text RELA 0000000000000000 000001f0 0000000000000060 0000000000000018 I 11 1 8 [ 3] .data PROGBITS 0000000000000000 00000091 0000000000000000 0000000000000000 WA 0 0 1 [ 4] .bss NOBITS 0000000000000000 00000091 0000000000000000 0000000000000000 WA 0 0 1 [ 5] .rodata PROGBITS 0000000000000000 00000091 0000000000000011 0000000000000000 A 0 0 1 [ 6] .comment PROGBITS 0000000000000000 000000a2 000000000000001c 0000000000000001 MS 0 0 1 [ 7] .note.GNU-stack PROGBITS 0000000000000000 000000be 0000000000000000 0000000000000000 0 0 1 [ 8] .note.gnu.pr[...] NOTE 0000000000000000 000000c0 0000000000000030 0000000000000000 A 0 0 8 [ 9] .eh_frame PROGBITS 0000000000000000 000000f0 0000000000000038 0000000000000000 A 0 0 8 [10] .rela.eh_frame RELA 0000000000000000 00000250 0000000000000018 0000000000000018 I 11 9 8 [11] .symtab SYMTAB 0000000000000000 00000128 00000000000000a8 0000000000000018 12 4 8 [12] .strtab STRTAB 0000000000000000 000001d0 0000000000000019 0000000000000000 0 0 1 [13] .shstrtab STRTAB 0000000000000000 00000268 0000000000000074 0000000000000000 0 0 1
|
描述了各个section的信息。
后面比较重要的信息是:
1 2 3 4 5 6
| Relocation section '.rela.text' at offset 0x1f0 contains 4 entries: Offset Info Type Sym. Value Sym. Name + Addend 000000000012 000300000002 R_X86_64_PC32 0000000000000000 .rodata - 4 00000000001a 000500000004 R_X86_64_PLT32 0000000000000000 puts - 4 00000000002a 000300000002 R_X86_64_PC32 0000000000000000 .rodata + 9 00000000003c 000600000004 R_X86_64_PLT32 0000000000000000 printf - 4
|
这里,包含了我们之前的需要PLT的函数——puts
和printf
,但是,这里有一个问题,为什么出现了.rodata
相关的内容?这些内容也需要重定位吗?其实这些定位的内容都是“全局符号”,如果代码被编译成动态链接库,则需要一个结构来表示这些全局符号的内容,这也叫做“基于PC的相对寻址”。
那么,我们来实际看一看这些汇编与之前那一步有什么不同吧:
1
| objdump -d -r test.o > test.dump
|
其内容为:
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
| 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 48 83 ec 20 sub $0x20,%rsp 8: 89 7d ec mov %edi,-0x14(%rbp) b: 48 89 75 e0 mov %rsi,-0x20(%rbp) f: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 16 <main+0x16> 12: R_X86_64_PC32 .rodata-0x4 16: 48 89 c7 mov %rax,%rdi 19: e8 00 00 00 00 call 1e <main+0x1e> 1a: R_X86_64_PLT32 puts-0x4 1e: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) 25: eb 1d jmp 44 <main+0x44> 27: 48 8d 05 00 00 00 00 lea 0x0(%rip),%rax # 2e <main+0x2e> 2a: R_X86_64_PC32 .rodata+0x9 2e: be 01 00 00 00 mov $0x1,%esi 33: 48 89 c7 mov %rax,%rdi 36: b8 00 00 00 00 mov $0x0,%eax 3b: e8 00 00 00 00 call 40 <main+0x40> 3c: R_X86_64_PLT32 printf-0x4 40: 83 45 fc 01 addl $0x1,-0x4(%rbp) 44: 83 7d fc 09 cmpl $0x9,-0x4(%rbp) 48: 7e dd jle 27 <main+0x27> 4a: b8 00 00 00 00 mov $0x0,%eax 4f: c9 leave 50: c3 ret
|
由于重定位的加入,我们不再需要那些section了,但是,很多内容我们还不知道,比如puts
和printf
到底该怎么运行?程序从哪里进入?这些我们都不知道,因为我们还没有进行链接操作。
链接
链接所使用的命令是:
1
| ld -o test -dynamic-linker /lib64/ld-linux-x86-64.so.2 /lib64/crt1.o /lib64/crti.o test.o /lib64/libc.so /lib64/crtn.o
|
这条命令的解释:
1 2 3 4 5 6 7 8
| ld \ -o test \ -dynamic-linker /lib64/ld-linux-x86-64.so.2 \ /lib64/crt1.o \ /lib64/crti.o \ test.o \ /lib64/libc.so \ /lib64/crtn.o
|
链接的流程是:后面的文件能为前面的文件提供解释。
利用objdump
查看,结果是:
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 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
| test: file format elf64-x86-64
Disassembly of section .init:
0000000000401000 <_init>: 401000: f3 0f 1e fa endbr64 401004: 48 83 ec 08 sub $0x8,%rsp 401008: 48 8b 05 d1 2f 00 00 mov 0x2fd1(%rip),%rax # 403fe0 <__gmon_start__@Base> 40100f: 48 85 c0 test %rax,%rax 401012: 74 02 je 401016 <_init+0x16> 401014: ff d0 call *%rax 401016: 48 83 c4 08 add $0x8,%rsp 40101a: c3 ret
Disassembly of section .plt:
0000000000401020 <puts@plt-0x10>: 401020: ff 35 ca 2f 00 00 push 0x2fca(%rip) # 403ff0 <_GLOBAL_OFFSET_TABLE_+0x8> 401026: ff 25 cc 2f 00 00 jmp *0x2fcc(%rip) # 403ff8 <_GLOBAL_OFFSET_TABLE_+0x10> 40102c: 0f 1f 40 00 nopl 0x0(%rax)
0000000000401030 <puts@plt>: 401030: ff 25 ca 2f 00 00 jmp *0x2fca(%rip) # 404000 <puts@GLIBC_2.2.5> 401036: 68 00 00 00 00 push $0x0 40103b: e9 e0 ff ff ff jmp 401020 <_init+0x20>
0000000000401040 <printf@plt>: 401040: ff 25 c2 2f 00 00 jmp *0x2fc2(%rip) # 404008 <printf@GLIBC_2.2.5> 401046: 68 01 00 00 00 push $0x1 40104b: e9 d0 ff ff ff jmp 401020 <_init+0x20>
Disassembly of section .text:
0000000000401050 <_start>: 401050: f3 0f 1e fa endbr64 401054: 31 ed xor %ebp,%ebp 401056: 49 89 d1 mov %rdx,%r9 401059: 5e pop %rsi 40105a: 48 89 e2 mov %rsp,%rdx 40105d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp 401061: 50 push %rax 401062: 54 push %rsp 401063: 45 31 c0 xor %r8d,%r8d 401066: 31 c9 xor %ecx,%ecx 401068: 48 c7 c7 85 10 40 00 mov $0x401085,%rdi 40106f: ff 15 63 2f 00 00 call *0x2f63(%rip) # 403fd8 <__libc_start_main@GLIBC_2.34> 401075: f4 hlt 401076: 66 2e 0f 1f 84 00 00 cs nopw 0x0(%rax,%rax,1) 40107d: 00 00 00
0000000000401080 <_dl_relocate_static_pie>: 401080: f3 0f 1e fa endbr64 401084: c3 ret
0000000000401085 <main>: 401085: 55 push %rbp 401086: 48 89 e5 mov %rsp,%rbp 401089: 48 83 ec 20 sub $0x20,%rsp 40108d: 89 7d ec mov %edi,-0x14(%rbp) 401090: 48 89 75 e0 mov %rsi,-0x20(%rbp) 401094: 48 8d 05 69 0f 00 00 lea 0xf69(%rip),%rax # 402004 <_IO_stdin_used+0x4> 40109b: 48 89 c7 mov %rax,%rdi 40109e: e8 8d ff ff ff call 401030 <puts@plt> 4010a3: c7 45 fc 00 00 00 00 movl $0x0,-0x4(%rbp) 4010aa: eb 1d jmp 4010c9 <main+0x44> 4010ac: 48 8d 05 5e 0f 00 00 lea 0xf5e(%rip),%rax # 402011 <_IO_stdin_used+0x11> 4010b3: be 01 00 00 00 mov $0x1,%esi 4010b8: 48 89 c7 mov %rax,%rdi 4010bb: b8 00 00 00 00 mov $0x0,%eax 4010c0: e8 7b ff ff ff call 401040 <printf@plt> 4010c5: 83 45 fc 01 addl $0x1,-0x4(%rbp) 4010c9: 83 7d fc 09 cmpl $0x9,-0x4(%rbp) 4010cd: 7e dd jle 4010ac <main+0x27> 4010cf: b8 00 00 00 00 mov $0x0,%eax 4010d4: c9 leave 4010d5: c3 ret
Disassembly of section .fini:
00000000004010d8 <_fini>: 4010d8: f3 0f 1e fa endbr64 4010dc: 48 83 ec 08 sub $0x8,%rsp 4010e0: 48 83 c4 08 add $0x8,%rsp 4010e4: c3 ret
|
增添了程序的初始入口,以及结束所用的代码,也引入了C标准库中的函数,到此,我们的程序算是能运行起来了。
后记
说实话,这篇写得有够烂的了。深究下去,这篇文章还有很多很多很多很多可以写的地方,不过,不利于我回看时学习。
其实,只需要了解程序大概运行的逻辑以及汇编语言的基础就足够了,就足以让我们去Crack一些软件了。
参考资料