文章>从猫蛇之战再看内核戏CPU>

从猫蛇之战再看内核戏CPU

张银奎
内核
cpu
1月前

连续写了几天的代码,有些疲倦,吃过晚饭,换个工作方式,继续和大家聊猫蛇之战。

image.png

蛇不仅丑陋,而且可能伤人害命,是邪恶的象征。猫与蛇战,代表着讨伐奸邪,是正义之战。猫与蛇战,技艺娴熟,举重若轻,叫人拍手叫绝,看着过瘾,想起也心情舒畅。

image.png

儿时听过很多关于猫的故事,比如传说猫武艺高强,是老虎的老师,教老虎武功时,怕其背叛师门,反目攻击老师,就故意留了一项会爬树的技艺。因为此,老虎是不会爬树的。

再比如,家里吃面条时,不要把面条喂给猫,怕它没有面条吃时,去把蛇当作面条吃。在外面吃完了还好,如果叼回家里来吃就吓人了。

在搜索猫蛇之战的照片时,确实看到了猫吃蛇的照片,有点血腥。

线下版本的猫蛇战先聊到这,我们继续谈计算机世界的猫蛇之战。上回书说到调试器在访问内存时,会使用特殊的probe函数来访问,访问之前会禁止页错误。但是很多问题还没有说透,比如:

  1. 这样禁止了后,访问非法内存时,CPU硬件真的不报异常了么?

  2. 如果要读很长一段内存,那么probe函数会访问一次发现不行就停了,还是像猫与蛇战那样连续作战呢?

  3. probe函数发现不能访问时,会返回一个名为EFAULT的错误码(-14),它是怎么知道访问失败的呢?这个问题有点别扭,需要这样来体会。如果普通函数访问非法内存,一碰就爆炸了,这个函数立刻失去控制,根本没有机会知道刚才发生了什么。

为了以生动的方式(想想猫与蛇战,何其生动,应该感谢猫“类”啊)回答上面的问题,老雷想了个办法。

先写一点代码,调用一下probe函数,让它充当我们的“试验品”。

    static void	ge_probe(void)

    {

    	char data[32] = {0};

    	long addr = 0x880;

    

        long ret = probe_kernel_read(data, addr, sizeof(data));

    	printk("probe %lx got %ld\n", addr, ret);

    }

封装一个简单的函数调用probe_kernel_read,故意指定0x880这个无效的线性地址。0x880肯定无效么?肯定的,小于4K的地址都是无效的。

在proc虚文件的写回调函数里调用这个函数。

    else if(strncmp(cmd, "probe", 5) == 0)

    {

		ge_probe();

    }

再写一个不用probe的野人方法,作为对照。

    else if(strncmp(cmd, "nullp", 5) == 0)

    {

        *(int*)(long)0x880 = 0x88888888;

    }

把这点代码放到一个内核模块中,比如老雷常用的llaolao模块。llaolao代表“刘姥姥”,取“刘姥姥进内核这个大观园”之意,感谢雪芹前辈,为我们的文化宝库增添了这样一个生动的角色。

尝试触发执行上面的两种方法,直接做非法访问时,系统大怒,CPU发出异常,向操作系统告状,操作系统追查叛逆,严惩不贷,当前的bash进程会被kill掉。

image.png

而执行probe方法时,则风平浪静,一且安好。

image.png

为了能观察其中的细节,我们将使用KGDB双机内核调试,用强大的调试器做控制,探微索隐。

可能有看官说,还可以这样玩啊?是的,想想猫蛇之战,如果功夫不够,那可能被蛇吃掉的。对于今天的计算机来说,CPU在以光速奔跑,CPU执行一条指令的时间,光也只能行进几个厘米。猫之所以敢与蛇正面 交锋,靠的是反应速度要比蛇快很多倍。而人的反应速度要比CPU慢不知道多少倍,如果不依靠调试器,怎么看的清楚?

Linus大神喜欢读源代码和加print,但那不是老雷的风格。

说话间,两个虚拟机都跑起来了,一个叫GE64,是调试目标,另一个叫GD64,跑调试器,二者通过虚拟串口通信,已经建立了内核调试会话。

image.png

在目标机器中,编译加载llaolao模块,然后执行如下命令让目标机中断到调试器怀抱。

    echo g > /proc/sysrq-trigger

目标机应声中断,在调试器中执行如下命令对处理页错误异常的do_page_fault函数设置一个断点。

image.png

这样设置断点后,恢复执行,发现断点会频繁命中,没法继续玩下去。

为了避免这样的问题,要改变断点的设置方法,先对do_page_fault函数做反汇编,找到访问cr2寄存器的地方。

(gdb) disassemble do_page_fault

Dump of assembler code for function do_page_fault:

=> 0xffffffff8106b650 <+0>:	push   %rbp

   0xffffffff8106b651 <+1>:	mov    %rsp,%rbp

   0xffffffff8106b654 <+4>:	push   %r13

   0xffffffff8106b656 <+6>:	mov    %rsi,%r13

   0xffffffff8106b659 <+9>:	push   %r12

   0xffffffff8106b65b <+11>:	mov    %rdi,%r12

   0xffffffff8106b65e <+14>:	push   %rbx

   0xffffffff8106b65f <+15>:	mov    %cr2,%rax

   0xffffffff8106b662 <+18>:	nopl   0x0(%rax)

然后对这个位置下断点 : b *0xffffffff8106b662

还要再附加上一个条件:cond 2 $ax==0x880

告诉调试器,只有因为访问0x880触发页错误时才中断给我们看。

这样做好准备后,恢复目标执行,结果怎么样?

目标机还是无法操作,虽然在GDB中指定了条件,但是目标系统中一旦有页错误还是会中断到GDB,GDB判断条件不符合,立刻恢复执行,但是因为反复中断和恢复,目标机还是太慢了。

怎么办呢?修改内核源代码,在do_page_fault函数中插入几行代码,如下图所示。

image.png

这样修改后,等一下就可以把断点设置在条件块内部了,那么就可以精准命中我们希望的条件,又不需要频繁中断到GDB了。

如此修改后,执行make bzImage以增量方式构建内核。

image.png

有些看官可能又惊诧了,这么麻烦啊?

对于Java同行来说,重编内核可能是有点吓人。其实没那么可怕,特别是如果经常这么做的话,其实是很便捷的,修改代码,编译,编译好的复制到boot目录,以新换旧,一杯茶还没有喝好的功夫就搞定了。

使用新的内核重启目标系统,中断到GDB,反汇编do_page_fault函数,寻找新修改的代码地址:

image.png

x86汇编很是浅显易懂,+22的位置是与0x880比较,+31的je指令是条件跳转,如果相等就跳到0xffffffff8106b686 <do_page_fault+54>。那么我们只要对这个0xffffffff8106b686地址设断点就可以了。

     b *0xffffffff8106b686

这样埋好断点后,恢复目标执行,目标系统活蹦乱跳,灵活自如了,不像刚才那样动弹不得。如此看来,能够重新编译内核真是好,可以在高特权的内核空间里安排自己的兵力。

闲言打住,在目标系统中,加载刘姥姥模块,然后执行如下命令触发调用probe动作:

    echo probe > /proc/llaolao

断点如期命中,美哉GDB!

执行bt命令观察CPU的执行过程:

image.png

上面一图值二两白银,在清代时可以买一块地。

这幅图的价值在于,它抓到了一个非常难以抓到的状态,把风驰电掣般飞奔的CPU“停”在了一个非常敏感的位置:因为有人违反系统规则,非要访问不可以访问的地址,CPU硬件发起异常,保存基本的位置信息后投到内核怀中上述。

上面截图中#16和#17中的信息其实就是CPU硬件压入栈的执行非法访问的“黑手”地址,即CS:RIP。0x10是内核代码段的段选择子,RIP指向copy_user_generic_string函数,它正是probe函数中调用的。

感谢强大的调试技术,它帮助我们把CPU停在我们希望仔细观察的地方,让我们可以细细体会,证实了我们的推理。君子戒慎乎其所不睹,恐惧乎其所不闻,亲眼目睹,亲手实践,何其重要也!

继续观察寄存器信息:

image.png

可以看到,rax的值正是0x880,确认这次中断就是我们在llaolao驱动中通过probe函数访问0x880时导致的。

如此看来,问题1的答案有了,使用probe函数时,CPU还是会报异常的,CPU还是会进入到do_page_fault这个处理页错误的内核函数,有调试器抓到的现场为证,千真万确。

如此一来,除了前面提到的第二个和第三个问题,还有其它问题了,CPU在报告页错误时,会回滚状态,把程序指针回退到导致错误的那条指令,而调用probe函数时又能顺利返回到调用者,是谁悄悄调整了程序指针呢?

1319 阅读
请先登录,再评论

评论列表

暂无回复,快来写下第一个回复吧~