调试器中不通过TF标记位实现单步操作的实现思路
----besterChen
最近刚讲完Debug API,马上就要开始第三阶段写调试器的作业了。如果没有意外的话,这应该是我在科锐学习的最后一个编程类作业了吧,%>_<%。
说实话,这个调试器写出来以后, 我用它的可能性不大。但作为作业,就索性往难处做,虽然可能不实用,甚至根本就没必要……, 就权当作是锻炼下自己吧。
我们都知道,如果程序不借助标志寄存器的TF位来实现单步步过和步入功能的话,我们只需要知道下一条指令的地址,然后在这个地址上随便下个int3或者硬件之类的断点,然后放开程序跑就可以实现单步步过或者步入了。至于难点应该就是怎么确定下一条要执行的代码的地址,由于需要考虑的情况比较多,故写此文档一来算是备忘,二来就算作抛砖引玉,看看各位师兄有没有更好的解决方法。
进入主题。
一、类别统计
一般程序都是顺序执行的,其下一条指令地址就是EIP+当前指令的长度作为下一条指令的位置,但是如果遇到转移类指令就需要做特殊的计算,这里列出已知的情况,供参考:
1、CALL 类指令
CALL类指令,如果是步过的话,不需要特别计算,只要按照EIP+当前指令的长度的算法即可解决,下面讨论的是步入时的问题。
CALL ADDR
像这样的立即数寻址比较简单,可以通过取到机器指令中的相对地址部分,计算出目标地址;也可以通过反汇编引擎反汇编的代码来直接拿到目标地址。
CALL [ADDR]
对于地址取内容的也比较简单,取到地址,自己ReadMemory就可以了。
CALL [Reg]
对于地址取内容的也比较简单,比较麻烦的就是取出相应寄存器的内容,但是有CONTEXT,也不麻烦的。取到内容以后自己ReadMemory也可以搞定。
CALL [Reg+offset]
这一类的指令多见于调用虚函数,也是CALL类指令中最复杂的,应为它需要我们自己计算出指针的值,通过指针ReadMemory才可以。
call dword ptr [reg+reg*NUM+0xOFFSET]
这是我见过的最复杂的寻址方式….
RET
对于RET指令,需要结合CONTEXT中ESP的内容来获取下一条指令的地址,这个应该是这些所有的情况中最简单的。
JCC类指令
JCC类指令中JMP指令的情况与CALL相同,其它的条件指令先根据符号标记寄存器来判断跳转是否已经实现,如果跳转没有实现,其下一条指令地址就是EIP+当前指令的长度。下面讨论的是跳转已经实现的情况下。
JCC ADDR
同CALL ADDR的思路
JCC [ADDR]
同 CALL [ADDR]的思路
JCC [Reg] / JCC [REG+OFFSET]
同CALL 的思路
JCC [REG+REG*OFFSET]
这是所有需要分析的指令中最复杂的情况,通过比例因子的寻址方式。我们不仅需要分离出各个元素及其内容,最难麻烦的是它的四则运算还需要考虑优先级。
二、整体的计算流程
通过以上的统计,我们知道,有些情况下CALL类指令和JCC类指令的处理流程是一样的,所以,我整理的处理流程如下:
- 检测当前指令是否是CALL或JCC类指令,如果不是,下一条指令就是 EIP + 当前指令长度
- 检测指令是否为CALL类指令,如果指令是CALL类的指令就看下当前是步入还是不过,如果是步过,下条指令就是 EIP + 当前指令长度,如果是步入就继续步骤4。
- 如果检测指令是JCC类指令,就如果是检测跳转是否实现,如果没实现下一条指令就是EIP + 当前指令长度;如果跳转实现,就继续步骤4。
- 取出当前指令的操作数中中的所有的寄存器、立即数操作数以及操作符号。
- 根据取出的内容,判断当前的转移指令是属于上面的哪个部分。
- 对分离出的指令按照当前操作数中的规定进行运算得到目标地址
大致的说明性代码如下:
//************************************************************************
// 函数名: GetNextAddr
// 权 限: public
// 返回值: DWORD 返回下一条指令的地址
// 参 数: BOOL isStepOver TRUE为步过,FALSE为步入
// 说 明: 用于计算下一条指令所在地址的函数
// 合 格:
//************************************************************************
DWORD CDebugLib::GetNextAddr(BOOL isStepOver)
{
DWORD dwNextAddr = 0;
TCHAR szCommand[CODE_SIZE] = {0}; // 用来接收指令
TCHAR szCmdLine[CODE_SIZE] = {0}; // 用来接收反汇编出来的汇编指令
t_disasm da = {0};
if (m_pBreakPoint == NULL)
{
return FALSE;
}
PCONTEXT tagpContext = GetContextValue();
if (!tagpContext || tagpContext->Eip == NULL)
{
return FALSE;
}
int nLen = GetAsmCode((LPVOID)tagpContext->Eip, &da);
_tcscpy(szCmdLine, da.result);
_stscanf(szCmdLine, _T("%[^ ]"), szCommand); // 分离出指令来作判断
_tcsupr(szCommand);
if (_tcsicmp(szCommand, _T("CALL")) == 0)
{
if (isStepOver != TRUE)
{
// 对于单步步入的情况,需要计算步入的地址
#pragma chMSG(CALL 指令的单步步入情况还没处理....)
return dwNextAddr;
}
// 对于CALL的步过情况,与普通指令相同,这里就不处理了。
}
else if (_tcsstr(szCommand, _T("J")) != 0)
{
// 需要判断跳转实现没有
if(IsJmpSuccess(tagpContext, szCommand))
{
// 如果跳转实现了,需要自己计算目标地址。
_tprintf(_T("跳转已经实现....\r\n"));
#pragma chMSG(JCC 指令中跳转实现的情况还没处理....)
return dwNextAddr;
}
// 如果没有实现,与普通指令相同,这里就不处理了。
_tprintf(_T("跳转没有实现....\r\n"));
}
else if (_tcsstr(szCommand, _T("RET")) != 0)
{
// 需要手工计算步入的地址
assert(m_pBreakPoint->ReadMemory((LPVOID)tagpContext->Esp, \
&dwNextAddr, sizeof(DWORD)));
return dwNextAddr;
}
// 剩下的指令都是 EIP + 当前指令长度了。
return (DWORD)((BYTE*)tagpContext->Eip+nLen);
}
三、实现的具体细节
以上列出的流程中,有好多的细节没有讲,来本小节中给我出的处理思路。
1、关于指令分离
在我们判断当前指令是否为CALL或者JCC类指令时,就需要开始对当前指令进行词法分析了。当然,对指令的扫描不可能一步就进行完善,所以我们也分步骤的完成,先说对指令和操作数的分离:
回顾一下X86下的汇编语法格式,一条指令中很少有3个操作数的情况,所以,我们暂且只考虑一个操作数和两个操作数的情况:
汇编指令 操作数1 , 操作数2
对于JCC或者CALL类转移指令中,都满足如下这样的格式:
汇编指令 操作数
是的,一般只有一个操作数,它遵循如下归于,指令与操作数之间一般有一个或多个空白字符(如空格或者TAB)分割,各个操作数之间用逗号分隔,由此,我们可以编写如下的代码来分离它们:
int nLen = GetAsmCode((LPVOID)tagpContext->Eip, &da); //得到当前指令的长度和反汇编的字符串
_stscanf(da.result, _T("%[^ ]"), tagCMDline.szCommand); // 分离出指令部分
_stscanf(da.result, _T("%*[^ ] %[^,]"), tagCMDline.szParam1); // 分离出指令的操作数1
_stscanf(da.result, _T("%*[^ ] %*[^,] , %[^,]"), tagCMDline.szParam2); // 分离出操作数2
2、关于操作数的分离
经过观察,我发现,一般的立即数寻址方式的操作数不需要分离,它直接就是我们想去的地址,我们只需要转换下数据类型,直接继续操作就可以了。
而需要我们进行分离的操作数,有这样的规律:操作数以左方括弧为开始,以右方括弧结束,比如:
[ebp+eax*4]
对于这样的指令,用的是哪个寄存器,是什么样的格式,我们都很难猜到,也很难写出像上面分离指令那样的正则式。
我们不仅要分离出操作数中的各个元素,还要对它们进行运算,对这种各个类型混合在一起的字串解析,我能想到的比较好的工具,就是状态机。
分析这个格式的自动机如下:
根据此状态自动机,我们很容易的就能分离出操作数中的各种元素。接下来就是表达式运算了。
3、关于表达式的计算
如果说,以上两小节的分离算作是词法分析的话,那本小节也就应该算是语法分析吧,╮(╯▽╰)╭
一提起语法分析,不自觉的就想起什么BNF啊,语法树之类的名词,这个东西确实是让像我这样菜鸟头痛的东西,因为我几次手写的语法树都存在二义性,更别说用程序写……
回顾一下我们这个操作数中的所有运算,它既没有括弧之类的token来改变优先级,也没有什么太复杂的运算(比如函数调用),甚至也最基本的四则运算中的除法运算都没有。因此,我感觉就实际情况而言,一个栈结构应该足够处理这里的所有情况了,并不用构建什么语法树,俗话说:杀鸡焉用宰牛刀?
这里拿我们最复杂的操作数来说明解析过程,以 edx+eax*4+0x00401000为例来说明:
- 取出 edx ,将其值存放到一个临时变量 A 中。
- 取出+ 运算符,就想临时变量A的内容压栈。
- 取出eax,将其值存放到一个临时变量A中。
- 取出* 运算符,由于其优先级最高,就直接取下一个值。
- 取出立即数 4, 将其与临时变量A的内容作乘法运算,并将结果再次存入临时变量A中。
- 取出 + 运算符, 将临时变量A的值压栈。
- 取出立即数 0x00401000,将其值存入临时变量A中。
- 取值结束,将临时变量A的值压栈。
- 依次求出栈中所有数据的和,得到最终的结果。
整理一下,就是遇到 + 就压栈,遇到 * 就先求值再压栈,直到全部读取完毕。最后求出栈中所有数据之和。
这里的词法分离和这简单的语法解析都比较容易,以上思路说明的已经比较透彻了。再者处理上面的步骤代码金量忒差,就不给出具体的实际代码了。
当然,代码中,有一点还是值得说一下的,就是符号表的构建(我自己理解的不知道对不对….)。比如,我们在分析操作数时,得到了一个edx,我们的解释单元会直到edx属于关键字类型(具体分类见上面的自动机图),这样我们就需要一个符号表,将关键字这个类型中edx的值给返回出来,否则我们没法计算。
大致的说明性代码如下:
DWORD GetNodeValue(opera* tagpOper)
{
switch( tagpOper->type )
{
case Reg:
/* 知道此时栈节点的类型为关键字,就需要根据EDX在符号表中找到EDX的值 */
return dwEdx;
/* ... */
}
return -1;
}
思路到这里就表述完成了。希望各位学长批评指正。(*^__^*) 嘻嘻……
[课程]FART 脱壳王!加量不加价!FART作者讲授!