第一次接触攻防世界的PWN新手区,入门题 get_shell 几乎是所有人绕不开的一道坎。它的目标很直白:利用二进制漏洞,让目标程序执行system("/bin/sh"),从而拿到一个 shell。我记得自己刚刷这道题时,卡点根本不在“会不会溢出”,而在工具链不熟、偏移算错、本地通了远程却莫名挂掉。这篇文章就把这三个坑逐个拆开,顺着完整做题链路走一遍:拿到文件怎么分析、漏洞点怎么定位、exp 怎么写、本地远程怎么切换,最后再说几句后续方向。
1. 为什么这道入门题叫 get_shell
1.1 PWN 题目里的 “get shell” 到底指什么
PWN 这个分类在 CTF 里全称是 binary exploitation,直译是“二进制漏洞利用”。出题人会给一个编译好的可执行程序,里面故意留一个漏洞,你的任务是通过输入数据或者交互,让程序执行本不该执行的逻辑。最终目标通常是拿到一个 shell,也就是进入程序的运行环境,然后读取 flag。
“get_shell”这个名字本身就是答案:它告诉你这道题不需要复杂的 ROP 链、不需要先泄露 libc 地址,所有的考点都指向一件事——把程序执行流劫持到一段能弹 shell 的代码上。新手区经常用它来展示“栈溢出 + 控制返回地址”的最小完整闭环。题目通常有两种长相:一种是程序里自带一个shell()/win()/getshell()后门函数,函数体就是system("/bin/sh");另一种是只有明显的栈溢出点,需要你自己构造返回地址去执行一个图书馆里的 libc 函数。攻防世界这道经典版属于前者,这也是为什么它被称为入门第一题。
1.2 前置知识:栈帧、返回地址、system 函数
要理解这道题,必须先把“函数调用时栈上发生了什么”想清楚。每次调用函数,CPU 都会把返回地址压栈。返回地址是调用者中下一条指令的地址,函数执行完ret指令时,CPU 会从栈顶弹出一个值,直接跳转到那里继续执行。这个机制是栈溢出利用的核心。
举个例子,函数 A 调用函数 B:
- 调用前,参数放入寄存器或压栈。
call B指令把 A 中call的下一条指令地址压入栈。- B 函数开头会保存旧栈底,然后开辟自己的栈空间,比如
char buf[0x10]。 - B 返回时,先恢复栈底,再执行
ret,弹出返回地址,跳回 A。
如果 B 里读入数据时没有限制长度,你就能把数据一路写到返回地址所在的位置,覆盖它。覆盖成一个你想执行的函数地址,比如system的地址,程序返回时就会自动跳过去。get_shell 这道题,说白了就是三步:找到一个可控的缓冲区、算清到返回地址的偏移、把返回地址改成后门函数地址。
2. 分析二进制前的三个固定动作:file、checksec、看反汇编
2.1 file 和 checksec 拿到手的两个答案
拿到题目压缩包,解压后第一步永远是file命令。它能告诉你三件最基础的事:架构是 64 位还是 32 位,是否被 strip 过,以及链接方式。架构直接决定后续用p64还是p32,这个搞错了,exp 一定通不过。
第二步是checksec。如果安装了 pwntools,终端里直接执行:
checksec --file=get_shell输出会包含 RELRO、Stack Canary、NX、PIE 这几项信息。我把这道题常见的结果和含义整理成一张表:
| 保护项 | 常见状态 | 对你意味着什么 |
|---|---|---|
| RELRO | Partial | .got表部分可写,暂时不影响本利用 |
| Stack Canary | No | 栈上没有金丝雀值,溢出不需绕过 |
| NX | Enabled | 栈不可执行,不能直接执行 shellcode |
| PIE | No | 程序基址固定,后门函数地址硬编码即可用 |
看到 No Canary + No PIE,基本就可以锁定方向:栈溢出 + 固定地址跳转。NX 开着也没关系,因为我们跳转的是程序自带的system("/bin/sh"),不是往栈里塞 shellcode。
2.2 反汇编:找到后门函数或读入点
这一步的目标是确认两件事:后门函数地址是多少,漏洞点在哪个函数、缓冲区多大。用 IDA 打开最简单,没有 IDA 也可以用objdump -d和readelf顶住。我这边分析的版本,反汇编后看到的逻辑大致是这样:
int shell() { system("/bin/sh"); return 0; } int main() { char buf[0x10]; read(0, buf, 0x30); return 0; }注意read的第三个参数 0x30 是 48 字节,而buf只有 0x10 即 16 字节,一瞬间就溢出了 32 字节。后门函数shell的地址固定不变,这就是我们返回时要跳的地方。
如果 IDA 伪代码里看不到这些,还可以用objdump -d -M intel get_shell | grep system快速找system的调用点,再往上翻函数名。
2.3 IDA 与命令行工具在这个阶段怎么选
我刚入门时有个误区:总是想用最新最强的工具,其实这题用命令行工具就够了。objdump看汇编,readelf -a看符号和节区,gdb看栈。IDA 的好处是伪代码直观,新手不容易迷失在汇编细节里。我的建议是:先用file+checksec建立画像,再用 IDA 或 Ghidra 看伪代码,看到疑似溢出点后,回到命令行用 gdb 验证。工具只是辅助,核心是把“漏洞点、偏移、目标地址”这三角榨出来。
3. 栈溢出的关键链路:从 read 到返回地址劫持
3.1 漏洞点长什么样:一个明显的 read
只要你看到类似read(0, buf, 0x30)、gets(buf)的代码,就要立刻敏感起来。read本身不追加\0,你能精确控制写入的字节;gets则一直读到换行或 EOF,还会自动补\0。两者都能制造溢出,但 payload 处理细节略有不同。
回到伪代码:
char buf[0x10]; read(0, buf, 0x30);栈上的布局从高地址到低地址大致是:
高地址 ----------------------------- main 的返回地址 <- 我们要覆盖这里 ----------------------------- main 保存的 rbp <- 8 字节 ----------------------------- buf[0x0] 到 buf[0xf] <- 16 字节 ----------------------------- 低地址当你输入 48 字节时,前 16 字节填满 buf,第 17 到第 24 字节覆盖了保存的 rbp,第 25 到第 32 字节恰好覆盖返回地址。这是最常见的 64 位布局。32 位下没有保存的 rbp 占用一层,相对简单,但思路一致。
3.2 覆盖返回地址:程序执行流如何被劫持
覆盖完返回地址后,main执行到ret指令,CPU 从栈顶弹出你写的垃圾数据,然后尝试跳转。那 8 个字节如果是后门函数的地址,程序就直接跳到system("/bin/sh"),你得到一个 shell。如果填的是无效地址,程序会 segfault。所以判断一个 payload 成不成功,不一定要看到程序不崩溃,关键看有没有弹出 shell。
需要注意,后门函数地址在 64 位程序里通常长这样:0x4005b6。写 payload 时用p64(0x4005b6)打包成小端序字节。小端序意味着低字节在前,直接0x4005b6的十六进制字节表示是\xb6\x05\x40\x00\x00\x00\x00\x00。很多新手看到奇怪字节就懵了,其实是打包规则在起作用。
3.3 ASLR 和 PIE 挡不住的后门地址
这题为什么说简单?因为NX Enabled和ASLR都拦不住它。ASLR 随机化的是栈地址、堆地址和共享库加载地址,但程序自身代码段在非 PIE 情况下固定加载在0x400000附近。后门函数地址属于代码段,所以无论系统和远程怎么随机化,地址都不变。这也是 checksec 里 No PIE 这么重要的原因——没有 PIE,你写在 payload 里的硬编码地址就是最终答案。
4. 偏移量计算:别再把 buf 长度当成唯一答案
4.1 为什么偏移不是 buf 大小那么简单
这道题新手最容易翻车的地方就是算偏移。看到buf[0x10],很多人直接填 16 字节再覆盖返回地址,结果本地一跑就崩。原因很简单:栈上还有一层保存的 rbp。
64 位程序函数开头通常这样:
push rbp ; 把调用者的 rbp 压栈 mov rbp, rsp ; rbp 作为新的栈底 sub rsp, 0x10 ; 给 buf 分配 16 字节此时栈空间从 rbp 往下是 buf,从 rbp 往上依次是保存的 rbp 和返回地址。buf 到返回地址的偏移 = 16 字节 buf + 8 字节保存的 rbp = 24 字节,也就是 0x18。32 位下没有多出来的 8 字节,所以是 0x10。但这并不是定死的,编译器可能因为对齐等原因多分配一点空间,所以必须动态验证。
4.2 用 gdb + cyclic 实测偏移
不要靠肉眼看栈布局,用工具实测最稳。pwntools 自带的cyclic可以生成无重复的字符串,配合 gdb 使用:
from pwn import * # 本地生成一段 100 字符的填充 print(cyclic(100))把输出存到文件,用 gdb 跑目标程序:
gdb ./get_shell run < cyclic_input.txt程序崩溃时观察RIP寄存器,比如 RIP 变成0x6161616c,然后拿去算偏移:
from pwn import * print(cyclic_find(0x6161616c))返回的数值就是 buf 到返回地址的真实偏移。这个值为 24 时,印证了我们刚才 0x18 的推断。强烈建议任何栈溢出题都走一遍这个过程,尤其是 32 位和 64 位程序混着刷的时候,别凭感觉猜。
4.3 32位与64位栈布局差异
我在刷题时经常在两种架构间切换,总结出一点规律:64 位程序的参数传递通过寄存器,栈上布局相对规整;32 位程序参数全部压栈,栈布局更直接,但地址打包用p32,长度只有 4 字节。如果 payload 里本应 8 字节的地址你只给了 4 字节,ret时会从一个错位地址取指令,直接崩掉。所以拿到文件先确认架构,这是 exp 能跑通的第一前提。
5. 编写第一个 exp:本地验证与远程拿 flag
5.1 pwntools 脚本骨架
pwntools 是 PWN 题最常用的 Python 库,它把交互、打包、调试集成得很顺手。一个基础 exp 长这样:
from pwn import * context.arch = 'amd64' context.log_level = 'info' # 本地调试时用 process,远程打时换成 remote sh = process('./get_shell') # sh = remote('题目给的地址', 端口) offset = 0x18 shell_addr = 0x4005b6 # 反汇编里看到的后门函数地址 payload = b'A' * offset + p64(shell_addr) sh.recvuntil(b'Input:') # 根据实际交互提示调整 sh.sendline(payload) sh.interactive()注意三处小细节:shell_addr必须换成你自己反汇编出来的值;recvuntil的内容取决于题目有没有提示文字;如果题目没有提示,可以直接用sleep(0.5)再发送。interactive()会把当前输入输出交给你手动操作,拿到 shell 后就能敲命令。
5.2 本地调试与远程连接的切换
本地和远程切换不只是改一行代码,背后问题完全不同。本地连的是process,你拥有文件系统和调试器,崩溃了可以马上看栈;远程连的是remote(host, port),这个端口是题目平台动态开启的,背后实际跑着目标程序,你要保持网络交互稳定。建议顺序是:本地先把偏移和后门地址跑通,再用相同 payload 打远程,不要在远程上反复试偏移。
远程连接后,目标程序可能需要一段预热时间,也可能一个sleep就超时。保险做法是:
sh.recvuntil(b'Input:')如果题目没有输出任何提示,就直接sendline(payload),然后立刻interactive()。这个等待提示符的小动作,经常成为本地通、远程挂的分水岭。
5.3 交互技巧:什么时候 sendline,什么时候 send
如果题目漏洞点是gets,必须用sendline(payload),因为gets会一直读到换行才返回。如果漏洞点是read,它读到指定长度就返回,不依赖换行,理论上用send更干净,因为 payload 内部如果碰巧出现0x0a(换行)会被误认为是输入结束。不过这道题read读取长度是 0x30,而 payload 是 0x20 左右,都没到长度上限,所以sendline和send都能过。但养成好习惯:gets用一行,read用精确send,能规避很多恶心问题。
拿到 shell 后建议依次输入:
id ls cat flagcat flag就是本题的终点。有些环境 shell 不是 bash 而是 sh,ls、cat这些命令仍然通用,不用担心。
6. 本地通远程挂的常见翻车点
6.1 环境差异:libc、地址、aslr
明明本地弹了 shell,换到远程就毫无反应,这可能是最劝退新手的时刻。常见原因有三个:一是本地和远程的操作系统位数不一致,比如本地 64 位跑 32 位程序时缺少 32 位运行库;二是 PIE 状态不一致或题目实际开了 PIE 而你没看 checksec;三是 libc 版本不同,如果你构造的 payload 需要依赖 libc 里的函数地址,远程大概率会变。
但对 get_shell 这道经典版,后门地址在程序自身代码段,无 PIE 时不受上述影响。如果本地能通远程不通,优先检查通信层,而不是地址层。
6.2 网络交互问题:没等提示符就发 payload
远程连接不是本地进程,发起remote后,程序可能在等待你输入前已经打印了一段提示文字。如果你立刻sendline,可能出现两种情况:把 payload 发得太早,提示文字还没有输出完,交互乱了;或者程序根本没有进入read,你的 payload 变成垃圾。正确的争取方式是先用recvuntil或recv()把输出接收干净,再发送 payload。实在不确定输出内容,可以先用sh.recv(4096)打印出来看看。
6.3 pwntools 和 Python 版本带来的隐性坑
另一个容易被忽略的是环境版本。老教程里大量 exp 是 Python 2 写的,现在默认 Python 3,差异主要在字符串类型和编码。Python 3 里发送字节串要用b'A',不能用字符串加地址直接拼接。pwntools 自身不同版本对remote、process的默认行为也有微调,遇到报错先看版本:
python3 -c "from pwn import *; print(pwnlib.__version__)"如果本地没有 pwntools,安装很简单:
pip install pwntools但不少 Linux 发行版默认权限限制pip全局安装,遇到权限报错就加--user。这一套环境问题没解决,题目其它环节再熟练也跑不起来。
7. 刷完这道题之后该往哪走
7.1 ret2text 是 ROP 链条的第一环
get_shell 的利用手法有个正式名字:ret2text,返回地址指向程序自身已有的危险函数。所有返回导向编程的后续姿势——ret2shellcode、ret2libc、ret2syscall、栈迁移——本质都是在做同一件事:控制返回地址,逃出原有执行流。只不过越来越难,因为要绕过 canary、PIE、NX、RELRO,还要找到合适的 gadget。
你现在掌握了最小的闭环:确定偏移、选个目标地址、用p64打包、发送、交互。后面的一切膨胀都从这套闭环展开。
7.2 后续题目的主线:ret2libc、ret2syscall、栈迁移
接着可以按这几步爬坡:
- ret2shellcode:找一个可写可执行的内存区域,把 shellcode 直接放进去并跳过去。NX 开启后这条路被堵死,所以你会先学会判断 NX。
- ret2libc:程序里没有后门函数时,你需要让程序调用 libc 里的
system,前提是知道 libc 版本和基址,于是引入泄露地址、计算偏移。 - ROP 链多步跳转:一个函数解決不了问题时,用
pop; ret等 gadget 串联多个调用。 - 栈迁移:溢出空间不足时,把栈指针搬到可控内存,再接 ROP。
每条线背后都是保护机制的对抗。所以我在刷题时会给自己一个固定动作:每拿到一道新题,先不急着看 wp,自己把 checksec 结果和漏洞点推测写在笔记里,猜它会考哪一种手法,再动手验证。这个习惯能让你把零散经验收拢成体系。
7.3 一个关于调试的小习惯
最后说一个我实际调试中养成的习惯:本地进程崩溃不一定是坏事。当你看到SIGSEGV时,先打印 RIP 或 EIP,它直接告诉你当前执行流到了哪里。如果 RIP 落在一串0x61上,说明偏移没算对;如果 RIP 恰好是后门函数地址,但 shell 没弹出来,那可能卡在system("/bin/sh")的/bin/sh参数上或/bin/sh交互没接管。先看寄存器、再对栈、最后查输入,这套排查顺序在处理任何 PWN 题时都通用。
回到 get_shell 本身,我之前反复踩偏移和交互的坑,后来发现只需要始终记住三件事:先看架构再定p64还是p32,用 cyclic 测偏移而不是估算,远程交互前先收干净输出。把这三件事刻进肌肉记忆,后面刷 ret2libc 时会感谢前面这个简单的开始。