workspace/how2pwn/
How2pwn
从计算机基础到二进制安全利用的学习路径。
- 1-4 ret2libc
在前面几章的学习中,我们都提供了一些方便的 gadget 或后门函数,那么如果这些东西都消失了我们应该怎么办?
libc
libc是 Linux 程序中最基础、最常用的一类函数库,可以理解为程序和操作系统之间的“工具箱”。在编写 C 程序时,我们经常会使用下面这些函数:
printf("Hello\n"); strlen("hello"); malloc(100); free(ptr); read(fd, buf, 100);这些函数大多不是程序员自己实现的,而是由 libc 提供。
libc 中常见的功能包括:
- 输入输出,例如
printf、puts、scanf - 字符串处理,例如
strlen、strcpy、memcpy - 内存管理,例如
malloc、calloc、free - 文件操作,例如
fopen、fread、fwrite - 进程控制,例如
fork、execve、exit - 对 Linux 系统调用的封装,例如
read、write、open
libc 中的函数通常并不是程序本身的一部分,而是存放在一个单独的共享库文件中。程序运行时,系统会将 libc 加载到进程的内存中,程序再调用其中的函数
这样做有几个好处:
不同程序都可以直接使用
printf、malloc等函数,不需要每个程序重新编写一遍。多个程序可以共享同一份 libc,而不需要把所有库函数的代码都复制到自己的可执行文件中。
Linux 内核真正提供的是系统调用,但直接使用系统调用比较麻烦。libc 会将它们封装成更容易使用的函数。
例如:
程序调用 read() ↓ 进入 libc 中的 read 函数 ↓ libc 发起 read 系统调用 ↓ 进入 Linux 内核读取数据需要注意的是,并不是所有 libc 函数都会进入内核。
- 输入输出,例如
- 1-3 ret2syscall
在上一章节中简短的提到了一些 syscall 相关的内容,在本章我们将详细讲解 ret2syscall 的具体技术细节与 syscall 当然此处的 ret2syscall 并不能完全算是标准的 ret2syscall ,因为硬要说的话,你的 syscall gadget也在text段,何尝不是一种 ret2text呢?
syscall
Linux 中的 syscall(系统调用),是用户态程序向内核请求服务的接口
普通程序运行在权限较低的用户态,不能直接操作硬件、进程或内核数据 因此,当程序需要执行以下操作时,就必须通过系统调用进入内核:
read:读取文件或输入write:写入文件或输出openat:打开文件mmap:映射内存fork/clone:创建进程或线程execve:运行新程序exit:退出进程
以 x86-64 Linux 为例,执行系统调用时通常:
- 将系统调用编号放入
rax - 将参数依次放入
rdi、rsi、rdx、r10、r8、r9注意此处参数顺序,第四个参数不是rcx保存 - 执行
syscall指令 - 内核完成操作后,将返回值放入
rax
例如:
mov rax, 1 ; syscall 编号 1:write mov rdi, 1 ; 文件描述符 1:stdout mov rsi, message ; 字符串地址 mov rdx, 5 ; 字符串长度 syscall可以将 syscall 简单理解为:
用户程序进入内核并调用内核功能的一扇受控入口。
ret2syscall
依旧我们搭配一个 demo 理解这种攻击手法
点击查看
#include <stdio.h> #include <stdlib.h> char binsh[8] = "/bin/sh\x00"; void gadget(){ asm("pop %rax; pop %rdi; pop %rsi; pop %rdx; ret"); asm("syscall;"); } void vuln(){ char buf[0x20]; read(0, buf, 0x100); } void main(){ vuln(); }使用以下命令编译
- 1-2 ret2shellcode
接下来赶到现场的是 ret2shellcode ,虽然严格意义上来说,ret2shellcode 是一种比 ret2text 更早的攻击手法 主要的利用点即 ret 到提前准备的 shellcode ,但是因为 NX 的存在,可执行与可写存在巨大的矛盾,因此在新时代 ret2shellcode 以一些更奇特的形式存在 在本文中,将主要讲解旧时代的 ret2shellcode 以及构造 shellcode 的一些工具和手法
shellcode的编写
编写 shellcode 执行
/bin/sh,需要一点点 syscall(系统调用) 的知识,我们这里简单带过一下,后续会在 ret2syscall 中详细讲解计算机系统的各种硬件资源是有限的,在现代多任务操作系统上同时运行的多个进程都需要访问这些资源,为了更好的管理这些资源进程是不允许直接操作的,所有对这些资源的访问都必须有操作系统控制。也就是说操作系统是使用这些资源的唯一入口,而这个入口就是操作系统提供的系统调用(System Call)。在linux中系统调用是用户空间访问内核的唯一手段,除异常和陷入外,他们是内核唯一的合法入口。
在 x86-64 下,我们可以设置 rax 寄存器为对应的系统调用号,此时执行 syscall 时将会执行对应的系统调用
一般来说,我们希望执行
/bin/sh,就需要构造execve("/bin/sh\x00", NULL, NULL)执行 syscall 时的参数传递和我们之前提到的函数调用约定有一点细微的区别,这里暂时不细讲,但我们如果想要达成刚刚的execve("/bin/sh\x00", NULL, NULL)还是一样的即我们需要设置
- rax = 0x3b //0x3b 为
execve对应的系统调用号 - rdi = “/bin/sh”
- rsi = 0
- rdx = 0
因此可以写出一段很简单的 shellcode
- rax = 0x3b //0x3b 为
- 1-1 ret2text
在上一篇中,我们已经完成了一次最简单的栈溢出,在本章节中将开始学习 ROP 在此之前请确认你已有以下基础
- 函数调用约定
- pwntools使用
ROP
我们知道 NX 保护,他的存在使得我们并不能非常方便的输入段可执行的 shellcode 并让程序 ret 到这里,而 ROP 则是利用多段包含
retn指令的代码片段,间接控制程序运行流的攻击手法 ROP 通过利用程序自身存在的代码片段而不是自己手动输入的 shellcode ,因此可以极大程度规避 NX 的制约,而根据 ROP 中,我们找到的 gadget 来源或利用手段,可以简单的归类为 ret2text,ret2shellcode,ret2syscall,ret2libc等 这里的 gadget 也就是我们刚刚提到的原本程序中的自身存在的代码片段gadget
我们刚刚提到,gadget 是原本程序中存在的可以被我们复用的代码片段,最常见的 gadget 即包含了 pop 和 retn 的代码片段,例如
pop rdi retn假如我们执行了这段 gadget ,程序会从栈顶 pop 8字节的数据到 rdi 寄存器,并且执行返回,假如栈当前的结构如下
rsp ptr 此处地址指向"/bin/sh"字符串 rsp+8 system_addr 此处地址指向system()从这种构造来看,执行
pop rdi显然会将/bin/sh的地址给送进 rdi 寄存器,并移动 rsp 向高八字节,此时再执行retn将会执行 system(),而根据刚刚的 rdi 的值被设置为了指向 /bin/sh 字符串,因此构造出了system("/bin/sh");ret2text
刚刚介绍了如何利用 gadget 控制寄存器,那么接下来就是一个简单 demo
- 1-0 Stack_overflow
pwn
栈溢出基础
前面的文章分别介绍了寄存器、汇编、栈帧与调用约定 现在我们把它们串起来:如果程序存在栈缓冲区越界,攻击者能把程序带到哪里去?
这篇文章的目标是让你亲手完成一次最简栈溢出——覆盖返回地址,让程序跳到不该执行的地方。读完本文后,你将能够自己编译漏洞程序、用 GDB 定位偏移、用 Python 构造输入,为后续的 ret2text、ret2shellcode 等专项文章做好准备
什么是栈溢出
当程序向栈上的局部数组写入超过其容量的数据时,多出的部分会覆盖相邻内存。如果覆盖范围到达保存的返回地址,
ret指令就会取到一个由攻击者控制的值,从而跳转到任意地址。回顾计算机组成中的栈帧布局:
高地址 +-------------------------+ | 返回地址 | 由 call 压入 +-------------------------+ | 调用者保存的 RBP | +-------------------------+ | 局部变量、数组、填充 | ← 数组从这里开始,向高地址方向写入 +-------------------------+ 低地址C 语言不检查数组边界。如果
buffer[32]的写入没有长度限制,多出的字节就会沿栈"向上"覆盖,依次破坏局部变量、旧 RBP,最终触及返回地址。一旦ret执行,CPU 就会跳到攻击者选定的位置。演示程序
这里提供一个demo
点击查看
#include <stdio.h> #include <stdlib.h> #include <string.h> /* * win — 正常情况下不会被调用的函数 * 溢出利用的目标就是让程序"意外"进入这里。 */ void win(void) { printf("\n=================================\n"); printf(" 你成功了!程序跳转到了 win 函数。\n"); printf("=================================\n\n"); exit(0); } /* * vulnerable — 包含栈溢出漏洞的函数 * * gets 不检查目标缓冲区大小,超出 buffer[32] 的输入 * 会持续写入栈上更高地址,可能覆盖: * - 其他局部变量 * - 保存的 RBP * - 返回地址 */ void vulnerable(void) { char buffer[32]; printf("请输入一些文本:"); /* gets 不会限制读取长度 */ gets(buffer); printf("你输入了:%s\n", buffer); } /* * print_info — 打印可用于偏移计算的信息 */ void print_info(void) { printf("-----------------\n"); printf("win() 地址:%p\n", (void *)win); printf("-----------------\n\n"); } int main(int argc, char **argv) { print_info(); vulnerable(); printf("\n程序正常退出。\n"); return 0; }vulnerable 中
gets会一直读取直到换行,完全不管buffer只有 32 字节。win是一个正常情况下永远不会被调用的函数,我们的目标就是通过溢出覆盖返回地址,让程序"意外"进入win。 - 0-0 计算机组成初步
re · pwn
从 C 语言到汇编:二进制安全需要的计算机组成基础
我们平时编写 C 语言程序时,习惯用变量、循环、判断和函数来描述程序逻辑:
int add(int a, int b) { return a + b; }但是 CPU 并不认识
int、return或函数名。源代码经过编译器处理后,会被翻译成机器指令;机器指令本质上是一串二进制数据。汇编语言则为这些机器指令提供了便于人类阅读的写法。上面的函数可能对应如下汇编逻辑:add: lea eax, [rdi + rsi] ret对于普通软件开发,我们未必需要关心翻译后的结果;但在逆向工程、漏洞分析和恶意代码分析中,拿到的往往只有编译后的程序。此时,我们必须从寄存器、内存和汇编指令出发,重新理解程序做了什么。
本文不会完整讲授数字电路、流水线或缓存一致性等计算机组成原理课程内容,而是围绕二进制安全所需的主线展开:
硬件如何协作 → CPU 如何保存数据 → 汇编指令如何操作数据和改变控制流 → 函数如何使用栈与寄存器。
文中的汇编代码采用 x86-64 Intel 语法,运行环境默认为 Linux 用户态,函数调用遵循 System V AMD64 ABI。示例以讲清原理为第一目标,实际编译结果会随着编译器版本、优化等级和源代码写法发生变化。
计算机由哪些主要硬件组成
一台计算机包含很多部件。对于分析普通用户态程序,我们首先需要认识 CPU、内存、外部存储、总线和输入输出设备。
部件 主要作用 与二进制分析的关系 CPU 解释并执行机器指令,完成运算和控制 汇编指令最终在 CPU 中执行 内存(RAM) 临时保存正在运行的程序、代码和数据 代码、栈、堆和全局变量都位于进程的虚拟地址空间中 硬盘或固态硬盘 长期保存程序和文件,断电后数据仍存在 ELF 可执行文件最初存放在这里,运行时再由系统装入内存 总线 在 CPU、内存和设备之间传输地址、数据和控制信息 决定各硬件如何交换信息,但初学阶段不必深入电气细节 输入输出设备 让计算机与用户和外界交换数据 程序通常通过操作系统提供的接口访问这些设备 1. CPU 内部有什么
为了便于理解,可以把 CPU 内部的重要部分概括为:
Terminal
C0nvR3 Lab terminal ready. Type help for commands.