对于hacker来说,最有趣的事情莫过于破坏软件设计者的原有规则,重新建立属于自己的规则了。姑且不论这个行为是否合法或违规,单就技术本身而言,矛与盾、攻与防、破坏与重建的过程中,为了达到最终目的而衍生出来的奇妙技术,再配上天马行空的想像和创造足以 让人着迷不已。
尽管Linux内核开源,升级或替换内核十分方便,但仍有一些特殊场景,需要在不替换内核的前提下给内核“动手术”。考虑如下两种场景:
对于前一种场景,Linux有已一套livepatch的解决方案。然而livepatch当前并不支持嵌入式常用的arm架构,因此针对后一种场景,我们只能采用一些非常规的手段达到目的。
我们要完成两个任务:在不重编内核的前提下给嵌入式设备的Linux内核做安全修复和安全加固。修复即是规避掉有缺陷的函数;加固即是保留原有函数不变,只不过我们需要在执行函数功能之前,先检查函数的传参是否合法,是则放过,否则阻断。以图为证:
无论我们出发点的好坏,内核并不期待这种函数执行流的改变,那么只能去hack它。好在内核提供了可插拔的模块功能,我们可以将hack的逻辑放入内核模块,然后插入到内核中。内核模块由于是内核功能的扩展,两者工作在同样的权限和地址空间中(与之对比的是用户态程序,只能通过系统调用获得内核支持),插入内核后便可修改内核自身的数据。请见下图:
既然需要修改函数的调用关系,那么有两种修改办法:1)修改父函数;2)改造子函数。从这里开始,所有的函数都是以二进制汇编指令的形态呈现。
修改父函数的函数调用指令,将offset替换成修复函数地址。这样做的优点是侵入简单,缺点是但凡调用B函数的父函数都需要修改,查找父函数的工作量难以承受。
改造子函数意味着需要替换子函数的二进制指令,在子函数中侵入一个无条件跳转,跳转的目标是新子函数。这样做的优点是不用满天下寻找父函数,缺点是对子函数的侵入需要仔细设计。
这里有几个比较tricky的地方:
跳板函数专门预留4个指令长度的空间,用作子函数B的第1、2条指令存放,同时有两条指令长距离跳转到B函数的第3条指令地址处。当参数检查函数想要恢复B函数功能时,它先执行跳板函数中保存的两条指令,再回跳到原函数第3条指令处继续执行。那么通过跳板函数的“接力”,子函数B的指令被完整执行,这样也就保留了B函数的功能不受影响。
通常情况下,编译器会替我们打理好所有的栈操作和寄存器分配任务(事实上我们也不需要操心这些细节)。而如今我们不能信任编译器能帮我们做好这些事。实现跳板函数时,首先我们会使用naked强制编译器不生成栈的序言和结尾(prologue and epilogue)。其次我们使用asm自己编写汇编指令操纵所需寄存器,并且使用volatile拒绝编译器帮我们做任何优化。
至此看上去问题已经解决了,可事实上并非如此。在考虑了这么多情况后,我们的设计达到了最优吗?我认为不是的。功能上的达标跟设计上的beautifully还有很长的路(比如把这种静态的宏定义设计成为动态可注册的方式),设计上的美学追求将永无止境。
作者介绍:
刘涛:5年linux内核开发经历,熟悉操作系统原理,擅长C语言、汇编,热爱底层技术,曾在业余时间独立开发过操作系统。目前是ThoughtWorks中国安全团队核心成员。我的个人github地址是 https://github.com/liutgnu
领取专属 10元无门槛券
私享最新 技术干货