性能文章>在调试器里看LINUX内核态栈溢出>

在调试器里看LINUX内核态栈溢出原创

4年前
12915319

图灵最先发明了栈,但没有给它取名字。德国人鲍尔也“发明”了栈,取名叫酒窖。澳大利亚人汉布林也“发明”了栈,取名叫弹夹。1959年,戴克斯特拉在度假时想到了Stack这个名字,后来被广泛使用。

今天的CPU和软件都是基于所谓的栈架构的,CPU运行时,几乎每时每刻都是离不开栈的。

简单说来,每个普通线程一般都有两个栈,一个位于用户空间,供在用户空间执行时使用,另一个位于内核空间,供这个线程执行系统调用、掉入陷阱或者当CPU在执行这个线程时遇到中断时用。

因为系统中每个进程都有一个用户空间,但是内核空间只有一个,所以内核空间的栈一般都是比较小的。对LINUX内核来说,更是这样。多大呢?32位时是8KB,64位时是16KB。

栈是流动性很大的一个临时空间,每次想到栈,我都不由得想起那部经典的电影——《新龙门客栈》,茫茫大漠之中,孤零零的一座客栈,不同身份的客人从不同方向,带着不同的目的,在这里相遇,可能是姻缘邂逅,可能是狭路相逢,也可能是千里追杀…

image.png

软件世界的栈也很忙碌,也很变幻莫测,也很危机四伏,也很有故事。

几年前曾经开笔写了一本书,叫《栈上风云》,可惜只完成了5章,便搁置在那里了。

闲言打住 ,今天先说说LINUX内核态栈溢出。

启动一个Ubuntu作为调试目标,再启动一个Ubuntu作为调试主机。在主机上启动GDB,开始双机内核调试。(详细过程可以参阅高端调试网站的文章)

准备好GDB后,在目标机中按Alt + PrtScr + g触发其中断到调试器,片刻之后,GDB中收到消息,执行bt命令观察执行官过程。

83db67e5c28b270c8013bbf271287f2e.jpg

上图中的栈回溯比较完美地展示了LINUX内核处理中断的过程,特别地,这一次是在处理键盘中断,也就是我们刚才按下的中断热键。

执行frame 20命令切换到#20栈帧,执行info locals观察函数的局部变量:

(gdb) info locals
old_regs = 0x0 <irq_stack_union>
desc = 0xffff88003e030000
vector = 49
__func__ = "do_IRQ"
执行info args观察函数的参数:
(gdb) info args
regs = 0xffff88003b44b9e8
使用万能的print命令打印regs变量:
(gdb) p *regs
$16 = {r15 = 18446612133308578668, r14 = 33617120, r13 = 142166, r12 = 0, bp = 18446612133308578480, bx = 18446612133308578496, r11 = 659, r10 = 34472032, r9 = 1, r8 = 8392803, 
  ax = 13282968638105362717, cx = 23, dx = 868807409, si = 1928222438591233, di = 1302, orig_ax = 18446744073709551566, ip = 18446744071579876365, cs = 16, flags = 662, sp = 18446612133308578456, 
  ss = 24}

这些寄存器是处理中断前保存的寄存器状态。其中的sp寄存器就是栈指针(stack pointer)。目前显示为10进制,观察不便,使用printf格式化一下:

(gdb) printf "%p\n", regs->sp
0xffff88003b44ba98

Linux的内核态栈使用一种特殊的约定,分配栈时绝对是按栈大小对齐,然后在栈的最低地址处存放一个thread_info结构体,即如下图所示。

image.png

这样设计带来的一个好处是非常容易低从栈指针得到栈的位置,方法就是把低位抹掉(换为0)。
因为目标系统是64位,栈的大小是16KB,因此,只要把低14位替换为零就得到栈空间的起始地址。

(gdb) printf "%p\n", regs->sp&(~(0x4000-1))
0xffff88003b448000

看着是不有点野蛮,有点吓人?
或者你可能怀疑这样做的正确性,来验证一下吧,先打印thread_info结构体:

(gdb) p *(struct thread_info*)0xffff88003b448000
$19 = {task = 0xffff88003bdf0e80, flags = 8, status = 0, cpu = 0}

看着靠谱么?靠谱的,第一个字段是著名的任务结构体,Linux内核源代码中著名的current宏就是从这里取到的哦。所属CPU为0也是合理的。
进一步验证task结构体:

(gdb) p *((struct thread_info*)0xffff88003b448000)->task
$20 = {state = 0, stack = 0xffff88003b448000, usage = {counter = 2}, flags = 4194304, ptrace = 0, wake_entry = {next = 0x0 <irq_stack_union>}, on_cpu = 1, wakee_flips = 0, 

各个字段的字也都很合理,特别是其中的stack字段代表这个线程的内核态栈空间起始地址,和我们手工算出来的一模一样啊。
在GDB中执行monitor ps命令列出所有线程,找到线程号2112:

(gdb) monitor ps A
Task Addr   Pid   Parent [*] cpu State Thread Command

0xffff88003bdf0e80 2112 1778  10   R  0xffff88003bdf18c0 *gnome-software

可以看到它的任务结构体地址和我们前面通过thread_info找到的完全相同,关联的CPU为0号,所属进程的程序名为gnome-software,名字前面的*代表它是CPU当前正在执行的线程。
时光倒流一下,在我们按Alt + PrtScr + g热键那一刹那,CPU正在执行gnome-software进程的2112号线程,键盘硬件通过中断控制器给CPU发中断信号,CPU响应中断信号跳转到IDT表里的中断处理函数,处理中断,因为这是个普通的中断,IDT里面登记的是普通的中断门,不需要切换栈,于是CPU就借用当前线程的栈来处理中断。对中断处理函数来说,必须要做好准备,“借栈使用”,这一般被称为可以在arbitrary context(任意上下文里)执行。
对内核态栈有所了解后,可能有朋友想到了,这么小的栈,不够用可怎么办?
这确确实实是个严肃的问题。
不轻信不迷信,做个实验来看看吧!
在老雷编写的llaolao内核模块中增加一个函数,名叫wastestack,故意做递归调用:

static void wastestack(int recursive)
{
	int nothing;
	printk("wastestack %d\n",recursive);

	while(recursive)
		wastestack(recursive-1);
}

加载这个驱动,通过debugfs中的虚文件触发这个调用,好戏便开始了!

这样递归的话,只要recursive的初始值足够大,那么肯定会把栈用完的,用完了会怎么样呢?
执行了一会后,GDB中先接收到一个Oops,内核打了个喷嚏。

921744192a89c6329009e598b0be03c9.jpg

理解Oops开始的描述:

[ 6810.797013] NMI watchdog: BUG: soft lockup - CPU#0 stuck for 22s! [bash:2441]

看来是NMI看门狗超时了,通过NMI激发得到执行机会后,打印出这个Oops给我们看,意思是0号CPU在2441进程上粘住了,已经22秒。
值得单独写一篇文章来讨论系统的狗,今天暂且放过。
再仔细观察Oops信息中的栈起始地址,以及目前的RSP值(代表已经使用到的位置):

task.stack: ffff88003aea4000
RSP: 0018:ffff88003aea61c8

二者的差大约是还可以用的栈空间(要刨掉thread_info结构体的大小),看来还有大约8K,用了一半。
在GDB中发出c命令,让CPU继续残忍执行。
过了一会,又出现一个Oops,继续循环,又一个Oops,CPU义无反顾,继续勇敢地奔跑,系统忙碌着,风扇的声音变大,…
但是如此持续大约60秒时,突然安静了,GDB中最后的几行信息如下:

[ 8430.351318] wastestack 7621
[ 8430.354240] wastestack 7620
[ 8430.357109] wastestack 7619
[ 8430.359711] wastestack 7618
Ignoring packet error, continuing...

Thread 390 received signal SIGSEGV, Segmentation fault.
Ignoring packet error, continuing...
Ignoring packet error, continuing...

GDB报告通信错误,对方失联了!
在失联之前,内核报告在390线程发生段错误,访问了不该访问的。

追溯GDB记录下的最后一次Oops:

fc61a35ca11fa483feed460de5abdf23.jpg

比较当时的栈信息和栈指针:

task.stack: ffff88003aea4000
RSP: 0018:ffff88003ae48788 

已经越界很多了,大约超出了368KB:

0:000> ? (a4-48)*1000
Evaluate expression: 376832 = 00000000`0005c000

也就是,2441号线程里的递归调用不仅用完了自己的栈空间,扫荡了自己线程的thread_info结构体,还继续碾压了368KB的内核数据,哪些数据被碾压了呢?内核的那个字节都很敏感,何况300多K啊。
怎么没有保护呢?
就是没有,有点不可思议,但事实上就是没有。
其它操作系统也是这样么?不是的,或者说肯定不全是。
比如在Windows系统中,每个线程的内核态栈也是有明确的登记:

   kd> !thread
Stack Init 8eb6eed0 Current 8eb6e9d0 Base 8eb6f000 Limit 8eb6c000 Call 00000000

而且前后都有保护页:

kd> dd 8eb6c000-10
8eb6bff0  ???????? ???????? ???????? ????????
8eb6c000  ffffffff ffffffff ffffffff ffffffff
8eb6c010  ffffffff ffffffff ffffffff ffffffff
8eb6c020  ffffffff ffffffff ffffffff ffffffff
8eb6c030  ffffffff ffffffff ffffffff ffffffff
8eb6c040  ffffffff ffffffff ffffffff ffffffff
8eb6c050  ffffffff ffffffff ffffffff ffffffff
8eb6c060  ffffffff ffffffff ffffffff ffffffff

一旦有溢出,就会碰到保护页,触发著名的双误异常。

如此看来,Linux内核在安全性和可靠性方面还有不少的工作要做。

点赞收藏
张银奎

前英特尔软件架构师

请先登录,查看3条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

浅析AbstractQueuedSynchronizer

浅析AbstractQueuedSynchronizer

19
3