array(5) { ["chapterid"]=> string(8) "49409204" ["articleid"]=> string(7) "7300050" ["chaptername"]=> string(8) "第11章" ["content"]=> string(8725) "第三道题,Pwn方向。

题目叫"栈上的信"。靶场是一个简单的C程序——学生信息录入系统,用scanf读取用户输入的姓名、学号和专业,存在明显的栈溢出漏洞。程序开启了NX保护,数据段不可执行,但没有开PIE(地址随机化)和Canary(栈溢出检测)。攻击路径是标准的ret2libc:通过溢出缓冲区泄露libc基址,进而计算system地址并构造ROP链获取shell,最终读取flag。

这是三道题里难度最高的一道。Pwn方向的门槛远高于Web和Misc——它要求参赛者理解内存布局、调用约定、保护机制和漏洞利用链的完整构造。对大部分本科生来说,Pwn是一座还没开始爬的山。

二十一个人里,只有四个人尝试了第三道题。赵一鸣、陈逸飞、周可然,还有一个计算机学院大四的学生——孙浩,坐在赵一鸣前面两排,一直在安静地敲代码。林墨认识孙浩,操作系统课的成绩不错,但没听说过他打CTF。

赵一鸣做Pwn题的状态跟前两道题完全不同。

他的身体前倾,左手悬在键盘上方——绷带虽然拆了,但那个愈合中的伤口在他快速敲键时隐隐发红。他先用checksec检查了程序的保护机制,然后开始用pwntools写exploit脚本。速度很快,泄露libc基址的步骤在五分钟内完成。他的呼吸变重了,不是喘,是那种专注时下意识加深的呼吸,鼻翼微微张开。

但在构造ROP链的时候,他卡住了。

问题出在一个细节上:程序在返回前会调用一个exit函数,导致控制流在到达ROP链之前就被中断。赵一鸣需要绕过exit——要么覆盖exit的GOT表项,要么利用程序里的一个格式化字符串漏洞来修改返回地址。但他的注意力已经完全集中在"完善ROP链"上,没有退一步看全局。

他的exploit脚本越来越长。八十多行了,每次运行都返回"Segmentation fault"。他开始加调试输出,用gdb attach到进程上逐步跟踪。这是对的方法,但他花在调试上的时间已经超过了二十分钟。矿泉水瓶还在桌角,没开封,水珠已经蒸发干了。

"他在钻牛角尖。"苏晚晴走到林墨旁边,低声说。

林墨点了一下头。赵一鸣的屏幕上,gdb的输出在快速刷新——他在逐指令跟踪,试图找到ROP链断裂的位置。但他看的地方不对。问题不在ROP链本身,在更早的地方——栈布局。

与此同时,陈逸飞在做Pwn题时展示了第三种风格。

他没有用pwntools。他用的是纯Python的socket库,手动构造TCP连接和payload。这种做法更原始,但也更透明——他能看到每一个字节的发送和接收。他在发现exit函数的问题后没有像赵一鸣那样在ROP链上死磕,而是立刻切换了思路:用程序里的格式化字符串漏洞直接泄露栈上的canary值——

等等。

林墨皱了一下眉。他出题时确认过这个程序没有开Canary。但陈逸飞在试图泄露canary——这意味着他看到了林墨没有看到的东西。

陈逸飞运行了泄露脚本,拿到了一个值。然后他在脚本里加了一行注释:`# canary is 0, protection disabled, but stack layout differs from expected`。他发现程序的栈布局跟标准GCC编译结果不一样——有人改过编译选项,让栈帧的对齐方式发生了偏移。

那个"有人"就是林墨。他编译题目的时候手动加了一个`-mpreferred-stack-boundary=4`参数,让栈对齐变成了16字节而非默认的8字节。这个改动不影响程序功能,但会让标准exploit的偏移量全部失效——除非参赛者自己发现对齐差异并调整payload。

赵一鸣没有发现这个差异。他的exploit一直在用错误的偏移量,所以每次都崩溃。

陈逸飞发现了。他用了大约三分钟反汇编了main函数的序言部分,看到了栈帧对齐指令,然后在自己的payload里调整了偏移。修正之后,exploit一次通了。

flag出来的时候,比赛时间还剩一小时二十分钟。陈逸飞是全场唯一解出Pwn题的人。

但陈逸飞没有停。他在拿到shell之后又做了一件事:输入了`cat /proc/self/status`,查看进程的权限和命名空间信息。然后输入了`ls /flag`,确认flag文件的位置。接着——

他输入了`cat /etc/passwd`。

"他在试图逃逸容器。"苏晚晴的声音变了。

Docker容器逃逸是CTF里的高级技术——如果参赛者能从题目容器里突破到宿主机,整个CTF平台都可能被攻破。林墨的平台虽然做了基本的隔离,但没有做严格的容器逃逸防护。他没想到会有人在选拔赛里尝试这种操作。

陈逸飞的尝试没有成功。容器做了capability限制,他没有足够的权限逃逸。但他在尝试的那一刻所展示的东西——在拿到flag之后还不满足,继续往更深处探——让林墨在心里做了一个最终判断。

这个人不是普通的学生。他的技术能力、思维方式和操作习惯都指向同一个方向:受过系统的、高强度的安全训练。一个在互联网上零痕迹的软件工程大三学生,在CTF选拔赛上展现了接近职业水准的攻击能力。

他到底是什么人?

比赛结束前四十分钟,排名发生了最后一次变动。

赵一鸣终于发现了栈对齐问题。不是他自己发现的——是周可然走到他旁边,在他屏幕上看了一眼,然后说了一句话。

"你的payload偏移差了八个字节。看main的prologue。"

赵一鸣抬头看她,愣了两秒。然后低头重新反汇编,三秒后骂了一句什么,开始在脚本里修改偏移量。

"你不能跟他说话。"苏晚晴站起来,走向周可然,"比赛规则不允许交流。"

"我没有给他答案。"周可然推了推眼镜,语气很平,"我只是指出了他的问题在哪。怎么解决还是他自己。"

苏晚晴看了林墨一眼。林墨微微摇头——周可然说的确实不是答案,只是一个方向提示。在CTF比赛的灰色地带里,这算不算"交流"取决于你怎么定义。严格来说任何信息交换都算,但从实战角度看,队友之间互相指出盲区是正常协作的一部分。

"这次算了。"林墨说,"但以后比赛里不行。正式比赛的监考比这严格得多。"

周可然点了一下头,回到自己的座位。赵一鸣在修改偏移后,exploit第四次运行成功,拿到了flag。他靠回椅背,长出了一口气,在桌子上轻轻锤了一拳。不是愤怒。是那种憋了很久终于放出来的劲。

十二点整。倒计时归零。苏晚晴在平台上锁定了提交通道。

最终排名:

第一名陈逸飞,解题3题,总用时1小时42分钟;第二名赵一鸣,解题3题,总用时2小时51分钟;第三名周可然,解题2题,总用时37分钟;第四名孙浩,解题1题,总用时1小时15分钟;第五名李文彬,解题1题,总用时1小时48分钟。

前五名进入校队。加上林墨和苏晚晴,校队阵容是七人——但CTF比赛通常上场三到五人,需要从中选出首发。

参赛者们陆续离开教室。赵一鸣走到门口时停下来,回头看了一眼林墨。他的表情跟刚进来时不一样了——那副满不在乎的劲儿淡了很多,取而代之的是一种被锤过之后的沉默。他在Pwn题上浪费了将近一个小时,如果不是周可然提了一句,他可能到比赛结束都解不出来。

"栈对齐的事,是你故意改的?"赵一鸣问。

"对。"

"操。"赵一鸣说了一个字,但语气里没有恶意。他摸了一下手腕上的伤口,想了想,又说:"谢了,周可然。"

周可然正在收拾东西,听到这句话抬头看了他一眼,没什么表情地嗯了一声。

赵一鸣走了。走到走廊里的时候踢了一下墙,不知道是在骂自己还是在发泄。

陈逸飞是最后一个走的。他在其他人都离开后,才慢悠悠地合上笔记本电脑,把背包挎到肩上。走到讲台前,他停下来,看了看苏晚晴和林墨。

"你的Pwn题出得不错。"陈逸飞说,"但你应该把容器的capability限制做得更严。我差一点就逃逸了。"

"你差得远。"林墨说。

"也许吧。"陈逸飞笑了一下。那个笑容不太好读——不是自信,也不是谦虚。"不过你下次出题的时候,可以试试把seccomp也加上。那样我就得换个思路了。"

他没有等回答,挥了挥手,走出教室。走廊里他的脚步声慢慢远了,很轻,跟苏晚晴一样不出声。" ["create_time"]=> string(10) "1785963694" }