第4章:栈溢出基础原理——从ret2text到ret2shellcode

栈溢出,这大概是Pwn世界里最经典的入门题目了。我刚开始接触CTF那会儿,第一道能独立解出来的题就是栈溢出。说实话,当时看着那个跳转地址改来改去,感觉就像在玩一个「内存拼图」游戏。

这一章,咱们就把栈溢出的底裤扒干净。从原理讲到实战,从最简单的ret2text一路干到ret2shellcode。嗯,NX/DEP保护也会顺带聊一聊——毕竟你迟早要面对它。

4.1 栈缓冲区溢出到底是怎么回事?

先看一个最朴素的场景。你写了个C程序,在栈上开了个局部数组,然后调用了gets()或者strcpy()。用户输入的数据超过了数组长度,多余的数据就会往栈的高地址方向「溢出」。

为什么会这样?因为栈的生长方向是从高地址往低地址,而数组的填充是从低地址往高地址。你想想看,局部变量在栈上的布局通常是:

  • 高地址:返回地址(return address)
  • 中间:保存的EBP(old ebp)
  • 低地址:局部缓冲区(buffer)

当你往buffer里写数据,写满了就会覆盖EBP,再写就覆盖返回地址。一旦函数返回,CPU就会从那个被篡改的地址开始执行。

核心概念:栈溢出攻击的本质,就是通过溢出改写函数的返回地址,劫持程序的控制流。

我在项目中遇到过一种情况:有人觉得只要不用gets()就安全了。结果他用fgets()但size参数写错了,照样溢出。所以啊,API安全不是靠「换函数」解决的,得靠「算对长度」。

4.2 ret2text——最简单的劫持方式

ret2text,全称return to text。text段就是代码段,里面存着程序本身的指令。如果程序里已经有一段「后门函数」或者「win函数」,你只需要把返回地址改成它的入口地址就行。

举个例子:

// 目标程序
#include <stdio.h>
#include <string.h>

void win() {
    system("/bin/sh");
}

void vuln() {
    char buf[64];
    gets(buf);
}

int main() {
    vuln();
    return 0;
}

这个win()函数就在代码段里。我们只需要找到它的地址,然后通过溢出把vuln()的返回地址改成win()的地址。

怎么找地址?用objdump -d或者gdb都行。我个人习惯用readelf -s先看符号表,再用gdb确认。

小技巧:在32位程序里,返回地址通常从buffer起始偏移4字节(EBP)+ 4字节(返回地址)的位置开始覆盖。64位程序则要算上对齐和寄存器保存区,偏移量需要动态调试确认。

我曾经在打一个CTF的时候,发现目标程序里有个backdoor()函数,但地址里包含0x0a(换行符)。gets()读到换行就停了,导致地址写不全。那场比赛我卡了三个小时……最后用read()代替gets()才搞定。

4.3 ret2shellcode——没有后门就自己造

如果程序里没有现成的后门函数怎么办?那就自己写一段shellcode,塞到缓冲区里,然后让程序跳过去执行。

shellcode是一段机器码,通常用来启动一个shell(比如/bin/sh)。在x86 Linux下,一段经典的execve shellcode大概长这样:

; 32位 execve("/bin/sh", NULL, NULL)
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
xor ecx, ecx
xor edx, edx
mov al, 0x0b
int 0x80

这段代码只有23个字节。你把它放到buffer里,然后把返回地址指向buffer的起始地址,程序就会执行你的shellcode。

但这里有个坑——你得知道buffer在内存里的确切地址。以前没有ASLR的时候,地址是固定的,直接硬编码就行。现在嘛……

避坑指南:我曾经在调试一个ret2shellcode的题目时,发现shellcode明明写进去了,但程序就是崩。后来用gdb单步跟踪才发现,shellcode里有\x00字节,被strcpy()截断了。所以写shellcode时一定要避开坏字符。

4.4 NX/DEP保护——为什么ret2shellcode越来越难用了

NX(No-Execute)在Windows上叫DEP(Data Execution Prevention)。它的作用很简单:栈和堆上的数据不能被当作代码执行。

你想想看,如果栈不可执行,你把shellcode塞到buffer里,就算跳过去了,CPU也会直接报段错误。这就是为什么现在很多CTF题目里,ret2shellcode不再是首选方案。

NX/DEP的实现方式:

  • 硬件支持:CPU的NX位(AMD)或XD位(Intel)
  • 操作系统:通过页表项的NX标志位标记内存页是否可执行
  • 编译器:GCC默认开启-z noexecstack,链接时标记栈段不可执行

怎么判断一个程序有没有开NX?用checksec工具:

$ checksec --file=./vuln
RELRO           STACK CANARY      NX            PIE
Partial RELRO   No canary found   NX enabled    No PIE

看到NX enabled,就说明栈不可执行。这时候ret2shellcode就废了。

关键认知:NX保护的出现,迫使漏洞利用从「执行shellcode」转向「复用已有代码」——也就是后面章节要讲的ROP(Return-Oriented Programming)。

4.5 知识结构总览

下面这张图把本章的核心逻辑串起来了。从栈溢出触发,到两种利用方式,再到NX保护的影响,一目了然。

栈溢出利用知识结构图 栈溢出利用核心流程 栈缓冲区溢出 ret2text ret2shellcode 利用已有代码段函数 需要找到后门函数地址 在缓冲区植入shellcode 需要知道buffer地址 NX/DEP保护 栈不可执行 → ret2shellcode失效 无NX:ret2shellcode可用 有NX:需转向ROP

4.6 三种场景对比

我把三种常见情况整理成了表格,方便你对照着看:

场景 前提条件 利用方式 NX影响
ret2text 程序代码段有后门函数 覆盖返回地址为后门函数地址 不受影响(代码段可执行)
ret2shellcode 栈可执行,且知道buffer地址 植入shellcode,跳转到buffer NX开启后失效
ROP(后续章节) 存在足够多的gadget 拼接已有指令片段 不受影响(仍在代码段执行)

4.7 实战中的几个关键点

最后,我把自己踩过的坑总结成几条经验,你写exp的时候可以对着检查:

  • 偏移量一定要算准:用gdb的pattern create和pattern offset来定位返回地址的位置,别靠猜。
  • 注意字节序:x86是小端序,地址要反过来写。比如地址0x08048456在内存里是\x56\x84\x04\x08。
  • 坏字符过滤:如果输入函数是gets(),避开0x0a;如果是scanf("%s"),避开0x20(空格)和0x00。
  • 先checksec再动手:拿到题目第一件事就是看保护。开了NX就别想着ret2shellcode了,直接准备ROP。

个人习惯:我写exp的时候,喜欢先用Python的pwnlib库搭框架。它的p32()和p64()函数自动处理字节序,省心不少。