前言:最近在同学家玩,忙里偷闲打一打 nepctf,纯手搓的 wp,能不能给我个惊喜嘿嘿 ^_^
其实打这场比赛主要是为了学一点新知识,大一结束了,转头发现其实大一也没打过什么有意思的比赛,nep 的口碑一直挺不错的,想着学习学习。开了四个 pwn 两个 re 和一个 web 还有一个 realworld,其中 realworld 和一个 re 是直接让 ai 打得,不得不感叹 ai 确实是比人强得多,感觉除了强之外还有快,让所有 ctfer 在比赛当中为了拿血或高分不得不使用 ai 自动化开题,不过像这种公益性的比赛,我决定在自己学习的方向还是古法,途中也确实借助了大模型给出不少思路,这些思路也可以去学习,思考为什么这么去做,怎么能够想到这么去做,将 ai 当成一个合理学习的工具。
虽然这一次开题非常慢,也没有拿到一个较高的排名,但是很高兴的是能够靠自己手开出来这些题目,这种愉悦感还是很久违了,之前打比赛一直就是自动化开。
简单总结了三道 pwn 的做题思路,由于在同学家玩,乐不思蜀了属于是,写 wp 比较懒散,js 那道题的话之后会总结到博客上。
different_ROP
解题记录:

话说这道题目是 Hexagon 架构的,之前 VN2025 出过一道该架构的题目,但这场比赛的题目没有复现过,因此一开始写这道题目的时候也确实是有点束手无策了,那么就先学一点这个架构的知识。
解题思路:

先看一下题目,其实这里一开始打开还以为是常规的 x32pwn 哈哈,直到 IDA 打不开了才发现不是正常架构
于是搜了一下 QUALCOMM DSP6:

最开始尝试用 IDA 打开这个题目去分析,却发现没有 ida-hexagon 插件打不开,于是先去装了个插件:
https://github.com/gsmk/hexagon

很可惜,打开之后不支持 F5 伪代码,那就只能去看汇编找溢出了,像这种异构 pwn 基本上都是找栈溢出,简单看了一下汇编代码,像是 mips 架构和 x64 的架构的混合版 emmmm,还是先去学习一下 hexagon 架构汇编:
学习 hexagon:
依靠 x64 架构来学习 hexgon 还是比较快的:实际上差别也就是寄存器上了,调用原理都大差不差
1 | r0 r1 r2 r3 ... -> rax rbx rcx rdx |
学这么多差不多够了吧(心虚) emmmm……,开干
IDA 静态分析:

打开直接进入 start 函数,那么首要任务是去寻找 main 函数,但这里并没有被反编译出来,运行一下pwn文件:

找到 hint:
- Hint: on amd64 Linux, arguments travel through real registers.
- Hint: if a file is the goal, open/read/write is usually quieter than a shell.
因此有了初步的想法:打 ORW,IDA 直接找字符串定位 main 函数:


找一下交叉引用:Ctrl + X,于是乎直接找到了菜单

继续查找一下 sub_21350 函数的交叉引用:

进入到了 loc_21228 那么这里应该就是 main 函数了吧?读一读汇编:

上来就是调用菜单,我们改名为 menu:先分析这一部分
1 | .text:00021228 { call menu } |
猜测这里是读菜单输入,最多读 0x10 字节到 fp - 0x18,这个地方没有明显溢出。
之后会调用 sub_213B0,瞅一瞅:刚才是猜测,这里来验证一下这个函数:

没什么看下去的欲望,让 ai 分析了一份伪 C 代码:就是一个简单读取
1 | void sub_213B0(char *buf, int size) { |
然后:
1 | .text:00021238 { r0 = memub(fp + #var_18) } |
memub 是 memory unsigned byte :从内存读取 1 字节,并按无符号数扩展到寄存器
那就从 fp 寄存器读取一个字节,这里指的是我们输入的字符,-0x31,r 之后发现 0x31 实际上就是 1:

也就是 r0 = r0-1
接着: .text:00021244 { p0 = cmp.gtu(r0, #4) }
无符号比较 cmp.gtu:p0 = (r0 > 4),如果大于 4,说明不是合法菜单项,跳到 loc_21298,是个报错信息

若报错会返回 main 函数的最开始
ok、分析到了这里也就大概知道前面其实就是一个读取菜单 index 的服务,就没必要再继续分析了,按理来说漏洞点肯定在菜单函数指向的 12345 当中的函数,也就是这里:

1 | 输入 1 -> 0x21264 -> call sub_21430 |
运行 pwn 文件结合分析:
直接读汇编难度还是太大了,运行 pwn 文件能节省很多时间:

我们发现 1 实际上是直接打印,2 和 3 会让你输入一些东西,4 是 hint,5 是 exit
那么就是说漏洞 ORW 只存在于 2 和 3,
然而 2 叫做 set fake ROP register,并给出警告:stored. still no such architectural register, though
让我们不要把他当作真是寄存器利用,也就是 2 也是无效的,那么漏洞点只能存在于 3 了。
准备 K 题
我们现在明确了大致方向,漏洞点在 3,且利用手法是 ORW,分析一下 3:

1 | 0x215B8 r0 = #0 |
漏洞主要在这几行,sub_2B8C0 实际上类似于 read 函数
简单描述就是read(0, fp - 0x30, 0x40);,那么很明显,给了 0x30 的缓冲区却可以写 0x40 的内容
有栈溢出,写入长度又不够,那就很明显的栈迁移再打 ORW 喽
EXP 思路:
异构 pwn 和常见的 pwn 来说无非是寄存器不同,调用原理是基本一致的因此按照 32 位栈迁移+ORW 打即可:
打栈迁移养成好习惯,时刻记录 rbp:
先做一些准备工作
我们在 ORW 的时候是需要 syscall 调用的,需要找到类似于 pop_eax 这类的 gadgets,结果找到了这么一段:

有点类似于 csu 的那个样子,可以帮我们分配 r6、r0、r1、r2 寄存器,var 就是负偏移栈变量名
1 | fp + var_14 = fp - 0x14 |
这下我们调用 syscall 的时候就可以随意传参了
栈迁移
1 | payload1 = flat({ |
想要打 ORW 就必须保证可读可写,因此需要先迁移到 .bss 段上,修改一下 fp 然后跳回 read 函数

此时 fp = bss + 0x30,执行 read(0, fp-0x30, 0x40) 就直接写到 .bss 段上了。
那么接下来又存在一个问题:这个 read 只有 0x40 的大小可写,我们想要构造 ORW 的 payload 显然要远远超出这个长度,那么这个问题该如何解决?
syscall 调用 read
前面提到我们有 syscall 这个 gadgets 就可以进行其他的系统调用,我们就可以构造一个足够大的 read
1 | syscall = 0x21ab8 |
在 .bss 段构造如上 payload2,在 read 结束后的 fp = .bss + 0x30,最后会走到 dealloc_return

因此会执行 saved fp = *(bss + 0x30),leave,ret
因此 fp = bss+0x20,且跳转到 bss+0x20,之后执行 syscall,根据其调用规则再去构造一个 read
1 | fp + var_14 = fp - 0x14 |
为了构造read(0, addr, 0x400),就在 payload2 的一开始构造这样的 rop
1 | 0x00: p32(0x400), |
在这次read(0, addr, 0x400)我们就可以构造最后的 ORW 了
但是不要忘记在这次 syscall(read)结束之后还需要继续 saved fp = *(bss + 0x20),leave,ret
1 | 0x20: p32(addr + 0x20), |
因此给足 0x20 的大小放参数即可:p32(addr + 0x20)
ORW
1 | payload3 = ( |
这里就直接放 ORW 链子了,相信能写到这一步的人写个 ORW 肯定是比较轻松了,不多做解释了
总 EXP:
1 | from pwn import * |

onlyone
总而言之这道题目打下来给人感觉是东西太多了,添加了父子进程和管道,一开始写的时候就懵着写,读懂程序花了不少时间 emmm,不过好在还是很有收获的一道题目 ^_^
解题记录:

解题思路
开完题目后复现的,因此文件中直接包含 exp,无伤大雅,懒得整一个纯净文件了,重新 patch 怪麻烦
大概说一下从最开始到解出的关键步骤,大部分试错部分就不演示了:

依旧看一下防护以及 libc 版本:

版本给到 2.39,话说最近的比赛基本上都是 2.39 的版本,怎么都这么高啊 T _ T、、

IDA 静态分析:
直接找到 main 函数
1 | __int64 __fastcall main(int a1, char **a2, char **a3) |
pipe(pipedes) 是 Linux/Unix 里的系统调用,用来创建一个匿名管道,常用于父子进程之间通信。
之后无哦们看一下 sub_141F 这个函数:直接截到一张图里去分析

v1 = strlen(a1),而 a1 又是 sub_141F 传入的参数,之后 v1 作为参数 a3 传入了 sub_13B9 作为循环数
那么这里大概就明白了,见过很多这类的,实际上就是循环读入
我们直接把 sub_141F 改名成 write_0 增加可读性,

之后敏锐的察觉到这个 49,既然是匿名管道的系统调用,那么去查一下调用号 49 是什么:
目前的话没什么大用处,先留意一下

结合上面一小段伪 C 代码来看:
- pipe:成功返回 0,失败返回 -1
- pipe(pipedes):创建第一个管道,失败就报 start pipe failed
- pipe(fd):创建第二个管道,失败就报 notify pipe failed
- fork():创建子进程,失败就报 fork failed
之后继续往下看:

if 在父子进程或者 webpwn 之类题目里面,很大一部分都是报错信息,因此这一部分考虑先跳过去,往下看:

1 | setgroups(0, NULL); |
sub_1459(1001, 1001):意思是把当前进程切到:uid = 1001;gid = 1001
这里有一个切换权限的操作,也就意味着一开始我们的权限并不是 1001,还好给了 dockerfile:

也就是在 docker 当中是先以 root 权限操作的,在当中有一个降权的过程

结果最终是通过 sub_1784 把 flag 写到了 byte_5120
往后看 sub_1820 依旧是一个打印函数

先读 flag,存在内存里,然后等子进程通过管道发 6 字节,只有收到 “Nepnep” 才打印 flag,否则不打印
没什么发现,再回去看一看 if:
后话:哈哈当时确实是这么搞得,没想到 if 里面却是最重要的菜单,前面一开始属于是自作聪明了,之前写过一部分 webpwn,一大堆 if 语句也是分析麻了,后面渐渐就开始跳过 if,结果这次就翻车了,继续分析吧:

直接分析 sub_14D2

直接做一些基础逆向,就少说点吧,前面写那么细感觉有点累还有点累赘:

发现开启了沙箱:

发现很奇怪,为什么看不了沙箱呢?想了很久不太清楚原因,于是就问了一下 ai,大致解答如下:
seccomp-tools dump 会运行目标程序,目标程序会 fork,父进程读 flag 失败会打印 flag missing,子进程随后继续执行并开启 seccomp,dump 工具要跟到子进程的 prctl(PR_SET_SECCOMP) 才能看到规则。
1 | mkdir -p /priv |
如果本地 /priv/flag.txt 不存在或权限不对。pwn_patched 的父进程会先执行:
1 | sub_1459(1001, 1001); |
所以本地要有:/priv/flag.txt,并且要让 uid=1001 能读。

add:
直接挑关键的信息来:

add(idx)可以分配一个 0x30 大小 chunk 到 slot[idx],之后还有一个判断:
1 | if ( (unsigned int)sub_1882(s, s + 48, &stderr[-2].__pad4, &stderr[1]) |
判断新分配到的 [s, s+48] 是否落在 stdin/stdout/stderr 的 FILE 结构附近。如果落进去就退出,防止直接打 _IO_FILE。那么 2.39 不让打 _IO_FILE,这是不是有点?先继续往下看吧
write:

操作 2 说是 write,实际上就是 edit,可以向一个已分配的 slot 写入 0x30 字节数据
free:

很明显的 UAF 吧,事实上到这里的话,如果再没有一个 UAF 我可能就直接放弃了,没有 UAF 又不让打 _IO_FILE,在 2.39 这么一个高版本的 glibc 情况下,肯定就是一个非常严苛的漏洞点了
不过话又说回来了,这不是有 UAF 嘛
show:

只能使用一次的 show,只能直接泄露 slot[idx].ptr 这个指针值
poke:

只能改 freed slot,最多用两次且一次只能修改 8 字节
trigger:
1 | if ( buf == 112 || buf == 100 || buf == 115 || buf == 110 || v5 == 112 || v5 == 100 || v5 == 115 || v5 == 110 ) |
主要是这里吧,其实这个函数到后面分析完发现也没什么用处,当时还在这里浪费了一段时间。
总结一下思路:
也就是说我们想要拿到 flag,就必须控制 buf = Nepnep

看一下 buf,发现是写到栈上的,那么就不难考虑到 environ,environ 是在堆题目当中常用的一种栈泄露手法,如果我们能够寻找到 environ 并控制对应的栈地址,就可以在里面填充 Nepnep 从而绕过判断
EXP 思路
准备工作
打这道题目还是比较麻烦的,要么起一个 docker,要么就要按照 docker 复现一个类似的环境

docker 起了半天没有成功,那就在本地创建几个文件去模拟环境,在此再次强调:
1 | mkdir -p /priv |
如果本地 /priv/flag.txt 不存在或权限不对。pwn_patched 的父进程会先执行:
1 | sub_1459(1001, 1001); |
所以本地要有:/priv/flag.txt,并且要让 uid=1001 能读。
留意到前面给了一个 gift,目前并不知道这个是什么意思

在新增 /priv/flag.txt 文件之后依旧可以显示出来,因此我们可以在一开始去泄露这个地址,动调看一看:

1 | io = start() |

发现实际上是 printf 的地址,那么我们就可以直接拿 libc 基地址了,还有这好事?^_^//
1 | libc.address = gift - libc.sym["printf"] |

确实可以泄露 libc,没什么问题,那么我们接下来就好受多了
先打一个 poisoning
1 | add(io, 0) |
修改第一个 slot 0 的 fd 指针指向 environ,然后利用 environ 去泄露栈地址:
1 | add(io, 1) |
environ 是一个全局变量,它本身在 libc 里,保存的是指向栈上环境变量数组的指针
实际上也就是 chunk0 -> libc.environ -> *libc.environ = stack_addr就可以泄露栈地址
第二次 freelist posioning
1 | delete(io, 3) |
这里和第一步是一样的,就不多做解释了,这样就可以用 edit 创建两个可写地址
之后按理来说打一个 ORW 直接去写 flag 即可,像这样:
1 | payload = flat( |
不过在这里遇到了一个问题,在寻找 pop_rdx 的时候并没有寻找到一个干净的 pop_rdx_ret:

这也就意味着我们的 rdx 无法进行传参,与此同时还发现了第二个问题:
add 本身实际只有 0x30 的大小,上面 write 链实则占用了 0x38 出现了溢出的情况,于是首先应当考虑的是迁移
栈迁移:
那么暂且不考虑 poprdx 的问题,这个肯定是要在后面找东西替代的,既然是替代,其代码量只会多不会少,因此做一个栈迁移是必要的。衍生出两个问题:1. 迁到哪里?2. 怎么迁移?
前面利用 posioning 我们构建出了两个可写地址,分别是 environ 和 stack_addr
利用 stack_addr 当作踏板去执行 rip =environ,迁移到 environ,之后我们可以在 environ 中布置 rop 链:
1 | payload1 = flat( |
SROP:
之后开始思考如何构造一个 rop 链,不超过 0x30 大小,也可以绕过 pop_rdx。在学习栈 pwn 的过程中有这么一种手法,适用于在缺少 gadgets 的情况打 ROP 链,当时首要的想法就是打一个 SROP 执行 write:
触发 SROP 首先要有rt_sigreturn 作为信号返回帧恢复寄存器
1 | frame_addr = environ + 0x30 |
gets(frame_addr),执行 rt_sigreturn。所以执行 syscall 后,内核会恢复fake_frame

这里给了 fd = 6,那就按题目给的值走 rdx = 6:
1 | addr = frame_addr + len(bytes(SigreturnFrame())) |
最终利用 SROP 执行 write(6, “Nepnep”, 6);
总 EXP:
1 | from pwn import * |

直接打本地了
另一种解法:
朋友用 ai 开出来的一种解法:修了一下大概是这个样子:
总体思路没有变化,只是在打 ORW 的 write 的时候寻找到了一个 write:用这个一次就写成了

1 | from pwn import * |
shadow_signal
这道题目的利用手法主要是 ORW-SROP,记得有和这个很像的一道题目:[ NewStarCTF 2025 ]only_read
之前做过笔记,因此很快就想到了这样的利用手法,利用 syscall 调用 ORW
解题记录:

这道题开始写的时候已经一百多解了,主要是最近也比较忙,忙着去同学家玩哈哈,抽空来写一写,以至于写完这场比赛已经快结束了。现在依旧在同学家 emmm 还要写 wp 呃呃,现在已经是 20 号下午五点了,js 那个题目可能是没时间去写 wp 了,毕竟八点就要交 wp 了,之后写一写统一发布到博客上吧再 0.O、、
解题思路:


glibc 给到 2.35 版本,还是先从 IDA 静态分析
IDA 静态分析:
main:

非常简短的 main 函数,另外开启了沙箱,也就是说依旧要打 ORW,前面连着已经一直在打 ORW 了呃呃

我们从 main 函数中可以看到一开始给了个 printf 去打印 stdout,运行一下程序看看是怎么个事儿:

意思是说直接给了 stdout 地址吗?那很可以了,直接给 libc 基地址了,不放心的话可以动态调试一下看看
之后 puts(buf);,假设传入 1 等非法地址,就会触发 SIGSEGV 进入到 handler
init:

这里解释一下为什么会触发 SIGSEGV 进入到 handler:
在 init 当中注册了 handler,因此 SIGSEGV 不会直接崩溃退出,而是进入 handler()。
handler:

handler 有一个很明显的栈溢出,不过需要注意的一点是:
1 | if ( retaddr != (void *)shadow_saved_rip ) |
不能修改返回地址,但这里是 signal handler,Linux 调用 signal handler 时,handler 原本的返回地址就是 libc 里的 __restore_rt,我们不动返回地址即可
1 | readelf -Ws ./libc.so.6 | grep __restore_rt |

EXP 思路:
泄露 libc
1 | gift = int(io.recvline_contains(b"gift").split()[-1], 16) |
第一次 SROP
1 | def make_frame(rax, rdi, rsi, rdx, rsp, rip): |

执行 read(0, stage, 0x600),把 payload3 读到 .bss
ORWSROP:
1 | payload3 = flat({ |
最后一步就是利用 SROP 去分别调用 open,read,write,简单分析一个,然后剩下的就举一反三吧:
1 | path = stage + 0x480 # 0x480: FLAG + b"\x00" |
先利用 syscall 调用constants.SYS_rt_sigreturn触发中断,之后利用 SROP 执行open("flag", 0);
设置 rsp = read_chain = bss + 0x180,然后执行 read-srop
同理去布置 read 和 write 的 SROP-ORW 链即可,最后发送 payload 去接收即可:
1 | io.send(payload1) |
总 EXP:
1 | #!/usr/bin/env python3 |
emmm 这道题目有之前写过的类似题目打基础,因此感觉算是几个 pwn 当中最简单的一个了。

杂记:
做题当作遗留的一些小问题,之后再慢慢解决,先记录在这里

BizHawk 模拟器逃逸

准备开始写 EXP,看看动态调试:

额,似乎 pwndbg 和 gef 都不能进行动调



说些什么吧!