学习二进制的过程中我们总是会遇到断点,毕竟设置断点能使我们更好的调试程序。接触二进制以来,第一次比较清楚系统的了解了一下断点以及与断点有关的软件安全的东西。
断点
设置断点我们可以使一个进程的执行暂停在一个符合某种特定的条件的位置上,此时我们可以对程序的变量,栈上参数以及分配情况进行查看,并且在改变前我可以了解他们的面貌。因此,断点是我们调试程序几乎必不可少的功能,通常断点我们有三种
- 软断点
- 硬件断点
- 内存断点
在后面的内容中,我可能会设计到针对软件调试,我们开发人员会用到的防御措施的关键代码。
软断点
软断点的设置能够使得目标进程在执行到一个位置指令的时候暂停执行,软断点是目前调试程序最长用到的断点类型。我们常用调试器的F2功能便是常见的软断点。它的实质就是一个单字节的指令,我们都知道 0xcc,即INT3指令的操作码,这个指令是告诉CPU暂停执行当前的进程,这也就是软断点的核心。
如下汇编:
这个汇编仅是简单的将寄存器中EBX的值存入EAX中。
而在X86环境下,其机器码为
这才是CPU才可识别的机器语言, 机器码 分为成两部分,即操作码以及操作数。
设置软断点前:
1
| 0x66554433; 8BC3 mov eax,ebx
|
设置软断点后:
1
| 0x66554433; CCC3 mov eax,ebx
|
我们可以看到前后的变化,双字节的操作码8BC3中被替换了一个字节,替换的的字节是INT3的中断指令,单字节值为0xcc。当CP执行到这里,并触碰到这个字节的时候,就引发了一个INT3的中断事件。而调试器是如何实现这个过程的呢?当调试器需要在一个内存地址上设置断点的时候,调试器首先读取内存上的第一个操作码字节,并存储在断点列表中,接着调试器将字节CC写入被读取的内存地址将至替换掉。接着就是CPU发生INT3事件,被调试器所捕获,接着调试器会查EIP是否指向我们设置断点的内存地址,如果是,调试器会将之前的数据写回内存地址中,当进程恢复执行,写回正确的字节数据。
而针对软断点,我们的开发者常用的保护措施就是CRC校验,即循环冗余校验,可以检测程序内容是否被改动,因为软断点改变了字节,肯定也改变了程序内存的CRC校验值。在XP,没有ASLR机制的时候,我们可以用如下代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| DWORD CRC32(BYTE* ptr,DWORD Size) { DWORD crcTable[256],crcTmp1; for (int i=0; i<256; i++) { crcTmp1 = i; for (int j=8; j>0; j--) { if (crcTmp1&1) crcTmp1 = (crcTmp1 >> 1) ^ 0xEDB88320L; else crcTmp1 >>= 1; }
crcTable[i] = crcTmp1; } DWORD crcTmp2= 0xFFFFFFFF; while(Size--) { crcTmp2 = ((crcTmp2>>8) & 0x00FFFFFF) ^ crcTable[ (crcTmp2^(*ptr)) & 0xFF ]; ptr++; } return (crcTmp2^0xFFFFFFFF); }
|
其次是具体的代码实现,具体有两个部分,一个是需要被保护的代码的和需要创建一个线程来计算校验值
假设要被保护的代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| ProtectStart: __asm { inc eax dec eax push eax pop eax } start: HMODULE hMod = GetModuleHandle(NULL); HMODULE hUser32 = LoadLibrary("user32.dll"); ProtectEnd: DWORD dwThreadId = 0;
STBINGLEPARAM stParam = {0}; stParam.hEvent = CreateEvent(NULL,FALSE,FALSE,"bingle"); DWORD dwAddr = 0; __asm mov eax,offset ProtectStart __asm mov dwAddr,eax stParam.dwStart = dwAddr;
__asm mov eax,offset ProtectEnd __asm mov dwAddr,eax stParam.dwEnd = dwAddr; printf("开始了\n"); CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)bingleProc,(LPVOID)&stParam,0,&dwThreadId);
|
创建线程,用来计算校验值
并且将线程的创建放在循环中,这样保证在程序运行的过程中,会不断的监视内存的数据是否改变。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)bingleProc,(LPVOID)&stParam,0,&dwThreadId);
DWORD dwRet = 0; dwRet = WaitForSingleObject(stParam.hEvent,INFINITE); while(dwRet == WAIT_OBJECT_0) { Sleep(5000); CreateThread(NULL,0,(LPTHREAD_START_ROUTINE)bingleProc,(LPVOID)&stParam,0,&dwThreadId); dwRet = WaitForSingleObject(stParam.hEvent,INFINITE); }
#pragma pack(1) typedef struct __STBINGLEPARAM { HANDLE hEvent; DWORD dwStart; DWORD dwEnd; }STBINGLEPARAM,*PBINGLEPARAM; #pragma pack()
STBINGLEPARAM *stParam = (STBINGLEPARAM *)lpParameter; DWORD dwCodeSize = stParam->dwEnd - stParam->dwStart; BYTE *pbyteBuf = NULL; pbyteBuf = (BYTE *)stParam->dwStart; DWORD dwOldProtect = 0; VirtualProtect((LPVOID)stParam->dwStart,4*1024,PAGE_EXECUTE_READWRITE,&dwOldProtect); if(CRC32(pbyteBuf,dwCodeSize) != 0xa0eb5866) { MessageBox(NULL,"bingle","代码被修改了",NULL); printf("代码被修改了\n");
SetEvent(stParam->hEvent); ExitProcess(0); }
|
当然除了校验的方法,我们也可以通过设定一个clock,因为我们调试是需要的时间的,因此当我们设置一个clock来计算程序暂停的时间,当超过一个阈值,我们就可以断定我们的的程序正在被调试,可以直接exit()退出,或做一些事情保护我们的程序。
硬件断点
硬件断点是有自身的适用场合的:
则是当少量的断点即可满足调试任务,或者当我们的调试目标实现了CRC校验的反调试机制。这种类型断点的设置是通过一组CPU上的特殊的寄存器实现的。一个典型的CPU应当有8个的寄存器,这8个寄存器就是我们所说的特殊寄存器,一般称之为调试寄存器。
接着下面的内容我们可以稍微详细的介绍一下这8个寄存器
DR0到DR3,用于存储所设硬件断点内存地址,也正是因为如此,我们才能只能最多使用8个硬件断点。
DR4和DR5会被保留。
DR6,这个寄存器上记录了上一次断点触发所产生的调试事件类型信息。因此DR6寄存器又称之为调试状态寄存器。
DR7,存储各个断点的触发条件信息,通过设置DR7寄存器上的特定标记位,我们可以断点设定一下的触发条件:
- 特定内存地址上的指令被执行触发断点
- 当数据被写入一个特定内存弟子时触发断点
- 当数据被读出或写入,这是不包括执行,一个特定非可执行内存地址触发断点
因此DR7实质上是硬件断点的激活开关。