推荐阅读:《深入理解计算机系统》(CSAPP)
01 - The Parts of Computer you’ll need to know
总所周知,一台简单的PC(Personal Computer)一般由以下关键组件构成:
- CPU 中央处理器:CPU是电脑的大脑,负责执行指令和处理数据。它包含算术逻辑单元(ALU)、控制单元(CU)和寄存器等部分。
- 主板:主板是所有硬件组件的连接中心,上面有很多插槽和接口,用于连接CPU、内存、硬盘、显卡等设备。
- RAM 内存:内存是电脑用来存储数据和程序的地方,它的速度比硬盘快得多。内存的大小对电脑的性能有很大影响。
- 硬盘:硬盘是用来永久存储数据的设备,包括机械硬盘(HDD)和固态硬盘(SSD)两种。机械硬盘通过旋转盘片和磁头读写数据,而固态硬盘通过闪存芯片实现。
- 显卡:显卡负责处理电脑的图形显示,它包括图形处理器(GPU)和显存。对于需要进行图形处理的应用程序,如游戏和图像编辑软件,显卡的性能至关重要。
- 输入输出设备:电脑的输入设备包括键盘、鼠标、触摸屏等,用于输入数据和指令。输出设备包括显示器、打印机、音箱等,用于显示结果和输出数据。
以上包括其他硬件设施,都属于计算机的硬件组成,除开这些,我们还需要了解软件组成:
- 操作系统(OS):操作系统是电脑的核心软件,它管理硬件资源、提供用户接口和运行应用程序。常见的操作系统有Windows、MacOS和Linux等。
- 应用软件:应用软件是由程序员开发的,用于完成特定任务的软件。例如,办公软件、图像处理软件、视频编辑软件等。
- 开发工具:开发工具是用于编写、调试和测试代码的软件,包括集成开发环境(IDE)和编译器等。常见的开发工具有Visual Studio、Eclips和Xcode等。
- 驱动程序:驱动程序是用于控制硬件设备的软件,它们与操作系统紧密配合,使硬件能够正常工作。
- 网络协议:网络协议是用于在计算机网络中传输数据的规则和标准。常见的网络协议有TCP/IP、HTTP、FTP等。
在CTF-PWN中,我们主要关注应用软件的底层运作,分析其漏洞并实现利用方法,最终得到Flag。而接下来的入门,我们需要从Linux操作系统的用户态开始进行。
02- x86 Register
接下来我们了解一下CPU的储存元件——寄存器
寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。其实寄存器就是一种常用的时序逻辑电路,但这种时序逻辑电路只包含存储电路。寄存器的存储电路是由锁存器或触发器构成的,因为一个锁存器或触发器能存储1位二进制数,所以由N个锁存器或触发器可以构成N位寄存器。寄存器是中央处理器内的组成部分。寄存器是有限存储容量的高速存储部件,它们可用来暂存指令、数据和位址。
你可能已经在Bk-1-02-C Programming中了解过C语言的编译过程了,其中会产生一个低级的代码文件——汇编语言。在汇编语言中,寄存器充当着类似变量的角色。在Intel&AMD的CPU中使用的h就是我们现在要介绍的x86/x64架构。
x86架构CPU走的是复杂指令集(CISC) 路线,提供了丰富的指令来实现强大的功能,与此同时也提供了大量寄存器来辅助功能实现。
通用寄存器
首先要介绍通用寄存器,这些的寄存器是程序执行代码最最常用,也最最基础的寄存器,程序执行过程中,绝大部分时间都是在操作这些寄存器来实现指令功能。
- eax:通常用来执行加法,函数调用的返回值一般也放在这里面。
- ebx:数据存取
- ecx:通常作为计数器,例如在for循环的汇编实现
- edx:读写IO端口,该寄存器存放端口号
- esp:存放指向栈顶的指针
- ebp:存放指向栈底的指针,通常用
ebp + offset
的形式来定位栈中的局部变量 - esi:字符串操作时,用于存放数据源的地址
- edi:符串操作时,用于存放目的地址的,和esi两个经常搭配一起使用,执行字符串的复制等操作
在x64架构中,这些寄存器都拓展为了64位版本 rax rbx rcx rdx rsp rbp rsi rdi 同时引入了r8-r15的寄存器
通用寄存器在x64架构中被用来传递参数。
标志寄存器
标志寄存器,里面有众多标记位,记录了CPU执行指令过程中的一系列状态,这些标志大都由CPU自动设置和修改
- CF 进位标志
- PF 奇偶标志
- ZF 0标志
- SF 符号标志
- OF 补码溢出标志
- TF 跟踪标志
- IF 中断标志
指令寄存器
eip
: 指令寄存器可以说是CPU中最最重要的寄存器了,它指向了下一条要执行的指令所存放的地址,CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令,如此不断重复,这就是CPU工作的基本日常。
在CTF挑战中,我们主要通过修改IP寄存器的内容控制程序执行流。
段寄存器
段寄存器与CPU的内存寻址技术紧密相关。
- cs:代码段
- ds:数据段
- ss:栈段
- es:拓展段
- fs:数据段
- gs:数据段
03 -Assembly Language
要说到学习PWN,绕不开的一个话题就是汇编语言。什么是汇编语言?
汇编语言(Assembly Language)是任何一种用于电子计算机、微处理器、微控制器或其他可编程器件的低级语言,亦称为符号语言。 在汇编语言中,用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址。 在不同的设备中,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令
实际上,汇编语言实际上是一条条指令,指挥计算机去操作和运算。譬如:
mov eax, 8
这条指令表示将数值8
赋值给eax
寄存器。如果将eax
看作是一个变量value
那么这条指令等价于
int value = 8;
汇编语言可以无缝转换成计算机能读懂的二进制码。在接近底层研究方向的PWN,我们就是和汇编语言打交道。
在学习本节内容之前,你应该保证自己熟记了Bk-2-02,寄存器的相关知识。
基础汇编指令
汇编语言是由一条条指令组成的,于是我们有以下类型的指令。
数据传输指令
MOV
传送指令MOV dest, src
:将数据从src
移动到dest
PUSH
压栈指令PUSH eax
:将eax
寄存器里的数据放入栈顶。%% 关于栈的相关知识请看Bk-2-04 %%POP
弹栈指令POP ebx
:将栈顶的数据放入ebx
的寄存器。LEA
计算有效地址LEA rdi, LABEL
:将LABEL的地址赋予rdi
算术运算指令
ADD
加法指令ADD eax, ebx
:将eax
里的数据与ebx
里的数据相加,结果放入前一个中。SUB
减法指令SUB ecx, edx
:ecx
中的数据的基础上减去edx
中的数据。INC
+1指令INC edx
:edx
寄存器中的数据自增1DEC
-1指令DEX edx
:edx
寄存器中的数据自减1
逻辑运算指令
NOT
取反指令NOT eax
:将eax
中的数据的二进制按位取反%%比如数据为2,二进制表示为10,取反就是01%%AND
与运算AND eax, ebx
:将eax
中的数据与ebx
中的数据做与运算后放回前一个。OR
或运算OR eax, ebx
:将eax
中的数据与ebx
中的数据做或运算后放回前一个。XOR
异或运算XOR eax, ebx
:将eax
中的数据与ebx
中的数据做异或运算后放回前一个。
循环控制指令
LOOP
计数循环指令LOOP Label
:使ecx
的值减1,当ecx
的值不为0的时候跳转至label,否则执行LOOP之后的语句。
转移指令
JMP
无条件跳转指令JMP Label
:无条件跳转到label的位置CALL
过程调用指令CALL Label
:等价于PUSH ip;JMP Label
JE
&JNE
:条件跳转指令JE Label
:考察前一条指令的执行结果,JE
为真跳转,JNE
为假跳转
汇编程序分析
大致了解以上指令后,我们用一个简单易懂的例子来深入理解汇编语言。
%%Linux_x86_64 环境%%
一般地,我们有一个c程序
#include <stdio.h>
int main{
printf("Hello World!");
return 0;
}
学习了c语言,我们可以了解到c语言在编译执行时会生成一个.s
文件,这就是c语言程序转换成的 汇编语言文件。
.LC0:
.string "Hello World!"
main:
lea rdi, .LC0[rip]
mov eax, 0
call printf@PLT
mov eax, 0
ret
当然,实际的.s
不会如此简洁,这里我们只保留了需要的部分。
.LC0
可以看作一个储存着字符串常量的指针。lea rdi, .LC0[rip]
:将.LC0
的指针地址赋予rdi
,此时rdi
装着一个指针,实际指向.LC0
mov eax, 0
:将0赋予eax
call printf@plt
:调用.plt
表中记载的printf()
,打印字符串。ret
:返回上一个函数。
看到这里,你应该对我们所说的内容有了一些懵懂的理解,在接下来的学习中。我们将从底层剖析函数调用流程和参数传递规范,以及对基本数据结构——栈的学习。
04 -Stack
栈基本介绍
在学习函数调用流程和参数传递规范之前,我们先要了解一下一个基本的数据结构:栈(Stack)。它与堆(Heap)一样,是一种运行时数据结构。在我们运行一个程序时,首先它会被加载进入内存,按照特定的顺序将不同的内容存放到内存的各个部分。栈就是内存中的一个部分。
栈是一种典型的先进后出(First in Last out-FILO)数据结构,对于栈的操作主要有压栈(PUSH)和弹栈(POP)(出栈)。两种操作都对栈顶进行。栈在虚拟地址空间中用于保存函数调用信息和储存局部变量。 %%栈示意图%%
C语言中的函数调用栈
程序的执行可以看作连续的函数调用过程。当一个函数调用完毕,需要回到原来的函数继续执行。函数的调用通常使用堆栈来实现,编译器使用堆栈传递函数参数、保存返回地址、临时保存寄存器的值。
函数的调用通常是嵌套的。在同一时刻,栈中通常会有不同函数的信息,每个未完成的函数占用一个独立的连续的虚拟内存空间,称作栈帧Stack Frame。
%%栈帧示意图%%
栈帧的边界由寄存器BP
和SP
界定。在x86_64架构中,RSP
寄存器指向当前栈帧的栈顶,RBP
寄存器指向当前栈帧的栈底
在x86架构中
- i386(32位)中,函数的参数在栈上传递
- x86_64(64位)中,函数s的参数小于等于6时,依次由
RDI
,RSI
,RDX
,RCX
,R8
,R9
传递,大于6个时,第7个开始的参数在栈上传递
函数调用约定
我们有一个实验来具体学习函数调用约定。我们给定以下源码。
Source Code
#include <stdio.h>
int add(int a,int b){
return a + b;
}
int main(){
int x = 1;
int y = 2;
int z;
z = add(x, y);
printf("%d", z);
return 0;
}
用以下命令编译
$ gcc -m32 code.c -o code
-m32
表示指定编译为32位程序。
同样的,我们可以查看在编译过程中会生成的.s
文件。
$ gcc -m32 -S code.c -o code.s -masm=intel
-S
表示输出汇编文件-masm=intel
表示用intel的汇编格式
剔除掉不重要的部分后,我们可以得到汇编代码
main:
push ebp
mov ebp, esp
push ebx
push ecx
sub esp, 10h
call __x86_get_pc_thunk_bx
add ebx, (offset _GLOBAL_OFFSET_TABLE_ - $)
mov [ebp+var_14], 1
mov [ebp+var_10], 2
push [ebp+var_10]
push [ebp+var_14]
call add
add esp, 8
mov [ebp+var_C], eax
sub esp, 8
push [ebp+var_C]
lea eax, (aD - 3FF4h)[ebx] ; "%d"
push eax ; format
call _printf
add esp, 10h
mov eax, 0
lea esp, [ebp-8]
pop ecx
pop ebx
pop ebp
lea esp, [ecx-4]
retn
add:
push ebp
mov ebp, esp
call __x86_get_pc_thunk_ax
add eax, (offset _GLOBAL_OFFSET_TABLE_ - $)
mov edx, [ebp+arg_0]
mov eax, [ebp+arg_4]
add eax, edx
pop ebp
retn
我们知道main()
是程序的入口。实际上main()
也会被相应程序调用。我们主要关注main()
的执行流程。在进入main()
时,程序的栈帧如图。
此时进入main()
我们需要开辟一个新的栈帧
push ebp
mov ebp, esp
push ebp
指令表示将当前ebp
的值压入栈中,实际上就是将_libc_start_main
的栈底保存到栈中。_libc_start_main()
函数会调用main()
。这一条指令的意义就是保存当前函数的栈帧。注意这里push了之后esp
会抬高一个内存单元mov ebp, esp
指令表示将当前esp
的值赋给ebp
。esp
是指向栈顶的指针,那么esp
中的值就是当前栈顶的地址,ebp
的值变成了esp
的值,ebp
是指向栈底的指针,那么实际上ebp
现在与esp
重合了,将栈底抬高至栈顶
内存地址和地址储存的值是不同的,这一点注意区分。可以近似理解为指针。
push ebx
push ecx
sub esp, 10h
push ebx
将ebx
的值压入栈中,esp
抬高一个内存单元push ecx
将ecx
的值压入栈中,esp
抬高一个内存单元
感兴趣的师傅可以了解一下这些寄存器的在这里的作用这里不做赘述
sub esp, 10h
将esp
的值减去0x10
,注意栈是高地址向低地址生长,在i386架构中,0x8
bit为一个内存单元,实际上就是抬高栈顶两个内存单元。
mov [ebp+var_14], 1
mov [ebp+var_10], 2
push [ebp+var_10]
push [ebp+var_14]
mov [ebp+var_14], 1
将1放入[ebp+var_14]
的位置,此处[]
表示解引用。我们知道ebp
是一个指向栈底的指针,ebp+var_14
表示ebp
的值加上var_14
的偏移量,就是指向ebp+var_14
的指针,解引用表示向这个地址中的内存写入值。mov [ebp+var_10], 2
将2放入[ebp+var_10]
的位置。push [ebp+var10]
&push [ebp+var_14]
这两条指令将我们刚刚赋给的值push到了栈上,实际上,这一步就是参数传递,因为我们下一步准备调用add()
函数了,在32位程序中,参数依靠栈来传递。
call add
这里调用了add函数,关键点。我们知道每一个函数都有一个独立的栈帧,这里我们将为add()
开辟一个新的栈帧,然后,我们详细解释一下call
指令
实际上,call
指令是两条指令push eip;jmp label
push eip
将当前eip
的值压入栈中。我们知道eip
储存当前程序下一条指令的地址,称为返回地址,那么我们这个时候将eip
压入栈中实际上是压入了call
指令的下一条指令的地址,保存到栈中。当我们执行完call的函数后,将原来push的值pop
回eip
中,就回到上一个函数中继续执行了。jmp label
跳转到调用函数执行
好,现在我们执行流来到了add()
push ebp
mov ebp, esp
同样的,开辟栈帧。
注意此处
push ebp
目的是保存main()
函数的栈底,在执行完当前调用函数后,我们将会销毁这个栈帧,回到main()
函数的栈帧
mov edx, [ebp+arg_0]
mov eax, [ebp+arg_4]
add eax, edx
mov edx, [ebp+arg_0]
将[ebp+arg_0]
的值放入edx
中,此处对应刚刚push进到栈上的var_10
mov eax, [ebp+arg_4]
将[ebp+arg_4]
的值放入eax
中,此处对应刚刚push进栈上的var_14
add eax, edx
此处将edx
的值与eax
的值相加,返回值放入eax
中。
执行完add()
现在我们要返回了。
pop ebp
将栈顶的值弹入ebp
。
可以看到栈顶的值就是我们刚刚push入栈中的main()
的栈底。
现在,栈帧归位了,我们要将执行流改到main()
,就会用到ret
,即pop eip
,将栈顶的main return address
弹入eip
中。
现在我们回到main()
继续执行,刚刚执行add()
的结果保存在了eax
中。
ax
寄存器通常是保存返回值的寄存器
add esp, 8
mov [ebp+var_C], eax
sub esp, 8
push [ebp+var_C]
add esp, 8
清理栈帧,降栈一个内存单元。mov [ebp+var_C], eax
将eax
中的返回值放入[ebp+var_C]
中push [ebp+var_C]
保存参数,准备调用printf
lea eax, (aD - 3FF4h)[ebx] ; "%d"
push eax ; format
call _printf
lea eax, (aD - 3FF4h)[ebx]
这里将%d
格式化字符串的指针赋给eax
push eax
将eax
的值即%d
的地址压入栈中作为printf
的参数。call _printf
调用printf
外部函数。
add esp, 10h
mov eax, 0
lea esp, [ebp-8]
add esp, 10h
,降栈2个内存单元mov eax, 0
清空eax
lea esp, [ebp-8]
,将当前esp
的位置赋给esp
pop ecx
pop ebx
pop ebp
retn
pop ecx
&pop ebx
将先前保存的寄存器的值弹回对应寄存器pop ebp
回到_libc_start_main
的栈帧retn
会到_libc_start_main
的执行流。
至此,程序执行完毕。
你可以通过gdb
调试实时查看栈情况,通过复现此实验,彻底理解函数调用约定和参数传递流程。以上实验演示在i386架构下的函数调用。你可以通过以下指令来编译x86_64架构下的程序,学习了解其参数传递流程。
$ gcc code.c -o code