4.2调试控制台
调试控制台是整个调试器的工作中心,这个控制台将响应用户的一切输入,完成用户所需的调试功能。
4.2.1命令解析
4.2.1.1数据结构的设计
我将所有命令统一定义在一张表里,这个表是如下的数据结构:
typedefstruct{
CHAR*Cmd; //命令前缀
CHAR*Desc; //命令描述
CHAR*Usage; //命令用法描述
CHAR*Example; //命令用例
PCMD_HANDLERpHandler; //命令分发处理函数
}CMD_HELP,*PCMD_HELP;
这样我就可以把所有命令统一定义,如下所示:
CMD_HELPCmdHelp[] = {
{"BC","Clearbreakpoint","BC [*|id]",NULL,CmdClearBreakpoint},
{"BL","Listcurrent breakpoints","No param forBL",NULL,CmdListBreakpoint},
{"BPX","Breakpointon execute","BPX [addr] if [condition] do [cmd]","bpxntsetvaluekey if \"[[esp+8]+4]==\"imagepath\"\"do \"? byte [esp+4]\"\n",CmdSetSwBreakpoint},
{"CPU","Displaycpu registers information","No param forCPU",NULL,CmdDisplayCpuReg},
{"!DB","Displayphysical memory(byte)","!DB [address]","!db39000\n",CmdDisplayPhysicalMemoryByte},
{"!DW","Displayphysical memory(word)","!DW [address]","!dw39000\n",CmdDisplayPhysicalMemoryWord},
{"!DD","Displayphysical memory(dword)","!DD [address]","!dd39000\n",CmdDisplayPhysicalMemoryDword},
{"DB","Displaymemory(byte)","DB [address|symbolname]","db[esp+4]\n",CmdDisplayMemoryByte},
{"DW","Displaymemory(word)","DW [address|symbolname]","dw[esp+4]\n",CmdDisplayMemoryWord},
{"DD","Displaymemory(dword)","DD [address|symbolname]","dd[esp+4]\n",CmdDisplayMemoryDword},
};
这样定义有以下几个好处:
1、实现命令提示功能,例如输入一个B的时候,通过查这张表可以将所有B打头的指令列出来,让用户一目了然B打头的指令有哪些。
2、当用户输入了一个确定的指令例如BPX时,可以将BPX的语法自动提示给用户。
3、当用户输入HBPX请求BPX指令的用法时。可以将BPX指令的语法和用例打印出来。
4、结构体定义了命令分发处理函数,例如输入BPX就可以跳转到BPX处理函数CmdSetSwBreakpoint,由BPX函数负责解析这个函数。输入BC则跳转到BC处理函数CmdClearBreakpoint中。
4.2.1.2帮助信息与命令提示
有了上述定义的表,就可以很方便地获取指令帮助信息和命令提示功能。只需枚举表项即可。
4.2.1.3一般命令的解析
首先对输入语句的开头进行判断,进入相应的命令处理函数中,继续分割出命令的所有参数,然后对参数进行数值转换处理,例如可能需要将字符串转换成10进制或16进制数值类型。最后实现该命令。
4.2.1.4使用LL-1分析法计算表达式
有些时候我们需要输入表达式,例如查看esi+14h处指针指向的内存数据,使用命令:
dd[esi+eax*4+14]
[esi+eax*4+14]就是一个表达式,首先计算esi+eax*4+14,方括号表示对指标取值,得到该指针处的地址。
对表达式的分析我使用了LL-1分析法:算法从左到右分析表达式,设置符号栈和值栈,例如对表达式[1+4]>=45&&(([1+8]<9)||([1+c]&10))分析,步骤如下:
表4-1:LL-1分析法计算步骤
符号栈 | 值栈 | 串 |
[ |
| 1 |
[ | 1 | +4]>=45&&(([1+8]<9)||([1+c]&10)) |
[ + | 1 | 4]>=45&&(([1+8]<9)||([1+c]&10)) |
[ + | 1 4 | ]>=45&&(([1+8]<9)||([1+c]&10)) |
| [5] | >=45&&(([1+8]<9)||([1+c]&10)) |
>= | [5] | 45&&(([1+8]<9)||([1+c]&10)) |
>= | [5] 45 | &&(([1+8]<9)||([1+c]&10)) |
&& | 1 | (([1+8]<9)||([1+c]&10)) |
&& ( | 1 | ([1+8]<9)||([1+c]&10)) |
&& ( ( | 1 | [1+8]<9)||([1+c]&10)) |
&& ( ( [ | 1 | 1+8]<9)||([1+c]&10)) |
&& ( ( [ | 1 1 | +8]<9)||([1+c]&10)) |
&& ( ( [ + | 1 1 | 8]<9)||([1+c]&10)) |
&& ( ( [ + | 1 1 8 | ]<9)||([1+c]&10)) |
&& ( ( | 1 [9] | <9)||([1+c]&10)) |
&& ( ( < | 1 [9] | 9)||([1+c]&10)) |
&& ( ( < | 1 [9] 9 | )||([1+c]&10)) |
&& ( | 1 0 | ||([1+c]&10)) |
&& ( || | 1 0 | ([1+c]&10)) |
&& ( || ( | 1 0 | [1+c]&10)) |
&& ( || ( [ | 1 0 | 1+c]&10)) |
&& ( || ( [ | 1 0 1 | +c]&10)) |
&& ( || ( [ + | 1 0 1 | c]&10)) |
&& ( || ( [ + | 1 0 1 c | ]&10)) |
&& ( || ( | 1 0 1c | &10)) |
&& ( || ( & | 1 0 1c | 10)) |
&& ( || ( & | 1 0 1c 10 | )) |
&& ( || | 1 0 1 | ) |
&& | 1 1 |
|
| 1 |
|
详细算法实现见exp.c和exp.h文件。
4.2.2单步步入
单步步入是单步执行的一种,他会让调试器跟踪进入到call指向的函数中。
4.2.2.1设置单步
首先设置一个全局变量表示当前正在进行单步,然后设置单步陷阱标志EFLAGS.TF,并备份当前屏幕。已备退出单步返回操作系统时恢复屏幕。
需要注意的是,单步时需要清空客户机GUEST_INTERRUPTIBILITY_INFO域、GUEST_ACTIVITY_STATE域,否则在单步CLI指令后会死机。这个bug在hyperdbg中存在,我曾与hyperdbg作者讨论这个问题,最终在Intel手册中找到相关解释。
4.2.2.2回应单步
设置单步陷阱标志后,执行当前指令后会引发DEBUG异常,即单步异常。异常引发后进入我们的VMExit处理函数中。我们判断当前为单步操作,进行相应的动作,例如显示反汇编代码,高亮当前汇编代码等等操作。
4.2.2.3取消单步
取消单步只需要撤销单步陷阱标志TF即可。
4.2.3设置断点
设置断点的原理是替换某个指令为INT3指令,当程序执行到这个INT3指令后就会断下来,调试器获得执行机会,继续这条指令的执行前需要恢复原指令,因为原指令已经被替换成了INT3。
4.2.3.1数据结构的设计
我定义了如下的结构用于描述一个断点:
typedefstruct{
ULONGProcessCR3; //进程页目录
USHORTCodeSeg; //代码段
ULONGAddress; //断点地址
CHAROldOpcode; //断点位址处原指令备份
CHARIfCondition[128]; //条件表达式
CHARDoCmd[128]; //满足条件后运行的语句
}SW_BP,*PSW_BP;
一般调试器允许设置的断点数量有限,数组即可满足我们的需求,定义一个全局数组用于保存当前所有的断点。
4.2.3.2设置断点
首先检查断点数组是否满,找到一个空项,填写相关信息诸如进程页目录,地址,备份原指令。最后将断点处第一个字节写入CC字节(INT3指令)即可。
#defineINT3_OPCODE 0xCC
4.2.3.3回应断点
当CPU执行INT3指令后引发BREAKPOINT异常,即断点异常,然后进入我们的VMExit处理函数,枚举断点数组,判断是否为我们的断点,如果不是则将异常投递回客户机交给客户机操作系统处理,如果是我们的断点,则做出相应动作诸如显示反汇编代码,高亮汇编代码等。
当我们选择执行这个指令时,调试器将该断点处位址的原指令恢复,然后交给CPU继续运行这段代码。
但是一旦断点被恢复为原指令后这个断点便不再生效,有时我们希望一个断点可以被多次触发。因此还需要做一些额外操作。我使用的方法是设置一个单步陷阱,当执行过被断点的指令后,调试器会再次获得执行机会,这时我们把上一条指令恢复成INT3指令,这个断点就可以继续发挥作用了。
4.2.3.4清除断点
清除某一个断点,首先应当得到这个断点的位置,恢复该位置的指令,最后把对应的断点描述结构清空即可。
4.2.4单步步过
单步步过是单步执行的另一种,他不会让调试器跟踪进入到call指向的函数中。而是跳过call执行。
4.2.4.1设置单步步过断点
在单步非call指令时都是通过设置单步陷阱标志Eflags.TF来实现的,但是在设置标志前多一项工作:检查当前指令是否为call。如果当前指令为call,则不设置TF标志,而是将call指令的吓一跳指令设置为断点指令INT3,这样当call执行完毕后就会触发断点异常,从而实现单步步过call的功能。
设置断点需要一个结构体来描述一个断点信息以及备份指令,这个结构体其实就是SW_BP,只是换了一个名字,用另外一个数组来描述他。
4.2.4.2回应断点及清除断点
触发断点异常后首先检查是否为用户设置的断点,如果不是则检查是否为单步步过的断点。当属于单步步过的断点时,就自动恢复断点处的原指令,并删除该断点,因为这种断点我们只使用了一次,触发后就不再需要他了。
4.3其它功能
单步和断点功能是一个调试器最基本的功能,另外还应该有一些常用功能来辅助调试工作。
4.3.1查看内存数据
我一共实现了3个查看内存数据的指令分别是按位元组查看,按字查看,按双字查看。命令分别为DB、DW、DD。就是将某处内存读出并按照相应的格式输出即可。
4.3.2查看页面信息
页面信息功能可以查看某一个线性地址指向的物理内存地址,以及该页面的相关信息。通过查询PDE、PTE可以得到这些信息。
4.3.3CallStack(堆栈回溯)
堆栈回溯也是调试器的重要功能,当程序发生崩溃被调试器拦截时,我们希望得知是由哪个函数调用导致的崩溃,堆栈回溯这个功能就派上了用场。
大部分x86编译器编译一个函数后,都生成了固定的函数帧头部,大致如下:
push ebp
mov esp,ebp
…
而且我们知道call指令的实现其实就是将返回地址压入堆栈并jmp到函数体。所以这样的函数帧头部会产生函数调用链。例如当A调用B,B调用C,在C中观察栈帧结构就如图4-1所示:
图4-1堆栈结构图:A调用B,B调用C
因此,实现堆栈回溯只需要下面的代码即可完成:
while(EBP)
{
返回地址=*(EBP+4);
打印信息;
EBP= *EBP;
}
4.3.4查看CPU信息
这个功能可以实现查看CPU通用寄存器、控制寄存器等常用寄存器的值,以及解析出控制寄存器相应控制位,并在屏幕上打印输出它们。
4.3.5查看中断描述表
这个功能可以读取当前CPU的中断描述表,并将对应的中断处理函数打印出来。
4.4卸载调试器
卸载调试器其实就是虚拟化框架的卸载,也就是将运行在虚拟CPU上的操作系统“解救”出来,将他放回物理CPU上运行。
当位于虚拟CPU的操作系统执行我们调试器内核模块的module_exit函数时,会执行一个VMCALL指令,该指令引发VMExit,进入到VMExit处理函数,此时CPU模式为VMXroot。
此时我们要做的工作很简单。判断是否是请求卸载调试器,如果是,则删除调试器的所有断点,恢复虚拟CPU的各种控制寄存器,执行VMXOFF指令关闭虚拟CPU,然后跳回module_exit函数即可。
这样之后操作系统就又重新回到物理CPU上面执行了,同样的道理,这就是CPU控制权的转移。
5 基于IntelVt技术的Linux内核调试器主要问题以及解决
在开发基于虚拟化的调试器时遇到了很多问题,有些问题看似简单,但是解决起来很不容易。这是因为我们的调试器工作在VMXroot模式,而在这种模式下引发的崩溃性BUG,没有任何软件调试器可以对其进行跟踪分析,除非这个调试器同样运行在VMXroot模式。
专业的虚拟机开发人员可以使用硬件调试器来解决开发中遇到的问题。x86架构是支持外接硬件调试器的,类似于JTAG接口,这种调试器价格昂贵,大概两万美金左右,而且一般的主板并没有导出调试插座,只有Intel的一些高端主板才导出了配套的调试界面。
我在没有硬件调试器的情况下,遇到了一些很奇怪的问题,经过不断的摸索最终得到了解决。
5.1调试器运行后几秒内死机
现象描述:在使用insmodvmxice.ko命令插入内核模块之后,系统在几秒钟内完全死机,游标不闪烁,不响应任何键盘操作。
故障诊断:如果崩溃发生在操作系统层应该会提示崩溃信息,而我遇到的这种死机是完全的死掉,所以死机可能是发生在VMExit之后。我尝试在进入VMExit处理函数时立即在屏幕上打印出计数器数值,这个计数器在每次进入VMExit之后都会递增,我以此可以证明死机发生点是VMExit中的某一段程序。结果出乎意料,在插入调试器内核模块后,屏幕上立即开始输出计数器数字,但是几秒后计数器停止输出,证明了死机是发生在VMExit,而且是崩溃性的,时钟中断都已经不再被处理。
这个故障我纠结了大概2个星期,后来重新阅读源代码时发觉,可能是页表出了问题,我写了一个小程序以验证我的思路。我首先在模块入口打印出CR3寄存器内容,然后再模块出口再次打印出CR3寄存器。我以此来判断内核模块的整个生命周期是否使用同一个页目录。
结果是页目录不同!后来经过详细分析认为这是正常的,因为在模块入口的函数被执行时,是属于insmod这个进程空间的,当入口函数执行完毕,insmod进程退出,这个页目录就顺其自然地被操作系统毁掉。而我的调试器依然在使用这个页目录,就发生了致命的错误:无法定位VMExit函数,因此在进行VMExit切换时一定会死机。
解决办法:在构造HOST端VMCS结构体时,为HOST重新分配一个页目录,并把当前页目录完整的复制过来,这样我们调试器就使用自己的页目录,操作系统在insmod进程退出后销毁insmod的页目录也不会影响我们调试器所使用的页目录了。
5.2呼出调试器后死机
故障现象:按下F12,然后进行某项操作时突然死机,不响应任何键盘操作。
故障诊断:首先可以肯定死机是发生在VMExit处理函数中。我使用排除法。我猜测故障发生在某处,我就在他前面和后面分别加一个打印字符串的语句,这样,假如真是在这里发生死机,那么只会打印出前面的字符串,后面的字符串就打印不出来了。这种办法很有效,通过不断的排除,缩小范围,通常都可以定位到崩溃发生点。
解决办法:经过诊断,发觉是调试器访问了非法的内存页面,导致发生页异常,而我们的调试器没有接管处理页异常的中断,所以就死机了。我为HOST端构造了一个简单的页异常处理函数,只是简单的打开蜂鸣器,这样一旦发生页异常蜂鸣器就会长响,我就可以知道是发生了页异常错误。
解决页异常的方法,就是避免访问非法内存,所以,我在写代码时会特别注意,如果某段代码会可能访问非法内存,那么我会在访问前对页面进行判断,如果页面存在,则继续访问,如果页面不存在,则不访问,改为错误通知。
例如使用U 0这个指令试图反汇编内存地址0处的代码,而这个地址是不存在的,调试器就会返回INVALIDADDRESS的错误,而不是傻傻的真的去反汇编,导致崩溃的发生。