青岑ctfNoShell
第一次 ORW:NoShell 详细复盘
前言
这题名字叫 NoShell,题目本身也在明显暗示”没有 shell”。实际做下来,这个提示是真的,不是烟雾弹。
程序里确实有 /bin/sh,也确实存在 system() 调用点,但由于 seccomp 额外 ban 了 execve,所以所有 ret2system / ret2sh 的路线都会死。真正可行的路线是最朴素的 ORW:
openreadwrite
这题真正恶心的地方,不是 ROP 本身,而是两个细节:
- 菜单输入混用了
scanf("%d")和read(0, ...) - 远程容器里的真实 flag 文件名不是程序里写死的
flag.txt
题目初看
先看二进制基础属性:
- 架构:
amd64 No PIENo CanaryNX enabled- 动态链接
整体程序逻辑
程序启动时会做两件事:
- 初始化 IO 缓冲
- 加 seccomp
主流程大概是:
- 问你
Do you want to say something? - 再问
leave or capture the flag? - 进入一个循环菜单
菜单里关键的几个选项是:
1. Check your power2. Get_the_power_of_your_cat3. Open the door4. Destroy this world
其中真正有关的函数只有三个:
Check_your_power()Get_the_power_of_your_cat()open_the_door()
漏洞点分析
1. Check_your_power()
这个函数是栈溢出的核心:
这里原本是一张
Check_your_power()的反编译截图。
栈缓冲区只有 0x20,但读入长度由全局变量 off 决定。
程序初始时 off = 0x10,这当然溢不出来。但菜单选项 2 会把它改成:
这里原本是一张把
off改到0x100的关键逻辑截图。
于是流程就变成:
- 先选
2,把off改成0x100 - 再选
1,触发read(0, buf, 0x100)
此时溢出就是稳定的。
偏移也不复杂:
- 缓冲区:
0x20 - 保存的
rbp:0x8
所以覆盖返回地址的偏移是:
1 | 0x20 + 0x8 = 0x28 |
2. open_the_door()
反汇编非常直白:
这里原本是一张
open_the_door()的反汇编截图。
这个函数本身不会泄露 flag,它只是告诉你:
- flag 文件相关路径大概率和
flag.txt有关 - 题目希望你自己去读文件
所以它更像是一个“提示函数”,而不是“拿 flag 函数”。
3. destroy_this_world()
这里原本是一张
destroy_this_world()中system()调用点的截图。
这个函数里有 system(),而且字符串区里也真的存在 /bin/sh。
这就是本题第一个很容易误判的地方。
看到这里第一反应肯定是:
- 能不能走 ret2system
- 能不能改参数为
/bin/sh - 能不能直接调用菜单逻辑拿 shell
答案是不行,因为 seccomp 卡掉了。
seccomp 分析
程序的 seccomp 初始化逻辑非常关键。它用的是黑名单方式:
这里原本是一张 seccomp 过滤逻辑的截图。
这里的 59 是 amd64 下的 execve。
这就意味着:
execve不允许- 但
open/read/write都还活着
所以这题不是“完全沙箱”,而是“禁止 shell”。这类题最稳的思路就是 ORW。
也正因为如此,题目名 NoShell 其实是在直接告诉你正确方向。
第一版思路:固定读 flag.txt
最先能想到的一版 ROP 很自然:
- 先调用
open_the_door() - 再
open("flag.txt", 0) read(fd, .bss, 0x30)write(1, .bss, 0x30)
ROP gadget 也很够用:
这里原本是一张第一组 ROP gadget 的截图。
这里原本是一张第二组 ROP gadget 的截图。
其中 mov rdi, rax ; ret 很关键,因为 open() 的返回值在 rax,后续 read(fd, ...) 需要把它搬到 rdi。
这一版本地很快就能打通,甚至能读出测试用的 flag.txt。
但后面远程不通,说明还有别的问题。
第一个坑:scanf("%d") 和 read() 混用
这是整题最阴的点,也是最该写清楚的坑。
菜单读取选择时用的是:
1 | scanf("%d", &choice); |
真正的溢出读入用的是:
1 | read(0, buf, off); |
如果把这两步当成“同一个 fd 上顺序消费字节”,很容易直接这样发:
1 | io.sendafter(b"your choice: ", b"1" + payload) |
看起来很合理,但实际上glibc的 scanf 不是裸 syscall,它有自己的 FILE 缓冲。
真实发生的是:
scanf("%d")读到字符'1'- 它还会继续看下一个“非数字字符”来决定整数结束
- 这个非数字字符会被它预读进
stdio缓冲区 - 后面的
read(0, ...)是内核级读,不会从stdio缓冲里拿数据
结果就是:
- 我们送进来的 payload,第一个字节先被
scanf吞掉了 read()拿到的是“少一个字节”的剩余内容- 整条 ROP 整体左移 1 字节
这个现象在 GDB 里非常明显,栈上会出现一堆畸形地址,比如:
1 | 0x8700000000004013 |
如果看到这种“高位是原 gadget,低位全乱掉”的地址,基本就要想到是不是发生了字节错位。
正确做法
不能把菜单输入和溢出数据一次性塞进去。
要拆成两次:
- 先发
1X - 稍等一小会,让程序真正进入
read(0, ...) - 再发 payload
这里的 X 是牺牲字节,专门拿给 scanf 吃的。
最终脚本里的处理是:
1 | io.sendafter(b"your choice: ", b"1X") |
这一步修完之后,本地 ORW 就稳定了。
第二个坑:错误的二段栈迁移
在本地打通固定路径之后,下一个直觉是把“路径”也做成可控输入。因为远程有可能不是 flag.txt。
开始尝试典型的二段打法:
- 第一段 ROP 把第二段读到
.bss + 偏移 - 栈迁移到那块内存
- 第二段做真正的 ORW
思路没问题,但第一次实现犯了一个很典型的错误:
- 想当然地把第二段丢到了
.bss + 0x300
问题是这个 ELF 的 .bss 非常小,只有 0x30,而整个 RW 段边界也是有限的。
结果就是:
.bss + 0x300根本不在已映射可写段里- 栈一迁过去直接
SIGSEGV
这个坑很值得单独记一下:做无 PIE 题的时候,不要把 .bss 当无限大缓冲区。最好先看:
1 | readelf -S noshell |
确认:
.bss起始地址.bss大小- RW LOAD 段范围
之后再决定 staging 放哪。
第三个坑:远程环境和本地环境不一致
这题最坑的一点,是程序字符串里明明写着 flag.txt,open_the_door() 也确实在打开 flag.txt,但新远程容器里实际存在的文件名还是flag(好好好,虽然平时都是cat flag,但还是被题目flag.txt迷惑了)
这会导致一种非常迷惑的现象:
- 本地构造
flag.txt测试,一切正常 - 远程也能成功执行
open/read/write - 但读出来的不是 flag,而是一串垃圾
为什么会是垃圾?
因为固定路径版做的是:
1 | fd = open("flag.txt", 0); |
如果 open() 失败,返回值就是 -1。后面继续 read(-1, ...),缓冲区不会被正确填充,最后 write(1, buf, 0x30) 就会把旧内容或未初始化内容打出来,看起来就像“ROP 跑了,但读到一堆玄学垃圾”。
最终思路:单阶段任意路径 ORW
与其继续做复杂的多阶段栈迁移,不如把路径读取也塞进同一条链里。
题目里单次溢出窗口是 0x100,刚好够用。
最后的单阶段链逻辑是:
- 先
read(0, .bss, PATH_SIZE),把我们后发的路径字符串读到.bss open(.bss, 0)read(fd, .bss, 0x30)write(1, .bss, 0x30)
好处是:
- 不需要栈迁移
- 不依赖第二段 staging
- 远程只要路径猜对就能直接出 flag
利用时流程变成:
- 正常进菜单,把
off改成0x100 - 触发
Check_your_power() - 先送 ROP
- 再额外送路径,比如
flag
这也是最终脚本中 build_orw_chain(None) 的含义:路径地址先留空,由链自己先读入。
最终利用脚本
最终脚本如下:
- 本地/远程
- 固定
flag.txt - 任意路径读取
- 自定义
HOST/PORT
1 | from pwn import * |
远程命令:
1 | python3 exp.py REMOTE HOST=docker.qingcen.net PORT=42772 PATH=flag |
得到输出:
1 | Your power: |
另附人教版(上面为AI版):
1 | from pwn import * |
这题最值得记住的两个教训
1. scanf 和 read 混用是经典输入坑
如果之后再遇到:
- 菜单选择用
scanf - 漏洞触发用
read
那就要本能警惕 stdio 缓冲预读问题。很多时候你看到的“奇怪错位崩溃”,不是 gadget 不对,而是输入早就被前一个 scanf 吃掉一部分了。
2. 不要过度相信二进制里的明文路径
flag.txt 确实在程序里,但远程容器真正的文件名是 flag。这类题如果本地和远程不一致,最稳的解法就是尽早把“路径可控”做出来,而不是继续赌出题人会完全按本地附件部署。
结果验证
1 | flag{941480e5-f7e4-485f-9695-556d4d023176} |
- 合法授权:本文所述技术仅适用于已获得明确书面授权的目标或自己的靶场内系统。未经授权的渗透测试、漏洞扫描或暴力破解行为均属违法,可能导致法律后果(包括但不限于刑事指控、民事诉讼及巨额赔偿)。
- 道德约束:黑客精神的核心是建设而非破坏。请确保你的行为符合道德规范,仅用于提升系统安全性,而非恶意入侵、数据窃取或服务干扰。
- 风险自担:使用本文所述工具和技术时,你需自行承担所有风险。作者及发布平台不对任何滥用、误用或由此引发的法律问题负责。
- 合规性:确保你的测试符合当地及国际法律法规(如《计算机欺诈与滥用法案》(CFAA)、《通用数据保护条例》(GDPR)等)。必要时,咨询法律顾问。
- 最小影响原则:测试过程中应避免对目标系统造成破坏或服务中断。建议在非生产环境或沙箱环境中进行演练。
- 数据保护:不得访问、存储或泄露任何未授权的用户数据。如意外获取敏感信息,应立即报告相关方并删除。
- 免责范围:作者、平台及关联方明确拒绝承担因读者行为导致的任何直接、间接、附带或惩罚性损害责任。
- 先授权,再测试
- 只针对自己拥有或有权测试的系统
- 发现漏洞后,及时报告并协助修复
- 尊重隐私,不越界




