本内核调试器拥有如下几个主要模块:虚拟化框架(调试框架)、接口模块、反汇编引擎、键盘驱动模块、符号表模块、调试控制台模块等。以下内容为各个模块的设计与实现。
3.1虚拟化框架
虚拟化框架实现的主要功能就是创建一个虚拟CPU,并将在当前物理CPU上运行的操作系统转移到虚拟CPU上,而调试器则运行在当前CPU上。实际上就是CPU控制权的转移,或者说是CPU上下文的转移。
这样在虚拟CPU上运行的一切代码产生的CPU异常都将最先传递到物理CPU上,也就是我们的内核调试器上,调试器获取到执行权限后,可以阅读代码、下断点等操作,然后将执行权限交回。
这样的实现只依赖上述的两种切换:VMEntry和VMExit,也就是主机和虚拟机的切换,并不依赖中断向量表,因此传统的反调试手段诸如检测中断向量表等都无法探测到我们的内核调试器。
3.1.1检测CPU是否支持
我们的调试器在运行前要检测当前的CPU是否支持Intel-VT虚拟化技术,同时还要检测BIOS是否启用了该特性,否则调试器是根本无法使用的。
Linux的内核调试器是以内核模块的形式加载运行,因此它有最高CPU特权级(Ring0),可以执行特权指令。
调用CPUID这条指令可以检测当前CPU是否支持VMX指令集。读取MSR寄存器MSR_IA32_FEATURE_CONTROL的第4位可以检测BIOS是否启用VT特性。
3.1.2构造虚拟化环境
检测当前CPU支持硬件虚拟化之后就可以构造虚拟化环境了。首先设置虚拟机环境(VMCS),将当前CPU上下文完整地保存到VMCS中。然后启动虚拟机,这时候操作系统就被转到虚拟机中运行了。
一旦操作系统置于我们的虚拟CPU中运行,那么这个虚拟CPU上发生的任何异常,都会被我们运行在物理CPU上的VMM处理函数优先捕获到。我们主要关心单步异常和断点异常。在此基础上加以处理,就完成了内核调试器的基本框架。
图3-1时序图:构造虚拟化环境
3.2 界面
一个调试器还必须有一个友好的接口,我参考了Windows平台的著名调试器SoftICE,这是我最喜欢的一款调试器,他的开发公司已经被收购,并且早已停止了开发。作为一款划时代的优秀调试器,不再继续开发是一件非常遗憾的事情。
SoftICE使用字符接口,操作接口布局合理,简洁。从上到下依次为寄存器窗口、代码窗口、控制台窗口、控制台输入、状态区。如图3-2所示。
图3-2 SoftICE界面截图
我同样使用了这种设计,以纪念这款曾经无比辉煌的Windows内核调试器。如图3-3所示。
图3-3 我的VMXICE界面截图
3.2.1获取显存地址
在这个调试器中,接口部分我们只能通过直接写显存来实现,因为我们的调试器运行在物理CPU上,无法得到操作系统图形API的支持。
在加载调试器时调用FrameBuffer驱动程序接口可以获取到显存的物理位址。有了显存地址我们就可以随心所欲的画接口了。在接口方面我没有设计的很复杂,主要参考了Windows平台著名的SoftICE调试器,这是一个字符接口的内核调试器。为了实现这个接口我只需内置一个英文字库到调试器,就可以画出想要的字符接口了。
下面的代码实现了获取显存基地址以及当前显示模式参数:
#include<linux/fb.h>
VOIDVideoInit(void)
{
structfb_info *fbi = registered_fb[0];
VideoBuffer_va= (char *)fbi->screen_base;
VideoWidth= fbi->var.xres;
VideoHeight= fbi->var.yres;
VideoBitPerPixel= fbi->var.bits_per_pixel;
VideoPitch= fbi->fix.line_length;
printf("FrameBufferVirt: 0x%p\r\n",(PVOID)VideoBuffer_va);
printf("FrameBufferPhys: 0x%p\r\n",(PVOID)fbi->fix.smem_start);
printf("Resolution:%dx%dx%d\r\n",VideoWidth,VideoHeight,VideoBitPerPixel);
printf("Pitch:%d\r\n\r\n",VideoPitch);
StartX=(VideoWidth-GUI_Width*CHAR_WIDTH)/2;
StartY= (VideoHeight - GUI_Height * CHAR_HEIGHT) / 2;
pVideoBufferBak= (PUCHAR)kmalloc(VideoHeight * VideoWidth * 4,GFP_ATOMIC);
pVideoBufferPrint= (PUCHAR)kmalloc(VideoHeight * VideoWidth * 4,GFP_ATOMIC);
}
3.2.2输出字符与文本
有了显存基地址之后还不能直接输出字符,还需要一套字库,由于我们是英文字符接口,所以只使用256个字符的字库即可。
这里我使用了8*14*256的字库,使用如下方式定义:
charcFontData[] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7E, 0x81, 0xA5, 0x81,0x81, 0xBD, 0x99, 0x81, 0x7E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x7E,0xFF, 0xDB, 0xFF, 0xFF, 0xC3, 0xE7, 0xFF, 0x7E, 0x00, 0x00, 0x00,0x00, 0x00, 0x00, 0x6C, 0xFE, 0xFE, 0xFE, 0xFE, 0x7C, 0x38, 0x10,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x38, 0x7C, 0xFE, 0x7C,0x38, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x3C, 0x3C,0xE7, 0xE7, 0xE7, 0x18, 0x18, 0x3C, 0x00, 0x00, 0x00, 0x00, 0x00,0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0x7E, 0x18, 0x18, 0x3C, 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xE7, 0xC3,0xC3, 0xE7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF……}
另外定义了一些函数用于支持字符输出,主要有:
ULONGPrintChar(ULONG x,ULONG y,ULONG ForeColor,ULONG BackColor,ULONGbTransparent,UCHAR CharAscii);
VOIDDrawCursor(ULONG x,ULONG y,BOOLEAN isInsertState,BOOLEAN ShowState);
VOIDPrintStr(ULONG x,ULONG y,ULONG ForeColor,ULONG BackColor,ULONGbTransparent,PUCHAR String,ULONG FillLine);
VOIDBackupScreen(void);
VOID RestoreScreen(void);
PrintChar可在屏幕坐标(x,y),以指定背景色和前景色画出字符。
DrawCursor可以在屏幕坐标(x,y)画出游标。
PrintStr是对PrintChar的进一步封装,用于输出一段字符串。
BackupScreen和RestoreScreen用于呼出调试器前的屏幕备份和退出调试器恢复屏幕。
3.2.3控制台缓冲区
控制台相当于linux的shell。可以接收用户命令,显示命令结果等。控制台应具有历史记录和翻页功能,而一个屏幕显然不能容纳下所有的这些,必须使用控制台缓冲区记录这些。
我定义了一个高度为100行的控制台缓冲区。并且当超过100行内容时会自动将前面的内容删除,类似于数据结构中先进先出队列的概念,支持这种功能需要将缓冲区超长化,例如设计容纳100行数据,我们实际要定义超过100行数据的缓冲区,才能实现这个功能。也就是当有新内容进入时,先复制新内容,并检查当所有内容超过100行时,进行删减头部的操作。
与控制台有关的函数主要有如下定义:
VOIDConsolePrintChar(UCHAR c,ULONG ForeColor,ULONG BackColor);
VOIDConsolePrintCurrentPage(void);
VOIDConsolePrintPreviousPage(void);
VOIDConsolePrintNextPage(void);
VOIDConsolePrintStr(PUCHAR str,ULONG ForeColor,ULONG BackColor);
ConsolePrintChar输出一个字符到控制台缓冲区。
ConsolePrintStr是对上一个函数的封装,可以输出一个字符串。
ConsolePrintXXPage函数用于将控制台缓冲区内容输出到屏幕。
3.3键盘驱动
在我们的内核调试器中并不能收到操作系统处理好的键盘信号,因此我们必须自己完成这些操作。调试器在画出接口后需要不断的轮询键盘IO端口,以获取击键事件。
3.3.1键盘扫描码
获取到的数据是键盘扫描码,还需要一张键盘扫描表,用查表法确定按键。
扫描码定义在scancode.h头文件中,扫描码到键码的转换程序在scancode.c中定义,负责转换函数就是简单的switch语句。
3.3.2键盘模块接口
这个模块支持PS2接口的键盘,这种键盘只需要简单的IN/OUT指令即可响应键盘的输入。特别需要注意的是,当我们呼出调试器后,需要通过IOAPIC关闭键盘中断,否则我们对键盘的操作将影响到操作系统对键盘中断的处理。
最终导出了如下一个函数:
ULONGKeyboardReadKeystroke(PUCHAR pc, PBOOLEAN pisMouse);
该函数从PS2接口获取一个动作,这个接口可能被PS2鼠标影响,因此我们要过滤掉所有鼠标的响应,只需要读键盘的按键,按键码保存在pc指向的UCHAR变量中。
3.4 符号表
3.4.1符号表的作用
调试器反汇编出来的代码应该具有良好的可读性,在这方面我们需要符号表的帮助。
如果没有符号表,反汇编某段程序代码可能是这个样子
push 402010h
call [401067h]
假如有符号表的帮助,例如符号表中有以下记录:
401067 printf
那么上面的那段程序代码就可以反汇编为
push 402010h
call printf
这样我们一眼就可以看出这是调用了printf这个系统函数,大大提高了反汇编后的代码的可读性。
3.4.2符号表的实现
Linux的符号表位于/proc/kallsyms这个檔,这是一个虚拟的内存映像文件,里面包含了所有的内核、内核模块导出的、未导出函数的地址。
每次检索这个檔会有很大开销,最好的办法是将这张表读到内存中。我们知道既然这个文件是虚拟的内存映像文件,那么在Linux内核中肯定就存在已经组织好的符号表。通过研究kallsyms.h头文件发现了两个个有趣的函数:kallsyms_lookup_name和kallsyms_lookup。他们都是未导出的内核函数。通过调用kallsyms_lookup_name可以获取到内核中任意函数的地址。调用另外的kallsyms_lookup内核函数可以对一个地址解析,返回这个地址所在的函数名以及偏移量。使用这个函数可以将反汇编后的“call地址”转换成“call函数名+偏移量”这种形式,我们上面所说的可读性问题,完全可以通过这2个系统函数解决。但是这2个函数是未导出函数,定位他们的位址仍然需要读取/proc/kallsyms这个虚拟映像档。
3.4.3符号表模块接口
符号表的接口就是用过查询kallsyms檔得到的kallsyms_lookup_name和kallsyms_lookup函数,分别对应查函数地址,查函数名和偏移量。