做CTF的pwn题,很多人一看到ROP就条件反射地找system、找“/bin/sh”、找pop rdi,满脑子都是怎么一次性getshell。但有一类题目,真正卡住你的往往不是最后的shell,而是那些不起眼的“边角料”函数。0CTF 2016的warmup就是个典型例子——拿到手第一眼看过去就是个普通栈溢出,可真正把exp写到一半才发现,能不能把程序里那个不起眼的alarm用明白,直接决定了你是十分钟搞定还是卡一整晚。
这篇文章就围绕这道题,把我对alarm在ROP里几种用法的理解完整拆一遍。alarm这个函数平时没人正眼看它,但在特定场景下,它比system还好使。适合已经学过栈溢出基础、想提升ROP构造灵活度的朋友,也适合那些遇到“本地能通、远程就断”问题但一直找不到原因的人。
1. 先从warmup说起:一道“看起来人畜无害”的栈溢出题
1.1 题目的大致面貌
0CTF 2016的warmup,从名字就能看出来,官方定位是热身级别的送分题。二进制程序本身不大,保护开得也比较松,至少溢出点非常明显,输入缓冲区大小远小于覆盖返回地址所需偏移,攻击者能直接控制返回地址,把它改成某个函数地址或者构造一段ROP链。
这种题平时大家做得太多了,流程基本上是固定的:先checksec看保护,再用IDA看伪代码,接着找溢出偏移、找gadget、写exp、一把梭。如果你只是在本地随手打一下,可能一次就通了,甚至会觉得这题出得毫无诚意。那它凭什么能被大家反复拿出来讨论?
关键就在“远程”两个字上。本地调试环境里,你可以随便下断点、慢慢看栈帧、甚至直接修改寄存器;但一接到远程环境,你就会发现对方程序在启动阶段给进程挂了一个定时器,这种机制在CTF题目里非常常见。定时器一到点,SIGALRM信号直接把进程杀掉,你的连接就断了。如果不做处理,不管你ROP链布置得多完美,只要执行总时间超过了这个阈值,看到的永远是remote connection closed。
这不是warmup独有的问题,很多带交互的CTF题目都会用“几秒后自动断开”的方式来限制盲注类攻击。warmup有意思的地方在于,它没有绕开这个机制,而是让你在构造ROP链时主动去处理它。想明白这一点,你才算真正入门了这道题。
1.2 新手第一次打这道题会卡在哪
我见过不少同学做这道题时的状态:本地通了一次,然后跑到远程一打,发现payload发过去之后,对面要么没回显,要么直接断连。于是开始怀疑是不是offset算错了、是不是栈没对齐、是不是system地址在远程和本地不一样。排查了半天,把能怀疑的地方全怀疑了一遍,其实问题根本不在这。
远程环境里最常见的情况是:程序初始化阶段调用了alarm并设置了一个倒计时,意味着从进程启动那一刻起,到你ROP链完成所有操作,总时间不能超过这个数。如果你的利用流程需要先栈迁移、再调read、再回到第二段ROP链,一次交互要打好几个来回,那必然超时被杀。
这类问题有个典型特征:本地怎么调都是好的,一上远程就“超时”,而且没有core文件可以分析。因为它不是crash,是被信号杀掉的。很多pwn新手对这个现象完全没有概念,因为本地调试根本不会触发这个分支。这就像是你考试时题目都会做,但交卷铃一响就被拖走,你连答题卡都来不及涂。
所以要习惯一个视角:exp不只是在“构造一次栈溢出”,而是在“和程序的运行机制进行一场带时限的谈判”。alarm这种平时被忽略的函数,恰恰是谈判桌上最好用的筹码。
2. alarm在ROP里的第一重妙用:单参数函数的“低配置”优势
2.1 x64调用约定下,一个参数意味着什么
x64 Linux下,函数调用的前六个整型参数分别放在rdi、rsi、rdx、rcx、r8、r9。ROP的核心难点之一,就是你在控制返回地址的前提下,还得把这几个寄存器设置成你想传的值。
假设目标函数是system,想执行system(“/bin/sh”),只需要rdi一个参数,但“/bin/sh”这个字符串的地址你得先找到;如果是execve(“/bin/sh”, 0, 0),那就需要rdi、rsi、rdx三个参数,链子至少要找三组pop gadget再加上函数调用点。每多一个参数,就多一组约束。在gadget数量有限的小程序里,这往往就是把人逼疯的地方。
而alarm的签名是unsigned int alarm(unsigned int seconds),只有一个参数。这意味着构造一次alarm调用,只需要一个pop rdi; ret,甚至在某些特殊布局下可以直接复用已有的rdi内容。相比execve、system这些“高配”目标,alarm是真正的“低配”函数,对寄存器的要求极低。
这种低配置特性在ROP里非常有价值。很多时候你并不需要一个能直接getshell的函数,你只是需要“某个函数被调用一次,并且最好别产生坏影响”。alarm恰好满足:调用它,传一个数字,然后正常返回,不会产生什么结构性副作用。
2.2 几个常见目标的寄存器开销对比
我整理一下几个在ROP里经常出现的目标函数的寄存器开销,方便直观感受alarm到底有多“省”:
| 目标函数 | 参数数量 | 需要预先控制的寄存器 | 典型用途 |
|---|---|---|---|
| alarm | 1 | rdi | 设置/取消定时器 |
| sleep | 1 | rdi | 挂起当前进程 |
| usleep | 1 | rdi | 微秒级挂起 |
| system | 1 | rdi | 执行命令 |
| open | 3 | rdi、rsi、rdx | 打开文件 |
| read | 3 | rdi、rsi、rdx | 读数据 |
| write | 3 | rdi、rsi、rdx | 写数据 |
| execve | 3 | rdi、rsi、rdx | 执行新程序 |
从这个表可以看得很清楚:单参数函数在ROP里属于“最低门槛”的一档。如果要构造多次连续调用,每多一个多参数函数,payload长度和gadget匹配难度都会大幅上升。所以当你的目标只是“调一个函数、增加一个步骤”时,优先选单参数函数是更省力的方案。
2.3 何时拿alarm当“过渡拼图”
在实际构造ROP链时,有一种情况经常遇到:中间某个环节需要消耗两三个gadget才能把寄存器调整到下一个调用需要的状态,但你又不想让整条链子长度失控。这时alarm可以当“无害过渡函数”用。
具体来说,最常用的过渡场景就是栈对齐。x64的ret2libc里,如果你直接调用system,有时会遇到movaps指令导致的段错误,原因是栈指针没有16字节对齐。解决办法通常是在调用system前加一次ret。如果程序里找不到单独的ret gadget,那么alarm的调用本质上也能充当一次return。因为alarm正常返回后会走下一条链,它的PLT地址在这个场景里能起到“额外ret”的等价效果。当然,前提是alarm调用不会破坏你已经设置好的关键参数。
另一个很实用的场景是调试定位。当你拿不准ROP链到底执行到了哪一步,可以在某个关键节点放一个alarm(1),然后观察远程是否在1秒后出现断连。如果断了,说明执行流确实走到了这一节点;如果完全没反应,那问题出在更早的地方。这种“探针式调试法”在处理复杂链条时极其好用,比gdb在远程环境里可靠得多。
3. alarm在ROP里的第二重妙用:时间侧信道与超时免死金牌
3.1 ROP链太长?alarm(0)取消定时器
alarm(0)是一个特殊调用:取消当前进程的定时器,让先前设定的SIGALRM不再触发。这个用法在ROP里堪称“免死金牌”。
如果题目在程序初始化时设了一个几十秒的定时器,你的完整利用链又特别长,包含多次read、多次栈迁移,甚至还有盲注循环,那第一时间在ROP链开头调一次alarm(0),把定时器彻底关掉,后面所有操作就都不再受时间约束。
这里有个容易忽略的细节:alarm(0)本身也是alarm,也需要rdi = 0。如果你已有的gadget能让rdi变成0,那这条链的构造难度很低。而且alarm(0)的行为是“取消定时器”,不是“重置定时器”,所以之后不会再收到那个信号。如果你的目标是“重新开始计时”,那得先alarm(0)再alarm(N),分两次调用才能实现。
在CTF题目里,最常见的还是“alarm到点杀进程”这种设计。遇到这种情况,alarm(0)就是最直接有效的解法。很多远程环境不稳定的题,加一个alarm(0)之后立刻稳定,原因就在这里。
3.2 无回显盲注时,alarm当节拍器
另一种妙用是“时间侧信道”。什么叫盲注?就是在完全没有回显的情况下,通过某种手段逐位推断内存中的内容。经典做法是拿地址是否满足某个比较条件来控制程序走不同分支,再用“是否收到回显”来区分结果。但如果连回显都没有,那就只能用“程序是否超时被杀”来传递信息。
思路是这样的:如果目标地址的某个字节等于猜测值,就调用alarm(0)或alarm(1);如果不等,就什么都不做,让程序进入死循环或者自然超时。攻击者通过观察连接是“继续保持”还是“被断开”,就能确认这一位的值。alarm在这里充当的是一台节拍器:它把“猜测正确”这个布尔信息编码成了“进程能活多久”这个时间信息。
这种技巧在沙箱题、无输出题的本地盲打里非常实用。我自己在做没有输出函数的题目时,经常先用alarm搭一个时间侧信道验证思路,确认程序执行流能到某个关键点,再去考虑更精细的泄露手段。当然,时间侧信道对网络稳定性有要求,远程延迟抖动大的话,需要多次测量取均值,这是实战里必须接受的成本。
3.3 返回值里的信息通道
很多人没注意到,alarm的返回值是“上一次定时器的剩余秒数”。如果上一次设的是alarm(30),过了5秒后调alarm(0),返回值就是25。这个返回值本身可能携带一点时间信息。
在极少数场景下,ROP链可以利用这个返回值判断自己在调用链中的位置是否正确。比如有一段逻辑依赖alarm的返回值做分支选择,那alarm的返回值就成了一个可利用的条件开关。这不算特别通用的技巧,但它提醒我们一个思维模式:在ROP里,任何一个函数的返回值都可能是可利用的信息通道。不要只盯着系统调用和输入输出函数看,真正在赛场上拉开差距的,往往就是把这种“边角料”用出花的能力。
4. 第三重妙用:在沙箱与连环调用中当“垫场函数”
4.1 沙箱题目里ROP链的连锁调用结构
开了沙箱的CTF题是现在的主流,不能用execve直接弹shell,只能open/read/write一路把flag读出来。这种利用结构在ROP里就变成了一个“连环调用链”:先open,再read,再write。每一步三个参数,每次都要重新设置rdi、rsi、rdx。链条越长,中间可用的寄存器状态就越紧张。
连环调用链里最怕的就是“某一小段gadget断了”。比如你已经凑齐了pop rdi、pop rsi、pop rdx,但它们的排列顺序导致中间需要多余的一次ret来调整栈指针。这时候你会特别希望有一个无害的函数能垫一下,让布局更从容。alarm正好满足:行为无害、单参数、地址基本肯定存在于程序中(因为程序初始化大概率会调它),而且不依赖具体libc版本。
4.2 alarm在关键节点起到的“垫场”作用
在ROP链中,gadget的拼接本质上是“寄存器状态机”。每一步都在变换寄存器内容,最终在目标函数处让所有需要的寄存器恰好到达期望值。alarm作为一个函数调用,本身不额外读内存参数,行为可预期,所以可以在你“已经设置好关键参数,但还需要一个调用点让程序往前走”的时候充当垫场。
举个例子,当你利用栈迁移把一个新栈写到堆上,需要立刻验证当前栈布局是否正确时,先放一个alarm(1)试试水:如果1秒后程序被杀,说明你的栈迁移成功了;如果一点动静都没有,说明链子根本没执行到这个地方。这种用法在复杂的沙箱题里特别有用,因为这类题目往往链长、节点多,一步错步步错,没有快速定位手段会非常痛苦。
alarm作为“垫场函数”的另一个优势是不产生额外的内存副作用。相比之下,如果你拿read来垫场,会真的读入数据并修改缓冲区内容;拿system来垫场,会真的执行命令。只有alarm这种“设个定时器就走”的函数,才真正配得上“无害过渡”四个字。
4.3 从单次利用到多次交互的攻击链设计
当题目需要多次输入交互时,比如第一次输入触发一段ROP,第二次输入触发另一段ROP,中间的时序就很重要。alarm可以用来给两次交互之间留出稳定时间窗口,或者干脆在第一轮就把超时机制取消掉,让后面几轮利用不受干扰。
这算是我自己在做复杂题目时的习惯:凡是远程有超时限制的题目,只要我计划做两次以上的交互,第一轮payload里就一定带一个alarm(0)。先把这个变数消灭,后面的流程再慢慢调。因为一旦定时器还挂着,你调试时每次都要跟时间赛跑,浪费大量精力在“快一点、再快一点”上,根本没心思分析漏洞。
另外,如果你在构造攻击链时发现步骤太多,一次交互放不下所有ROP片段,可以考虑把攻击拆成多段:第一段先alarm(0)解除超时,再调用read把第二段ROP链读入某个可执行或者已知地址;第二段才慢慢做真正的数据泄露。这种“两次交互”的模型在CTF里非常常见,alarm(0)就是第一段提稳定性的核心保证。
5. 完整攻击链复盘:0CTF_2016_warmup的思路还原
5.1 从溢出点到可控寄存器的路径
0CTF_2016_warmup这道题,第一步自然是找溢出偏移。一般用cyclic pattern就能确定覆盖返回地址需要多少字节。找到偏移后,控制rip是很容易的事情,难点在于如何规划rip之后的执行流。
如果你把checksec结果打出来,发现程序没有开太多保护,顶多是栈不可执行或者NX开启,那么直接用ROP是主流选择。题目里肯定有alarm的PLT,这就给了我们前面说的各种玩法。
要注意的是,我不打算贴具体地址值,因为不同版本题目加载基址和环境可能不同。实际做的时候,用objdump或IDA找到alarm的PLT地址,用ROPgadget找到pop rdi; ret和其他需要的小片段,按照当时的题目环境动态计算即可。这也是做CTF的基本素养:不要死记硬背writeup里的地址,要理解每一步在做什么。
5.2 如何把alarm塞进ROP链而不打乱布局
对于这道题,最有效的做法是在ROP链开头插入一段:pop rdi; ret,rdi设为0,然后跳alarm的PLT。这样做就是取消程序初始化时设置的那个定时器。后面再接上你的system或open/read/write链,整个payload就稳了。
关键点是这个过程不需要额外找新的gadget:你本来构造system链就需要pop rdi; ret,现在只是多用一个pop指令和alarm的PLT地址,payload长度增加8字节左右。成本极低,收益却很大。
有些writeup还会稍微改动:不调用alarm(0),而是把rdi设成一个比较大的秒数,比如alarm(60)。区别在于alarm(60)是“重置定时器为60秒”,alarm(0)是“取消定时器”。在大多数题目里两者都能用,但我个人更推荐alarm(0),因为它更干净,不给后续执行留任何定时器隐患。如果你的题目环境后续还有别的逻辑依赖定时器,那就得看具体场景再选择。
5.3 本地调试与远程环境之间的差异坑
这道题最典型的一个坑就是本地和远程行为不一致。本地你爱怎么调都行,远程却经常在payload执行到一半时断连。以前我遇到这种情况会反复检查payload长度和gadget选择,浪费了很多时间。后来养成了习惯:遇到远程断连,第一时间排查定时器问题。
验证方法很简单:在本地用strace或gdb确认程序启动时有没有调用alarm,或者用objdump/IDA检查初始化代码里有没有对alarm的引用。如果有,那远程断开大概率就是它搞的鬼,直接在ROP链里加一个alarm(0)即可。如果没有定时器,再去排查别的因素。
另一个坑是alarm在PLT里存在,但如果程序只调用过一次alarm,而且开启了某些编译优化,PLT表项可能存在但间接跳转目标需要额外解析。这种情况下直接跳PLT可能出问题,稳妥做法是先利用puts或write泄露libc基址,再用libc里的alarm真实地址。在warmup这种简单题目里一般不用走这一步,但一旦遇到,你要知道有这个可能性。
6. 实战之后:选gadget时的几条判断标准
6.1 优先“一个函数解决”,其次“少参数函数过渡”
自己在赛场上总结下来的判断顺序:第一,看能不能用一个函数直接搞定目标,比如有后门就优先后门;第二,如果没有现成后门,再看单参数函数能不能至少帮你完成一个中间步骤;第三,实在不行才去选execve这类需要三四个参数的“全家桶”。
alarm就属于“单参数中间步骤”那一档里最常出现的选手。它不像sleep在CTF环境里有时会让人担心卡住执行流,也不像usleep的参数单位容易搞错。虽然sleep也是单参数,但sleep会真的挂起进程,alarm则只是设置一个信号,不会阻塞执行流。在ROP里这一点区别很大——alarm调用完可以立刻继续执行后面的链子,而sleep会卡住进程,导致后续ROP没法继续。所以选“过渡函数”时,alarm的优先级通常远高于sleep。
6.2 alarm vs sleep vs usleep的选型对比
这三兄弟都是一参数“延时”函数,但用途天差地别:
| 函数 | 行为 | ROP场景下的适用性 |
|---|---|---|
| alarm | 设置SIGALRM定时器,到点发信号,不阻塞执行 | 高:可用于取消定时器、时间侧信道、过渡垫场 |
| sleep | 挂起当前进程直到时间结束 | 低:会阻塞执行流,不适合放在ROP链中间 |
| usleep | 微秒级挂起 | 低:参数单位容易混淆,同样会阻塞执行流 |
我见过一个特别容易犯的错:把alarm当成sleep用。alarm(5)之后程序并不会睡5秒,它只是5秒后发一个信号。如果你以为程序会先等5秒再继续,那对整个执行流的理解就偏了。在ROP场景里,alarm设置完立刻返回,后续链子继续跑。这个差异必须记牢,否则会把payload的时序完全搞乱。
6.3 我踩过的几个坑和习惯性做法
踩坑1:以为alarm(0)会重置现有定时器而不是取消。实际上POSIX标准里alarm(0)就是取消。如果你希望“重新计时”,需要先alarm(0)再alarm(N),不能在一次调用里完成“从剩余时间重新开始算”。
踩坑2:忘记alarm的PLT解析问题。在部分题目里,PLT表项不是马上就可用,如果libc地址没加载,直接跳PLT的alarm可能触发错误。稳妥做法是先利用puts或write泄露libc基址,再跳真实地址。
踩坑3:时间侧信道盲注里,alarm的信号会被某些环境的安全机制吃掉。如果远程环境有seccomp或者信号过滤,alarm不一定能可靠触发SIGALRM。要提前用小流量测试一下alarm到底灵不灵,别等盲注打到一半才发现判断条件全部失灵。
习惯性做法:我拿到一个新题目,会先扫一遍程序里有没有alarm/read/write这类“教学友好型”函数。只要程序初始化里带alarm,我就默认这个题目在远程大概率有超时限制,于是ROP链里默认加上alarm(0)。这个习惯帮我省了很多调试时间,也让我遇到“本地通远程断”的问题时能第一时间定位。
最后再分享一个小技巧:如果你在构造ROP链时发现某个节点怎么调试都过不去,别急着重写全部payload,先在这个节点之前加一个alarm(1)。观察程序是否在1秒后被杀,就能判断你的执行流到底走到哪一步了。这个小“探针”比什么都好用。alarm在ROP里的价值,往往就是在这些最不起眼的地方体现出来的。