假设即将运行一个不受信任的程序。这个程序可能是学生提交的作业,也可能是网上下载的应用程序。出于安全考虑,我们会限制这些程序的运行权限,防止它们破坏整个系统。操作系统有很多安全策略能够帮我们实现这一点。但是有一种特殊情况需要考虑:一旦这些程序运行起来,如何可靠地将它们杀死。通常情况下结束正在运行的程序是很普通的操作,但有时会变得非常棘手,甚至比我们想象的麻烦得多。本文将就这个话题展开讨论。当然关机断电重启这种“杀敌一个,自损八百”的方案不在本文讨论之列。
现在我们已经运行了一个来历不明的程序。出于安全性考虑我们是切换到一个权限受限的用户ID(UID)来运行的,然后打算将它杀掉。不幸的是这个程序不想坐以待毙,于是在生和死之间展开了激烈较量。
死亡繁殖
通常情况下,我们是知道程序的进程号pid的,于是可以调用Linux提供的系统调用kill()直接消灭它:
kill(pid, 9)
这里的参数9表示SIGKILL-无条件杀死进程。但是黑客不会坐以待毙,他会使用类似下面的“fork炸弹”来防卫:
while(1) {
if (fork())
_exit(0);
}
这段代码很简单,在一个无休止的循环里反复进入fork()。Fork的本意是分叉,在这里可以理解成繁殖:一个母体一分为二,繁殖出一个一模一样的子体:一个新的进程。产后的母体和出生的子体最终都会从fork()中走出来,只是对于母体,fork()返回的是子体的进程号pid(不等于0),而在子体里,fork()返回0。这将导致接下来母体和子体执行轨迹的分叉:母体调用_exit()自杀,子体成了新的母体,重复繁殖子体,母体自杀,繁殖子体,母体自杀…过程。fork()创建的子体本质上是一个普通的进程,有自己的进程号pid,而且该pid肯定和母体不同,可能和早已离世的祖母祖先们也不同。最终的整体效果是,这段程序以内核允许的最快速度不断改变自己的pid号。
我们可以尝试执行上述程序(最好在虚拟机里),看看会有什么效果。基本上没很么效果,用ps查看进程,察觉不到进程数量变化了多少;同时内存使用也很正常。它在系统里静静陪伴我们,且电脑可以正常反应和使用。不过很快就会从风扇的飞转发现CPU负载异常,用top命令看见CPU使用率飙升,可是排行榜显示一切正常,看不出哪个进程消耗了这些CPU资源。
第一回合:杀死进程名
显然kill(pid, 9)已经无能为力了,因为恶意程序的pid一直在变,无法用一个特定的pid杀死它,就像玩打地鼠游戏,不知道地鼠会从哪个洞里钻出来。好还有killall()可以使用:根据进程的名字杀死它。每个进程都有名字,也就是进程的可执行文件名。fork()繁殖的子进程虽然拥有不同的pid,但名字不会变。不过该方案似乎不怎么有效,killall()在大部分情况下返回”no process killed”,就是什么进程也没杀死。即便偶尔成功地杀死了进程,死者也是母体,子进程依然顽强地繁衍生息。
怎么会这样?先看一下killall()是怎么工作的:
- 读取内核进程列表,寻找一个名字相符合的进程;
- 于每一个找到的进程,调用kill(pid, sig)终止它
我们知道,Linux是抢占式内核,当我们在调用killall()的时候,黑客的程序的死亡繁殖也在全速运转。由于第2步的kill()花费的时间较长,在这段时间里很大可能性是黑客程序已经调用fork()繁殖出子进程,完成“金蝉脱壳”,killall()找到的进程是已经没什么价值的,即将自杀的母体。也就是说,恶意程序的繁殖速度超过消灭它的速度,导致在消灭之前完成了繁殖。
这是事实。每调用1000次killall()才可能有一次彻底杀死恶意程序。我们不能依赖这么低的成功率来清理系统。
第二回合:限制繁殖
既然自我复制是恶意程序能够肆虐的根源,那么干脆一刀切,禁止它拥有这样的能力。一种办法是剥夺其运行fork()的权利,让fork()不能正常执行。这有点像绝育手术,让它失去繁衍后代的能力。Linux提供的seccomp()系统调用可以实现这一点。另一种方案是限制其拥有进程的个数,极端情况下,只允许其拥有一个进程,那么即使有fork()能力也无法产生第二个进程。这有点像计划生育,但更极端,只可生0个孩子。做到这一点也不难,前面说过,恶意程序是以另一个用户的身份允许的,将该用户的RLIMIT_NPROC参数设为0即可。
这样做虽然能够防止恶意程序的死亡繁殖,但过于简单粗暴:万一程序真的需要创建子进程才能正常运行怎么办?要知道多进程是很常见的工作方式。
第三回合:杀死pgid
仔细研究kill()的用户手册发现,pid参数有以下四种取值:
- Pid > 0: 想要杀死的进程的pid号
- Pid < -1: 取绝对值后,代表想要杀死的进程组的ID号,即pgid。
- Pid == 0: 杀死当前进程组里的所有进程
- Pid == -1: 杀死允许杀死的所有进程(除了当前进程外)
既然杀死特定进程办不到,杀死进程组(pgid)看上去是一个可行的办法,因为fork()出来的进程虽然pid变了,但pgid不会变,继承了母体的值。杀死进程组,也就是杀死进程组里所有进程,自然包括恶意程序。但恶意程序早有防备。由于修改pgid不需要特别的权限,普通用户也可执行,恶意程序又进化成以下形式:
while(1) {
if (fork())
_exit(0);
setpgid(0, 0);
}
对比前面的代码,多了一行调用setpgid(),目的是把进程的pgid改成和pid一样。这样一来,它的pgid和pid一样时刻在变,不可预测。于是通过pgid杀死进程变得和通过pid杀死进程一样不可能了。
第四回合:反戈一击
上述关于pid的四种取值,还有最后一种:杀死允许杀死的所有进程,由于不需要提供特定的pid或pgid,也可以用来对付恶意程序。除了root用户,所有本用户创建的进程都是允许被杀死的。前面提到,出于安全性,恶意程序是以单独的UID来运行的,同时它自己也没有权限修改自己的UID。所以只要以同样UID运行一个进程,在这个进程里调用kill()并使用第四种取值(-1),就可以杀死恶意程序。这个进程叫做“恶意程序收割机”,简称“收割机”,代码如下:
setuid(UID);
kill(-1, 9);
当然以上代码仅仅是个示例,现实中如果这样写法是很危险的:一旦setuid()切换用户失败,当前用户依然是我们自己,随后的kill()将让我们痛不欲生(想想-1代表什么)。这里假设一切正常,setuid()将用户ID切换至和恶意程序一样;然后用pid为-1调用kill(),杀死允许杀死的所有进程,当然包括恶意进程。(由于与killall()对进程列表加锁方式不一样,kill()在杀死恶意程序之前,不会给它任何调用fork()的机会。)
到此恶意进程被终结,看上去问题完美解决了。其实不然,黑客不可能坐以待毙,会千方百计寻找其中的漏洞。果然上述方案的漏洞浮出水面:收割机调用setuid()来切换成和恶意程序同样的用户UID后,的确能杀死恶意程序,但同时也把自己暴露在危险之中:恶意程序也可以用同样的方式杀死收割机!随即恶意程序进化成如下版本:
while(1) {
if (fork())
_exit(0);
kill(-1, 9);
setpgid(0, 0);
}
对比上一个版本,这个版本里多了一行和收割机一模一样的kill(-1,9)。也就是说,恶意程序先发制人,杀死所有它能杀死的进程。一旦收割机调用setuid()切换成恶意程序的UID,恶意代码就能反过来把收割机杀死。这是一种互相伤害,或反戈一击,恶意程序从消极防卫转为积极防卫。虽然恶意程序也有一定的被杀概率,但和killall()一样,不能依赖这样一种不可靠的方式来清理系统。
第五回合:非对称绝杀
事到如今,似乎所有手段都用过,已无路可走。不但没有干净利落地干掉恶意程序,自身都不保。反思上一回较量,如果能杀死恶意程序,又不把自己暴露在危险之中,问题就解决了。这意味着一种非对称:收割机只有寻求非对称优势才能达成目的。仔细研究kill()手册发现如下描述:
For a process to have permission to send a signal, it musteither be privileged (under Linux: have the CAP_KILL capability in the usernamespace of the target process), or the real or effective user ID of thesending process must equal the real or saved set-user-ID of the target process.
简而言之,这句话给出了一个进程能够杀死另一个进程的前提条件:每个进程都有effectiveUID (euid), real UID (ruid)和saved UID (suid);如果进程A想杀死进程B,A的ruid或euid必须和B的ruid或suid一致。这是一种非对称性,对A的要求和对B的要求是非对等的。
如果启动恶意程序时,将它的三个UID都设成同样的值:target_uid。是否能为收割机构筑一个(euid,ruid, suid)的组合,从而收割机可以杀死恶意程序,而恶意程序不能伤害收割机?答案是肯定的。如果我们把收割机的(euid,ruid, suid)设成(target_uid, reaper_uid, X) (这里X表示任何不等于target_uid的值),则:
收割机可以杀死恶意程序,因为它的euid等于恶意程序的ruid;
恶意程序不能杀死收割机,因为它的euid和ruid不等于收割机的ruid和suid。
于是终极版本的收割机代码出炉了:
setresuid(reaper_uid, target_uid, reaper_uid);
kill(-1, 9);
该收割机可以完美杀死恶意程序。要注意reaper_uid不能和其它正在运行的进程匹配,否则这些进程也会被杀掉。Linux和FreeBSD都支持setresuid()系统调用,但NetBSD目前还不支持。
结论
- 安全是建立在非对称性基础上的;必须对入侵者保持非对称的优势
- 上帝在细节里;要善于从细节中发现解决问题的方法
- 拥有狼一样的对手是幸运的;否则每个人都像澳大利亚的动物一样呆萌