逆向与Pwn基础:指针的本质与栈溢出(Stack Overflow)底层原理
逆向与Pwn基础:指针的本质与栈溢出(Stack Overflow)底层原理
在上一篇文章中,我们剖析了 C/C++ 程序的虚拟内存布局和栈帧结构。我们知道,EIP(指令指针寄存器) 决定了 CPU 下一步要执行什么代码。
如果黑客能随意控制 EIP 的值,他就能让程序执行任何他想要的恶意代码(比如弹出一个 Shell)。而获取 EIP 控制权最古老、最经典的方法,就是利用 C 语言指针的“盲目信任” 来触发 栈溢出 (Stack Overflow)。
本文将结合一段极简的 C 代码,亲手撕开指针的伪装,在内存的微观视角下推演栈溢出漏洞的发生全过程。
1. 危险的基石:C 语言与指针的本质
1.1 指针到底是什么?
在 C/C++ 中,变量存储的是数据,而指针存储的是内存地址。 现代高级语言(如 Java, Python, Go)都在虚拟机层面接管了内存管理,严禁开发者直接操作物理地址。但 C 语言不同,它是一门“相信程序员永远是对的”的语言。
当你写下 strcpy(dest, src)(字符串拷贝)时,C 语言的底层执行逻辑是极其粗暴的:
它拿到 dest 指针指向的内存地址,然后把 src 里的字节一个一个地往那个地址后面塞,直到遇到代表字符串结束的 \0 为止。
1.2 盲目的信任与边界缺失
最致命的问题在于:C 语言在拷贝时,根本不会去检查 dest 所在的这块内存到底有多大!
如果 dest 只是一个长度为 10 的字符数组,而 src 是一个长度为 1000 的恶意字符串,strcpy 依然会毫不犹豫地把这 1000 个字节写进内存。
这就好比你只有一个能装 10 升水的水桶,却接上了消防栓,水必然会漫出来,淹没周围的土地。在内存中,“漫出来的水”就会覆盖掉相邻内存区域中的关键数据。
2. 栈溢出漏洞的微观推演
让我们结合上一篇讲过的栈帧结构,来看看这“漫出来的水”究竟淹没了什么。
2.1 漏洞代码示例
考虑下面这段存在经典栈溢出的 C 代码:
2.2 正常情况下的内存切面
当 vulnerable_function 被调用时,栈帧(Stack Frame)在内存中的布局如下(高地址在上,低地址在下):
物理排布的关键点:
- 栈的生长方向是从高到低(新分配的
buffer在Saved EIP的下面)。 - 但是,向
buffer中写入数据(字符串拷贝)时,地址的增长方向是从低到高(从buffer[0]一路往上写到buffer[15])。
2.3 溢出发生:劫持控制流 (Hijack Control Flow)
如果黑客在命令行传入了一个超长的恶意字符串,比如:
"AAAAAAAAAAAAAAAABBBBCCCC"(16个A,4个B,4个C)
strcpy 开始盲目地工作,从 buffer[0] 开始往上写:
- 前 16 个字节(
A,即十六进制0x41)正好填满了buffer。 - 接下来是 4 个
B(0x42)。strcpy继续往高地址写,这 4 个字节直接淹没(覆盖)了 Saved EBP! - 接下来是 4 个
C(0x43)。strcpy继续往上写,这 4 个字节直接淹没(覆盖)了 Saved EIP (返回地址)!
此时的内存切面变成了这样:
2.4 Pwn!系统沦陷
当 vulnerable_function 执行完毕,准备返回 main 函数时,CPU 的底层操作是执行一条 ret(Return)汇编指令。
ret 指令的动作极其简单:把当前栈上的那个 Saved EIP 弹出来(pop),然后把这个值硬塞给 CPU 的 EIP 寄存器。
CPU 根本不知道这个值已经被黑客篡改了。它读取到了 0x43434343,然后毫不犹豫地将 EIP 指向了内存地址 0x43434343 去执行下一条指令。
如果黑客提前在内存的某个地方注入了一段恶意机器码(叫做 Shellcode,比如调用 execve("/bin/sh")),并且把这 4 个 C 换成那段恶意代码的内存地址。那么当函数返回时,CPU 就会乖乖地跳转去执行黑客的代码,系统瞬间沦陷!
3. 现代操作系统的漏洞缓解机制 (Mitigations)
在 90 年代(如著名的 Morris 蠕虫病毒),这种原始的栈溢出攻击让互联网付出了极其惨痛的代价。 为了对抗这种攻击,现代操作系统和编译器引入了三大核心缓解机制。如今写 Pwn 漏洞利用,本质上就是与这三大机制斗智斗勇。
3.1 DEP / NX (数据执行保护 / 不可执行)
- 原理:早期系统的栈区是可执行的(R W X)。黑客可以直接把 Shellcode 塞进
buffer,然后让 EIP 指向buffer的地址。 - 防御:现代 CPU 配合 OS,将栈区(Stack)和堆区(Heap)的内存页标记为 NX (No-eXecute,不可执行)(R W -)。
- 黑客的反制:ROP (Return-Oriented Programming,面向返回的编程)。既然不让我在栈上执行代码,那我就去代码段(
.text,这里肯定是可执行的)里找现成的、零碎的合法代码片段(Gadgets),把它们的地址像穿冰糖葫芦一样铺在栈上,拼凑出恶意的逻辑。
3.2 ASLR (地址空间布局随机化)
- 原理:如果每次程序运行,栈的地址和加载的动态库(如
libc)地址都是固定的,黑客很容易猜出跳转地址。 - 防御:ASLR 会在每次程序启动时,将栈、堆、动态库的加载基址完全随机化。
- 黑客的反制:寻找程序中未随机化的部分,或者利用信息泄露 (Information Leak) 漏洞,先打印出某个已知函数的真实地址,然后根据偏移量(Offset)计算出整个内存布局的基址,从而绕过 ASLR。
3.3 Stack Canary (栈金丝雀)
- 原理:这是专门针对栈溢出覆盖 EIP 设计的编译器保护。
- 防御:编译器在生成代码时,会在局部变量(
buffer)和 Saved EBP 之间,插入一个随机生成的 4/8 字节的随机数(Canary)。 - 工作逻辑:当发生溢出时,水漫金山,必定会先覆盖掉 Canary,然后再覆盖 EBP 和 EIP。当函数准备返回(
ret)前,编译器插入的校验代码会去检查这个 Canary 有没有变。如果发现变了,说明发生了溢出,程序直接报错*** stack smashing detected ***并强行终止,绝不给黑客劫持 EIP 的机会。 - 黑客的反制:想办法利用其他漏洞(如格式化字符串漏洞)提前把 Canary 的值读出来,然后在构造恶意字符串时,把正确的 Canary 原封不动地填在对应位置,实现完美“瞒天过海”。
4. 总结
指针是 C/C++ 强大性能的源泉,也是所有内存安全噩梦的开始。
栈溢出的本质,是“数据的边界”突破了“控制流的边界”。
黑客利用盲目拷贝的函数(如 strcpy, gets, sprintf),用超长的数据覆盖了栈帧底部的返回地址 (Saved EIP),从而实现了对 CPU 指令指针的劫持。
理解了这段微观的内存推演,你就能真正看懂 Pwn 题中那些神奇的 A * 16 + B * 4 + Target_Address 载荷构造公式,也就能明白为什么现代安全开发规范中,绝对禁止使用 strcpy,而必须强制使用带有长度限制的 strncpy 或 snprintf。
下一篇预告: 至此,底层二进制世界的基础我们已经讲透。接下来,我们将浮出水面,进入当今最活跃的安全战场——Web 前端安全。 下一篇,我们将抛开后端的 SQL 和 PHP,直击前端的灵魂:浏览器 DOM 树、BOM 对象,以及 JavaScript 诡异的单线程事件循环 (Event Loop) 机制。这也是深入理解 XSS 漏洞的必经之路!