前排提示:1.一些题目没exp
2.由于题目不是一次上完的,所以顺序上可能不完全与当时一致
3.附件在GitHub仓库里有
💓 引导之始(🍼Baby)
1 | ⚠ 题目描述 |
你室的pwn每年都有的nc就送题
🫨 打地鼠(🍼Baby)
1 | ⚠ 题目描述 |
依然是你室每年都有的 IO 题,这题比上一年的 CNSS娘中之人 简单一些,这个题打就完了,上一年的题还需要做分类和模式匹配。
个人观察来看,很多人第三题做了也没有做这个第二题,实际上这种 IO 题完全不需要 pwn 知识,只需要会用 pwntools 里的 IO 工具即可。究其原因,第一IDA反编译把大🔥给吓坏了,逻辑实现还是比较长的(虽然不一定需要去看),其次调 IO 比较麻烦,对于收发字符需要有比较精确的控制 ,其实pwn就是这样繁琐,差错一个字节甚至一个位都不行,IO 题只是一切的开始,喜欢的小伙伴千万不要放弃。
一点和本题相关的,反编译得知玩家输出的地鼠代号是用 getchar() 接收的,而且只有一个 getchar(),所以在发送的时候不要使用 .sendline(),否则多出来的 '\n' 会在下一次打地鼠时被接收。如果打过算法类竞赛,肯定对此深有体会。
🥺 not enough(🐔Easy)
1 | ⚠ 题目描述 |
这道题剥去了符号表,但实际上也没太大影响,核心代码只有一个main()和一个手写的改进版read(),这个改进版read()作用就是输入'\n'时终止输入,并把其改为'\0',以截断字符串,和scanf("%s")差不多,但是限制输入量。这个改版read()很常见并且一般漏洞点不会放在这里面。
查看12行,发现字符串可以越界读写,于是可以溢出到v4,将其修改为0x114514,然后就可以轻松 getshell()
😕 We’re safe… for now… or not?(🐔Easy)
1 | ⚠ 题目描述 |
很简单,复制字符串到目标栈地址上,由于没有检查长度和canary,导致了 previous rbp 和 返回地址的最低一位 被覆盖,而被覆盖后的地址指向了一个后门函数。
😗 I’m the mole(🐔Easy)
1 | ⚠ 题目描述 |
也是保留项目 ret2text,运用从上一题学到的栈溢出技术,将返回地址修改为后门函数即可 getshell 。
值得注意的一点是,众所周知,64位的 system() 要求栈地址16位对齐,而不是平常的8位(具体原因请移步nydn大佬的博客https://nyyyddddn.github.io/2023/09/26/exp%E6%9C%AC%E5%9C%B0%E4%B8%8D%E9%80%9A%E8%BF%9C%E7%A8%8B%E9%80%9A%E7%9A%84%E9%97%AE%E9%A2%98/ ),涉及其中一个寄存器的问题。
招新pwn题所有涉及 ret2text 和 system()的,似乎本地远程都有这个栈平衡的问题,对于此题来说,最终的 ROP 应该是下面这样的。
1 | io.send(b'a'*?????? + p64(ret) + p64(backdoor)) |
其他的题还有其他的方法,之后会讲。
☎️ Call Me……(🐔Easy)
1 | ⚠ 题目描述 |
可以感觉到Tim出的题很温油,后面的一道 heap 也是。
从这题开始要接触正儿八经的保护措施了,这题主要是Canary和Pie。
Canary(金丝雀)是栈溢出哨兵,如果开启了它,栈帧的 last rbp/ebp 低一位字长的位置就会填写一字长的随机量,然后程序就会在需要栈溢出检查的函数返回之前检查这个随机量,如果发现这个量被修改,当前函数不返回,执行错误处理(然后退出)。
注意Canary的最低位一定为'\x00',起到截断输出的效果(对write()没用),并且Canary的值是全局变量,在一个程序的生命周期中不变。
然后PIE,和ASLR一样是地址随机化的保护技术。区别在于ASLR是操作系统实现的,一般关不掉,而且只随机堆栈和动态链接的部分;PIE是编译器实现的 gcc -no-pie 可以关掉PIE,可以将.text、.bss、.data 等地址也随机化,让你打ROP更难受(不是)。最后,无论怎么随机化,都是以页为单位的,也就是16进制的后三位不会变化,地址之间的偏移也不会变化。
这个题在输入正好11位的电话号码之前,会一直循环打印输入的字符串,考虑借此leak pie 和 canary。
1 | # leak canary |
然后还是system() 栈平衡的问题,可以向上面一样,在 ROP 中加入 p64(ret + pie_base),也可以返回到backdoor() 中实际调用 system() 的位置,由于少了最开头的压栈操作,从此处开始调用确实是16位对齐的。
1 | public bug |
🐦⬛ happy sugar life(🤖Mid)
1 | ⚠ 题目描述 |
Hint+:Canary == n.金丝雀; 其次Canary可能发音与Candy有部分相近吧(
所以这一题还是想办法绕过Canary保护,然后返回到后门函数。实际上这一题比上一题上题上得早一些。
1 | unsigned __int64 sugar_salt() |
漏洞在for ( i = v2; i <= 40; ++i ),也是c语言新手常犯的错误,下标是0开始的,所以这里正好 offset-by-one , 把Canary最低位覆盖,之后又自带一个输出就把Canary泄露了。
可以看到第二遍输入没有越界写,但是有格式化字符串漏洞。所以这个格式化字符串漏洞做两件事,一是修复Canary最低位为'\x00',二是改返回地址。
然后一件事,由于我们要改栈上的内容,于是需要指向栈某些位置的指针,进而需要泄露栈地址,幸好栈地址也和Canary一起泄露了。
大致的payload如下,使用%hhn而不是%n修改单个字节。
1 | canary = u64(io.recv(8))|0xff - 0xff |
然后这个backdoor()也是有栈平衡的问题,解决方式和上一题一致。
🤔 s代表着…(🤖Mid)
1 | ⚠ 题目描述 |
已经告诉了s代表shellcode。
checksec魅力时刻,有rwx段,但就是不提示,只能gdb调试看看。
1 | $ checksec pwn5 |
1 | pwndbg> vmmap |
第一个就是。
注意到开了sandbox
1 | line CODE JT JF K |
由于Hint提示flag名称未知,所以我们需要使用图中getdents先获取当前列表的所有文件信息,然后再打一个ORW。
注意K那一列,是具体的系统调用号,网上都教64位用getdents64,但它的调用号这题被ban了,用getdents本身也足够了。
问了出题人,flag的名称每1s变一次,所以才给了两次shellcode的机会。
然后注意一点,第一遍shellcode要有压栈和返回的操作,不然到这就SEGV了,没有第二次shellcode的机会。
开辟0x200栈空间,信息直接放在栈上。
1 | shellcode1 = ''' |
博主写的时候由于用的wsl,不知为何wsl上pwntools的asm()很慢,导致两次shellcode间隔超过1s,flag文件名已经变了,死活过不了,后来用虚拟机直接过😅。
😎 头号玩家(🤖Mid)
1 | ⚠ 题目描述 |
谜语人题目,前置知识是看过头号玩家(至少是电影中的第一关)
实现逻辑比较长,这里就不贴了,简单来说就是根据程序的随机输出,做一个类似Yes or No的游戏,初始有30分,50次机会,答对一次加一分,错一次减一分。玩完之后,有一个读入字符串的机会,有多少分就可以读多少字符。
本题依然checksec魅力时刻
1 | root@PainTech:/home/pwn/worktable/cnss2024# checksec pwn6 |
显示Canary found,实际上根本没有。一般题目可以在IDA中去找找__stack_chk_fail函数,如果有就是有Canary;但本题静态链接,东西多不好找,所以动调看rbp - 0x8有没有Canary,发现没有。
本题的打法有两种,先说正解,也就是和头号玩家有关系的解法。

关键点在于正着开不行要你倒着开。由于没Canary,所以要打一个栈溢出,但问题是如果全部答对,也只有80字节,这个大小只够恰好覆盖到返回地址,根本不够ROPchain
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
细心的童鞋肯定发现,只有v15是无符号数,其他都是有符号数,恰巧又有对v15做减法的操作(答错题目),所以如果故意答错题目(既倒着开),v15就会向下溢出为一个很大正整数,此时构造ROPchain,打一个 ret2syscall 完全足够了。
然后说另一种解法,也就是哥们独创的解法,还把aic给带偏了🤣。
如果不考虑整型向下溢出的话,那么正好溢出到返回地址,于是可以按照栈迁移的思路来打。
1 | .text:0000000000401B76 loc_401B76: ; CODE XREF: main+227↑j |
此处为 main() 最后的输入字符串的部分,注意到0x401BA8开始为myread()(可视为一般的read())准备参数,其中var_4,var_40都是固定值(-4和-40),所以可以将rbp迁移到某个-4位置为一个较大数的位置,这样可以实现读大量字符串。
于是去找一块符合条件的风水宝地
1 | .data:00000000004A0277 db 0 |
随便找一个即可
然后是返回地址,理论上可以选0x4019CE及以下的任意位置,但是上如果直接跳转到上面myread()的位置会SEGV,猜测是栈没布置好,访问到非法内存了,而跳转到0x4019CE就没有问题,当然这意味着要再来50组游戏,虽然这次可以随便玩。
1 | .text:00000000004019C7 mov [rbp+var_4], 30 |
由我们控制的myread()结束后,程序将leave ret,这也是这种恰好只溢出返回地址的题目在栈迁移时关键的一点,既不将返回地址覆盖为leave ret,而是想办法要再次利用 read() ,往fake_rbp 上写一些东西,然后用函数末尾自带的leave ret,完成向目标位置的栈迁移。
对于这题而言,leave ret的流程是将rsp骗到我们输入地址+0x40的位置,pop rbp该位置,然后ret上一个字长位置。这意味着需要在输入时设置0x40 + 0x8的没啥用数据,然后才是ROPchain。
由于不是标解,所以把这种exp放上来,仅供参考
1 | from pwn import * |
🗒 凝眸回首映芳华
1 | ⚠ 题目描述 |
以下是出题人的心路历程。



看note知堆题,增删改看功能齐活.
1 | [*] '/home/pwn/worktable/cnss2024/pwn8/pwn' |
GOT表可写、然后没有PIE。没有堆题常见的UAF、堆溢出、offset-by-one等。不过即使具体漏洞还没找到,也依然可以先 leak libc 。
首先strings ./libc.so.6 | grep "glibc" 可以看到版本为2.35,所以铁有tcache
1 | for i in range(9): |
show(8)时就把 libc 泄露了
现在再来找具体的漏洞
先关注一下堆信息的存储方式,也就是1. Create a new page of notes
1 | case 1: |
v8和malloc((int)v8)那两行就表明了堆信息的存储方式。对于这种东西,我的意见是,能看就看,不能看直接动调,如下。
1 | # 申请一个0x10大小的堆,index = 0 |
从动调直接看出,首先输入一个index,确认从heap(0x4040c0)开始的偏移,每16字节作为一个结构体,前8个存堆大小,后8个是堆的指针。
接下来是2. View notes,就是打印
1 | case 2: |
用的puts,所以才能把libc + offset带出来,如果严格按堆大小输出,上面leak libc就没戏了。
看看3. Delete notes
1 | case 3: |
没有UAF,下一个
1 | if ( v3 != 4 ) |
也没啥好说的。
那整这么多没用的那么漏洞在哪里呢?对于这道题的漏洞,可能需要堆题方面的一些经验。
一般的堆题,抛开堆信息的存储可能不同之外,都有一些固定的规律。第一,堆的索引是系统分配,程序查询可用索引进行分配;第二,堆的索引有一定限制,不能过大,也就是堆的申请数量有限制;第三,在分配或者释放堆块时,首先对存放堆信息的位置检查,确认目标位置,避免造成指针重复覆盖或者free()释放无效空间。
所以再看这道题,这些特征完全没有,这也是为什么整体的逻辑实现较短。当我们回头再看case 1时,发现v4和v8是有符号整型,尤其v4,由于堆信息的寻址不是数组访问,v4在寻址时不会被转化为整型,所以当v4为一个负数时,反而会反向去寻址,加上对该位置的赋值,实际上这是一个任意写的漏洞,不过只能每两个字长中写一个字长,即使这样也已经足够了。
由于GOT表可写,并且已经leak libc,所以考虑使用负索引向上修改GOT表。要修改的GOT表需要满足两个条件。首先,GOT表项位于0x40xxx0到0x40xxx8,这个位置被用于存放v8的值;其次,由于v8是int,只有低4位可以覆盖,需要更高的位置已经被填写,所以我们需要一个已经重定位过的GOT表项。考察上述两点,于是选择atoi()的GOT表。
改完之后,在再发送'/bin/sh\x00'即可。
1 | v8_4 = system_addr & 0xffffffff |
🎮 Super Mario Code Revenge(😡Hard)
1 | ⚠ 题目描述 |
最先上的一道hard题,属于是比较温油的hard,但还是hard。
首先,根据提示,这个题使用了一个反逆向技术,叫做自修改代码。
1 | int __fastcall main(int argc, const char **argv, const char **envp) |
甚至把攻略贴在文件里,哭死。
可以看到,自修改代码就是在运行时解密代码,由于IDA是静态调试,所以无法呈现正确的代码。首先把加密代码段提权为rwx,原本的.text没有写权限,然后,从这个函数开始,每十字节位一轮,查表异或,直到全部解密完成。表是’Pwn5Shino!’这十个字节
看一眼marioGame
1 | .text:0000000000401236 ; __unwind { |
可以看到IDA虽然做出尝试,但显然加密是有效的。
考虑使用先使用IDApython,对这一段内容解密。注意先import idc

下面是弄完后的效果。

可以看到,识别了,但也没完全识别,和wiki上不一样。
这是因为x86有庞大的指令集,这个函数实现逻辑略有复杂,所以IDA识别出现歧义也比较正常,而且IDA也没有检查反汇编结果是否合理。这时候就需要做一个手动引导。
先打开动态调试,动态调试中可以看到正确汇编代码。

对于这种错误识别的汇编指令,我们右键它,点击Undefine,可以将其还原为单字节。

右键然后Assemble...,可以调出Patch窗口。注意只有汇编指令处才有这个选项,所有指令打开的窗口都是同一个。
此时我们从动态调试中复制一条指令,比如首个未能正确解析的指令mov DWORD PTR [rbp-0x24],edi,将它复制到Assembly窗口栏中。绿色代表从这个位置开始匹配指令,粉色代表匹配到了指令的位置。

然后,回车并退出这个窗口,右键刚刚解放出来的单字节,点击Code,就可以看到还原成功了。

你可能发现,一条指令正确识别了,别的又错了,这很正常。重复上述操作,需要注意,有时候不需要手动汇编匹配右键就有Code按,也就是说,不用一条指令一条指令地去匹配。
弄完了之后记得和动态调试的结果对比一下。
可能是由于我IDA的问题,无法按照wiki上的方法反编译,只能根据Shino的提示先跳过这一段。

不过这个题做完了之后,还是找到了反编译的方法,挺玄学,仅供参考。
当确认反汇编无误之后,先使用Apply patches to修改二进制文件,退出IDA,删掉原先的.i64(或者干脆不打包),然后再IDA打开修改后的二进制文件,就可以反编译了😅(
回到正题,这个漏洞确实不在加密函数里。注意到 __isoc99_scanf("%s", name);,这个东西可以理解为和gets(name)一样的东西,也就是存在溢出。这里的name是一个全局变量。
1 | .data:0000000000404070 public name |
name正好在密钥的上面,也就是说可以通过溢出修改密钥。
然后思考修改密钥有什么用,之前说到,密钥与密文对应异或,就可以得到原文,然后程序执行这一段的原文。所以说控制了密钥,就控制了解密出来的指令,然后执行我们控制的指令。
那么这就好办了,由数学可知,使用密文去异或我们想要的指令,即可得到篡改后的密钥,下面是一个demo,注意返回的是字符串不是bytes
1 | def genkey(sh, crypto): |
想法美好,但现实残酷,由于本来的密钥只有10位,原文索引模10后查表异或,所以无论密钥如何篡改,可以自由支配的指令最多只有10位。
显然10位的shellcode不足以getshell(),所以根据经验想办法用这10字节弄一个read的系统调用,然而调用一次read至少需要12字节,如果想要更多的读入,指令长度也会增长。
1 | len(asm(shellcraft.read(0, 'rsp', 0x1))) |
所以这个shellcode还是得手写。手写shellcode主要关注的是rax、rdi、rsi、rdx,这四个寄存器,分别是系统调用号、文件流、读入的地址和读入字符数量。
断点下载进入函数前(0x4015db),动态调试一下,
1 | RAX 0 |
可以看到,rax和rdi恰好都是0(read的调用号以及stdin),这两个就不用管了。主要弄剩下两个。
demo1
1 | shellcode1 = ''' |
显然demo1肯定不行了,这是因为syscall是固定2字节,而mov实际上是一个相当长的指令,在构造短shellcode是应尽量避免使用,尽量使用pop和push指令,尤其在置空寄存器时,可以使用xor rax, rax
demo2
1 | shellcode2 = ''' |
玛德正好多一个
仔细分析一下,rsi要求必须是一定的值,所以它的pop和push省不了,但rdx不一样,只要是一个大数就行。注意到,此时由于call指令,rsp指向的是返回地址(0x4015E0),已经足够大,所以就把它的push给省掉。
demo3
1 | shellcode3 = ''' |
甚至还少一字节😜,结尾加一个nop占位,这样前面算密钥的就不用再改了。
⚡ FFFFree!
1 | ⚠ 题目描述 |
介绍一下本题的数据结构。本题有关堆的结构是一个链表,链表分为控制信息和数据信息。首先控制信息有一个固定有一个head node,然后随着链表的添加接着添加其他的node
大概是下面一个结构,某个结点free之后,idx就会被设置为0x7fffffff表示不可读,但本身还留在链表中;在show时,先从stdin中读取一个idx,然后用*next依次计数找到链表中第idx个结点,puts()出*text的内容。一个这样的控制结点大小固定为0x18,也就是一个chunk固定为0x20
然后是*text指向的数据域,也是指定大小范围0~0x70,也就是这个题和unsorted bin关系不大了。
1 | struct node{ |
glibc版本2.31,所以没办法打最原始的tcache double free 以及 poisoning,因为从Ubuntu20.04(也就是glibc2.31之前某个版本,好像2.28)就有对tcache double free的检查。
针对这个double free的检查,可以通过UAF修改tcache chunk中的key值,或者通过堆溢出改tcache chunk的大小绕过检查,但显然这个题都没有。
那么还有一种不那么常见的方法,虽然glibc2.31有针对tcache double free的检查,但是没有对于fastbin的double free的检查。虽然这么说,但实际上还是有一些防范措施。首先chunk接入fastbin时会检查fastbin栈顶的chunk,如果一样就会被检查出来会报fastbin的double free;其次,chunk接入fastbin时会在tcache bin中检查,如果发现存在一样则会报tcache bin的double free。
由于此题没用calloc(),所以malloc()时会先从tcache bin中取出chunk,然后把fastbin中的一个chunk挪到tcache bin中,这个挪的过程中不存在double free的检查,所以这个题还是可以打一个tcache poisoning以及__free_hook。
首先考虑泄露一下libc,这里选择tcache poisoning以及堆风水技巧将某个控制信息的chunk劫持到free()的got表项上,然后show出来即可。
注意到,想修改*text,还需要先覆盖到*next,为了让链表正确地顺序寻址,这个题还需要泄露heap base。
得到libc基址之后,就是愉快地tcache poisoning以及__free_hook了。
完整exp
1 | from pwn import * |