pwn及计算机原理基础知识
PWN是什么
PWN是一个俗语,每次攻破某个服务或者系统就是“pwn进去了”,也就是:
破解、利用成功(二进制漏洞)
攻破(设备、服务器)
控制(设备、服务器)
流程如下:
Exploit:用于攻击的脚本和方案
Payload:攻击载荷,也就是构造的恶意数据
Shellcode:调用攻击目标shell的代码
二进制基础
C语言编译流程
c/c++在整个世界里面就和超级搅屎棍一样,一方面因为他真的很优秀,一方面因为时代局限性,导致有很多含有漏洞的程序在世界各处到处都有。
当我拥有一个c语言程序,例如:
1 |
|
我们对他进行编译:
1 | gcc helloworld.c |
就可以得到一个二进制程序a.out

a.out的内容如下:
1 | @ @@@���uu ���-�=�=X`�-�=�=�8880hhh��S�td8880P�td 44Q�tdR�td�-�=�=HH/lib64/ld-linux-x86-64.so.2 GNU���GNU |
我们可以从中看到一些看得懂的和大量看不懂的内容,其中绝大部分都是无法查看的二进制数据(机器码),而那些可以看出来的二进制数据,可以看到许多信息。这到底是什么文件呢,我们可以file一下。

我们可以发现他是一个ELF(linux下可执行文件格式),64位的文件。

如图所示,C语言到可执行程序的流程也就是_编译(compilier),汇编(assembler),和链接(linker)_

可执行文件
什么是可执行文件
广义上,可执行文件就是可以执行的文件,比如说python文件在python环境下就可以直接执行(因为他是一个脚本)。
狭义上,可执行文件必须是一个经历过编译,汇编和链接后得到的二进制文件,并且在环境合适的情况下可以直接打开运行。也就是说CPU可以直接认识,比如.out.exe.dll.so文件
所谓的二进制漏洞,都是在狭义上的程序中的漏洞。
可执行文件的分类
Windows为**PE(portable Executable)**
可执行程序:.exe
动态链接库:.dll
静态链接库:.lib
Linux为**ELF(Executable and Linkable Format)**
可执行程序:.out
动态链接库:.so
静态链接库:.a
ELF文件、内存加载和虚拟内存
ELF文件结构
段视图(Segment View):用于进程在内存区域中读、写、执行(rwx)权限划分
节视图(Section View):一个ELF文件编译链接时候,在磁盘上存储时的文件结构组织
一个段可包含多个节,节是段的细分单元。(不准确,映射关系中,进入内存后看起来段是高于节的)
不过大部分时候,不是严格区分成两个部分的,而是要根据不同的需求做不同的分析的。

比如说在程序从普通存储在硬盘状态到内存中运行的状态,就要从一种段视图的角度转移到节视图。如图所示,一个存储在Disk的程序变为进程进入Memory的时候,其中_**可读可写的.data.bss.got.plt段 **_就变成了Memory中的 Data节 ,很显然,Code节也是一样的由多个段组成的。而程序运行后又产生了新的节,例如stack(栈)和heap(堆)部分…
虚拟内存
程序在调试的时候需要查看内存里面的内容,但是很可惜真实的内存因为一些机制难以查看。
在硬件层面,cpu和memory之间互相沟通没有问题,但是作为程序员是没有办法和硬件沟通的,于是通过OS,OS将物理内存(memory)给抽象出来。
OS通过软件机制将物理内存、外存(硬盘)等资源整合后,向程序员呈现的 “逻辑内存”。
地址空间的隔离与映射
_**虚拟内存用户空间每个进程各一份,**_每个程序各自占用独立的空间,例如在32位系统中,默认会划分给2^32byte,也就是4GB空间的大小。
将CPU中的内存管理单元(MMU)和OS维护的页表(Page Table)实现映射。
因为所有的数据都需要内核,因此_虚拟内存内核空间所有进程共享一份_
最后,虚拟内存mmap段中的动态链接库(glibc)仅在物理内存中装载一份。虽然在每个程序的虚拟内存中都存在一份。
内存空间

如图所示,32位和64位的系统分别创造出来的空间大小各不相同。依次介绍一下分出来的各个部分分别是干嘛的。
首先是Kernel Space,就是映射出来的内核空间。
stack和heap都是动态存储各种数据的地方。
下面都是静态存储区。
RW段(可读可写,一般是.bss.data等)
ReadOnly段(只读,例如.init_array、.fini_array等)
RE段(可读可执行,一般是.init、.rodata、.text等)
Reserved是保留部分。
段(segment)与节(section)概述
1. 代码段(Text segment)
功能:存储代码与只读数据(ReadOnly)
包含节:
`<font style="background-color:rgb(187,191,196);">.text</font>` 节(核心代码存储) `<font style="background-color:rgb(187,191,196);">.rodata</font>` 节(只读常量,如字符串字面量 ) `<font style="background-color:rgb(187,191,196);">.hash</font>` 节(符号哈希表,辅助符号查找 ) `<font style="background-color:rgb(187,191,196);">.dynsym</font>` 节(动态链接符号表 ) `<font style="background-color:rgb(187,191,196);">.dynstr</font>` 节(动态链接符号名字符串 ) `<font style="background-color:rgb(187,191,196);">.plt</font>` 节(过程链接表,用于延迟绑定 ) `<font style="background-color:rgb(187,191,196);">.rel.got</font>` 节(全局偏移表重定位信息 )
2. 数据段(Data segment)
功能:存储可读可写数据(Read Write)
包含节:
`<font style="background-color:rgb(187,191,196);">.data</font>` 节(已初始化全局 / 静态变量 ) `<font style="background-color:rgb(187,191,196);">.dynamic</font>` 节(动态链接相关信息,如依赖库 ) `<font style="background-color:rgb(187,191,196);">.got</font>` 节(全局偏移表,加速地址访问 ) `<font style="background-color:rgb(187,191,196);">.got.plt</font>` 节(针对 plt 的全局偏移表 ) `<font style="background-color:rgb(187,191,196);">.bss</font>` 节(未初始化 / 初始化为 0 的全局 / 静态变量,运行时分配内存 )
3. 栈段(Stack segment)
通常用于函数调用上下文、局部变量存储,由系统自动管理栈帧分配与释放
程序数据如何在内存中组织

一开始,有两个全局变量。其中glb存放在内存中的.Bss节部分(因为他没有被初始化)。Bss的其中之一的作用就是存储未初始化的变量。在运行时候分配内存(静态变量也是如此)。
而在char* str中,其中str放在了Data节部分,也就是已经被初始化的变量存储部分。然后”Helloworld”因为是一个字符串,一个只读数据,所以就被放在了Text段中(一般是Text段里面的.rodata节)
然后所有的函数存放在了Text段的text节里面,例如sum、main等等。在程序运行中,所有在函数中的变量(函数的内存开销)都被放在了Stack,也就是栈里面。
在程序中有void* ptr=malloc(0x100);其中这个0x100的空间就通过malloc申请到Heap里面了。如此这般,read函数读取到的”deadbeef”就被读取到Heap里面存储着了,也就是堆里。
大端序和小端序
小端序 低地址存放数据低位,高地址存放数据高位。主流格式
大端序 低地址存放数据高位,高地址存放数据低位


比如说我现在
1 | char* str = "ABC"; |
就可以得到【ABC\0】这个字符串,分别的ASCII码的十六进制是41,42,43,00,合在一起数据就变成了”00434241”。
其中数据最低位为41,数据最高位为00。数据低位放在内存低位,数据高位内存高位(如小端序解释图所示)。
程序的装载与进程的执行

越靠近CPU的存储器,速度越快,价格越高昂,而且存储空间越小。而Register(寄存器)就是一个速度极快的存储器。
寄存器

寄存器一共就四个任务:
第一是把数据存到寄存器中
第二是把寄存器中的东西存放到另一个寄存器上
第三是把内存中地址上数据获取后存放到寄存器中
第四是把寄存器的数据存到地址上去
一、amd64 寄存器结构(以 RAX 为例的分级访问)
amd64 架构中,<font style="background-color:rgb(187,191,196);">RAX</font> 是 8 字节(64 位)通用寄存器,支持向下兼容的分级访问:
<font style="background-color:rgb(187,191,196);">rax</font>:完整 8 字节(64 位)寄存器,可存 64 位数据。<font style="background-color:rgb(187,191,196);">eax</font>:低 4 字节(32 位),兼容 32 位程序,存<font style="background-color:rgb(187,191,196);">rax</font>的低 32 位数据。<font style="background-color:rgb(187,191,196);">ax</font>:低 2 字节(16 位),兼容 16 位程序,存<font style="background-color:rgb(187,191,196);">rax</font>的低 16 位数据。<font style="background-color:rgb(187,191,196);">ah</font>:<font style="background-color:rgb(187,191,196);">ax</font>的高 1 字节(8 位),存<font style="background-color:rgb(187,191,196);">ax</font>的高 8 位。<font style="background-color:rgb(187,191,196);">al</font>:<font style="background-color:rgb(187,191,196);">ax</font>的低 1 字节(8 位),存<font style="background-color:rgb(187,191,196);">ax</font>的低 8 位。
这种设计让程序能灵活处理不同位数的数据(64/32/16/8 位),适配旧代码兼容需求。
二、部分寄存器的功能
- RIP(程序计数器指针寄存器)(Program Counter)
- 存当前下一条指令的偏移地址。
- RSP(栈指针寄存器)
- 存当前栈帧的栈顶偏移地址,栈是 “后进先出” 的内存区域(如函数调用时局部变量、返回地址的存储 ),
<font style="background-color:rgb(187,191,196);">RSP</font>随栈操作(<font style="background-color:rgb(187,191,196);">push</font>/<font style="background-color:rgb(187,191,196);">pop</font>)动态变化,始终指向栈顶位置。
- 存当前栈帧的栈顶偏移地址,栈是 “后进先出” 的内存区域(如函数调用时局部变量、返回地址的存储 ),
- RBP(基址指针寄存器)
- 存当前栈帧的栈底偏移地址,用于定位栈内数据(如函数局部变量相对于栈底的偏移 ),辅助访问栈帧中的内容,常配合
<font style="background-color:rgb(187,191,196);">RSP</font>管理函数调用栈。
- 存当前栈帧的栈底偏移地址,用于定位栈内数据(如函数局部变量相对于栈底的偏移 ),辅助访问栈帧中的内容,常配合
- RAX(通用寄存器)
- 作为通用寄存器,可临时存各种数据;特殊功能是存放函数返回值(如 C 语言中
<font style="background-color:rgb(187,191,196);">int</font>/<font style="background-color:rgb(187,191,196);">long long</font>等类型的返回值,会通过<font style="background-color:rgb(187,191,196);">RAX</font>传递 )。
- 作为通用寄存器,可临时存各种数据;特殊功能是存放函数返回值(如 C 语言中
静态链接的程序的执行过程

fork函数
当我们开始运行程序的时候,随后运行fork()函数
fork()在这张图的意思大概如下:我运行一个helloworld程序,需要用到一些内核kernel的东西。于是乎在我运行,也就是 $ ./binary的时候,shell使用fork函数复制自己,他会在父进程(也就是我的系统)中分裂出一个子进程(也就是./binary程序)。然后当子进程需要用到和修改父进程(kernel中的内容)的时候,fork函数就会复制一份数据(在初始阶段共享所有的数据(全局、 栈区、 堆区、 代码))到子进程中。所以在程序运行的时候,fork函数就是做这个的。
再单独解释一下fork函数,它的作用是从主进程中分裂出一个进程,他们之间是父子进程的关系。然后fork函数会返回一个int类型,这个int类型就是进程号。
fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
在父进程中,fork返回新创建子进程的进程ID; 在子进程中,fork返回0; 如果出现错误,fork返回一个负值;
因此我们可以通过fork返回的值来判断当前进程是子进程还是父进程。(注: fork 调用生成的新进程与其父进程谁先执行不一定,哪个进程先执行要看系统的进程调度策略)
execve函数
当然上述过程只是创建了一个进程,而进程中没有需要运行的内容,execve函数就是用来把./binary里面的内容(也就是需要运行的程序本身)放到子进程中的
子进程调用 execve("./binary", argv[], envp[]) ,目的是用新程序(./binary )替换当前子进程的内存空间:
argv[]:传递程序运行参数(比如 ./binary arg1 arg2 里的参数 )。 envp[]:传递环境变量(比如 PATH``HOME 等 )。
内核态切换与处理
execve() 会触发系统调用,进入内核态(Kernel mode):
先调用 sys_execve() (内核层的系统调用处理函数 ),再调用 do_execve() 。
do_execve() 会执行 search_binary_handler() ,作用是查找能处理该可执行文件的加载器(因为是静态链接的 ELF 文件,会找到 ELF 加载器 )。
找到后调用 load_elf_binary() ,负责加载 ELF 文件到内存(解析文件头、分配内存、加载代码段 / 数据段等 )。
sys_execve()和do_execve()
简单来说,<font style="background-color:rgb(187,191,196);">sys_execve()</font> 是系统调用处理函数,是用户态到内核态的接口之一。在 Linux 内核里,每个系统调用都有对应的处理函数,<font style="background-color:rgb(187,191,196);">sys_execve()</font> 就是处理 <font style="background-color:rgb(187,191,196);">execve()</font> 系统调用的函数,对用户请求进行初步审核和预处理;而 <font style="background-color:rgb(187,191,196);">do_execve()</font> 则是是内核中真正执行程序加载和执行关键操作的函数,属于 <font style="background-color:rgb(187,191,196);">sys_execve()</font> 处理流程的下一级,真正去操办程序加载运行的各项事务。
它们共同协作,完成从用户请求运行程序到程序成功加载运行的整个过程。
search_binary_handler()和load_elf_binary()
<font style="background-color:rgb(187,191,196);">search_binary_handler()</font>:负责识别文件格式,通过遍历格式处理器链表找到能处理当前文件的加载器。
<font style="background-color:rgb(187,191,196);">load_elf_binary()</font>:负责 ELF 文件的具体加载,包括内存映射、环境设置、动态链接等,最终让程序得以执行。也就是让程序可以静态调试的主要函数。
_start和main()
没什么好说,就是启动后让用户可以看到程序的最后一步。
动态连接的程序的执行过程

相比于静态储存,多了一个ld.so和__libc_start_main()和_init。这些是额外处理共享库加载、初始化和运行时支持。
分别依赖库是ld.so解决函数库地址映射关系之类的,加载是__libc_start_main()把这些东西放到,初始化是_init。







