PWN(1)
pwn,yyds
一些基本概念
exploid:用于攻击的脚本或方案
payload:恶意数据,构造用于破坏,一般翻译为攻击载荷
shellcode:用户和操作系统的命令行借口
程序的编译和链接
考虑一个C语言程序
1 | #include <iostream> |
Linux是通过文件头来判断文件类型的,而Windows使用后缀名标识文件。可以用file a
来查看a的文件类型。
利用gcc a.c
就可以输出a.out
。但是编译过程本身有很多步,我们这里主要涉及两步:
(1)编译为汇编文件
gcc -S a.c
,那么出现了a.s
,可以打开为汇编文件
(2)汇编文件进行链接
例如printf
函数,需要链接起来取得库中的代码。
可执行文件的概念比较复杂。广义的可执行文件是所有可执行的数据,比如.out、.py等;狭义的可执行程序是机器码文件,比如.out、.exe、.dll、.so
pwn主要研究的分成Windows和Linux。
- Windows PE:.可执行程序exe,动态链接库.dll,静态链接库.lib
- Linux ELF: 可执行程序.out,动态链接库.so,静态链接库.a
多数pwn题目都是ELF。一个ELF文件结构如下:
Header 1/2
- ELF header,文件头表,记录文件组织结构
- Program Header table,程序头表/段表,标识进程不同部分的程序,链接库文件不一定需要段表
Sections
- Code
- Data
- Sections’ names
Header 2/2
- Section Header table,节头表,储存ELF文件各个节的信息
段和节的映射
程序运行需要加载到内存中。编译好的可执行文件储存在磁盘中,运行则变成了进程映像。许多节合成了一个段,并只占内存中的一小部分。
利用objdump -s elf
可以查看磁盘中的可执行文件的结构,利用gdb可以管理内存中的C语言程序。
VMA是虚拟内存的结构。传统内存使用物理内存,则物理内存很容易被篡改,导致计算机很容易受攻击。为此,人们发明了一种保护模式,让内存地址映射到虚拟的内存,之后操作系统再将虚拟内存转换为物理内存。这样,用户不能直接拿到物理内存地址,保证了硬件和用户直接控制的分离。操作系统提供了硬件的调用接口,用户通过确定规则来访问硬件。
对于32位系统,每个进程都有一个4G的虚拟内存空间。所有的内存分配问题都由操作系统完成。对于Linux系统,将3GB分配给用户成为用户空间,1GB分配给操作系统成为内核空间。多个进程的用户空间相互独立,但是内核空间可以共享。
虚拟内存中地址以字节编码,1Byte=8bits,以两个16进制表示,即0x**
。
64位系统则分别取128T.由于空间过大,还要一部分未定义区域。
节决定存储结构,段决定执行的结构。比较重要的节有:
- .text节:用户代码
- .rodata节:只读数据
- .plt节:解析动态链接函数的地址
- .got.plt节:保存plt解析的实际地址
- .bss节:只占用内存空间
我们来以下面这个程序为例分析段的载入结构:
1 | int glb; |
- text段:main,sum,”Hello World!”
- data段:已初始化的变量,strr
- bss段:未初始化的遍历,glb
- Heap:动态存储区,”deadbeef”
- Stack:局部变量,t、ptr
x和y是形参。也就是说,只有在父函数调用起到传递作用,在32位架构下也存储在栈中,而64位则不出现在虚拟内存空间、而是直接存储在寄存器中,提高执行效率。
函数的调用
这里还有一点需要注意,就是大端机和小端机。例如0xABCD
,那么小端序中,AB放在最大的地址;大端序中,AB放在最小的地址。多数题目都是小端序。
内存和CPU通过地址总线、数据总线和控制总线交互。内存储存了CPU的指令和所需数据,而主要通过寄存器完成指令。
常见的是i386寄存器:
- EAX 一般做累加器
- EBX 一般做基址寄存器
- ECX 一般用于计数
- EDX 一般用于存放数据
- ESP 一般用作堆栈指针
- EBP 一般用作基址指针
- ESI 一般用作源变址
- EDI 一般用于目标变址
程序装载过程也比较复杂。
对于静态链接程序,会经历一系列过程。首先会进行fork,将自己的用户空间进行复制一份。接下来调用execve("/binary", argv[], *envp[])
,开始访问操作系统层。操作系统会执行一系列操作,将fork后的用户空间变得可用。此时回到用户接管,通过_start
入口执行main函数。
动态链接是在执行的时候寻找动态文件库,再进行执行。这要求其库文件都存在,并能随时找到。具体过程上,在回到用户之后,需要借助ld.so
来链接,之后进行_start
,再执行__libc_start_main()
和_init
才能开始执行功能。
汇编基础
[]相当于取值,例如[00404011H]表示取这个地址上的值
MOV将操作符传送给目标,MOV EAX,1234H表示EAX赋值成1234X
LEA是取地址,LEA EBX,ASC表示把ASC的地址储存在EBX寄存器中,LEA EAX, 6[ESI]表示将ESI+6传给EAX
PUSH是压栈,POP是弹出栈。栈总是从高地址往低地址增长。POP EAX可以将栈顶的值保存到EAX中。
LEAVE在函数返回的时候,表示离开当前函数,销毁子函数的栈帧。等效于
1 | MOV ESP,EBP |
RET则将控制流返回父函数。
函数调用栈
栈帧
一个栈帧保存了函数调用的状态,按照高地址到低地址增长。其栈顶是ESP,栈底是EBP。
stack frame pointer保存了父函数栈顶和栈底指针的位置。callee saved registers保存了子函数,local variables保存了局部变量。
子函数所用的参数并不是保存在自己的栈帧中,而是保存在父函数栈帧的arguments中。父函数末尾的stack frame pointer指向自己的栈顶。
工作过程
在调用子函数的时候,首先将子函数需要参数倒序压入栈中。EIP保存即将执行的程序指令的地址。
例如
1 | int sum(int x, int y) { |
对于一个main函数栈帧而言,其结构为:
- sum函数
- 参数2
- 参数1
- sum下一条指令的地址
接下来我们要把main函数的EBP压入到sum函数中,保存其地址。将局部变量压入被调函数的栈内,完成子函数的功能。此时EBP、ESP指向子函数的栈底和栈顶,栈帧为子函数的栈帧。
接下来将ESP和EBP移动位置,就相当于进行了数据删除。先将ESP和EBP移动到相同位置,再弹出寄存器,EBP变成了寄存器的保存值,也就是main函数栈顶的位置。ESP则自动减去一个字长。
我们用下面这个例子来演示过程。
1 | int callee(int a, int b, int c) { |
我们分析其汇编代码,然后给出每一步的栈变化。
1 | ;<caller>: |
具体的动图建议去看视频(
这样我们就做好了准备,去开始我们的第一个pwn漏洞——stack overflow.