3.4 全能的断点

现在我们已经有了一个能够正常运行的调试器核心,是时候加入断点功能了。用我们在第二章学到的,实现设置软件,硬件,内存三种断点的功能。接着实现与之对应的断点处理 函数,最后在断点被击中之后干净的恢复进程。

3.4.1 软件断点

为了设置软件断点,我们必须能够将数据写入目标进程的内存。这需要通过 ReadProcessMemory() 和 WriteProcessMemory()实现。它们非常相似:

BOOL WINAPI ReadProcessMemory(
    HANDLE hProcess,
    LPCVOID lpBaseAddress, 
    LPVOID lpBuffer,
    SIZE_T nSize,
    SIZE_T* lpNumberOfBytesRead
);

BOOL WINAPI WriteProcessMemory( 
    HANDLE hProcess,
    LPCVOID lpBaseAddress, 
    LPCVOID lpBuffer, 
    SIZE_T nSize,
    SIZE_T* lpNumberOfBytesWritten
);

这两个函数都允许调试器观察和更新被调试的进程的内存。参数也都很简单。 lpBaseAddress 是要开始读或者些的目标地址, lpBuffer 指向一块缓冲区,用来接收lpBaseAddress读出的数据或者写入 lpBaseAddress 。 nSize 是 想 要 读 写 的 数 据 大 小 , lpNumberOfBytesWritten 由函数填写,通过它我们就能够知道一次操作过后实际读写了的数 据。

现在让我们的调试器实现软件断点就相当容易了。修改调试器的核心类,以支持设置和 处理软件断点。

#my_debugger.py
...
class debugger():
    def init (self):
        self.h_process = None
        self.pid = None 
        self.debugger_active = False
        ...
        self.h_thread = None
        self.context = None
        self.breakpoints = {}
    def read_process_memory(self,address,length): data = ""
        read_buf = create_string_buffer(length) 
        count = c_ulong(0)
        if not kernel32.ReadProcessMemory(self.h_process,
                                            address, 
                                            read_buf, 
                                            length, 
                                            byref(count)):
            return False
        else:
            data += read_buf.raw return data
    def write_process_memory(self,address,data): count = c_ulong(0)
        length = len(data)
        c_data = c_char_p(data[count.value:])
        if not kernel32.WriteProcessMemory(self.h_process,
                                            address, 
                                            c_data, 
                                            length, 
                                            byref(count)):

            return False
        else:
            return True
    def bp_set(self,address):
        if not self.breakpoints.has_key(address): 
            try:
                # store the original byte
                original_byte = self.read_process_memory(address, 1)
                # write the INT3 opcode 
                self.write_process_memory(address, "\xCC")
                # register the breakpoint in our internal list self.breakpoints[address] = (address, original_byte)
            except:
                return False 
        return True

现在调试器已经支持软件断点了,我们需要找个地址设置一个试试看。一般断点设置在 函数调用的地方,为了这次实验,我们就用老朋友 printf()作为将要捕获的目标函数。WIndows 调试 API 提供了简洁的 方法以确定一个函数的虚拟地址, GetProcAddress(),同样也是从 kernel32.dll 导出的。这个 函数需要的主要参数就是一个模块(一个 dll 或者一个.exe 文件)的句柄。模块中一般都包含 了我们感兴趣的函数; 可以通过 GetModuleHandle()获得模块的句柄。原型如下:

FARPROC WINAPI GetProcAddress( 
    HMODULE hModule,
    LPCSTR lpProcName
);
HMODULE WINAPI GetModuleHandle( 
    LPCSTR lpModuleName
);

这是一个很清晰的事件链:获得一个模块的句柄,然后查找从中导出感兴趣的函数的地 址。让我们增加一个调试函数,完成刚才做的。回到 my_debugger.py.。

my_debugger.py
...
class debugger():
    ...
    def func_resolve(self,dll,function):
        handle = kernel32.GetModuleHandleA(dll)
        address = kernel32.GetProcAddress(handle, function) 
        kernel32.CloseHandle(handle)
        return address

现在创建第二个测试套件,循环的调用 printf()。我们将解析出函数的地址, 然后在这个地址上设置一个断点。之后断点被触发,就能看见输出结果,最后被测试的进程 继续执行循环。创建一个新的 Python 脚本 printf_loop.py,输入下面代码。

#printf_loop.py from ctypes 
import * import time
msvcrt = cdll.msvcrt 
counter = 0
while 1:
    msvcrt.printf("Loop iteration %d!\n" % counter) 
    time.sleep(2)
    counter += 1

现在更新测试套件,附加到进程,在 printf()上设置断点。

#my_test.py
import my_debugger
debugger = my_debugger.debugger()
pid = raw_input("Enter the PID of the process to attach to: ") 
debugger.attach(int(pid))
printf_address = debugger.func_resolve("msvcrt.dll","printf") 
print "[*] Address of printf: 0x%08x" % printf_address 
debugger.bp_set(printf_address)
debugger.run()

现在开始测试,在命令行里运行 printf_loop.py。从 Windows 任务管理器里获得 python.exe 的 PID。然后运行 my_test.py ,键入 PID。你将看到如下的输出:

Enter the PID of the process to attach to: 4048 
[*] Address of printf: 0x77c4186a
[*] Setting breakpoint at: 0x77c4186a 
Event Code: 3 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 6 Thread ID: 3148
Event Code: 2 Thread ID: 3620
Event Code: 1 Thread ID: 3620
[*] Exception address: 0x7c901230 
[*] Hit the first breakpoint.
Event Code: 4 Thread ID: 3620
Event Code: 1 Thread ID: 3148
[*] Exception address: 0x77c4186a 
[*] Hit user defined breakpoint.

Listing 3-3: 处理软件断点事件的事件顺序

我们首先看到 printf()的函数地址在 0x77c4186a,然后在这里设置断点。第一个捕捉到 的异常是由 Windows 设置的断点触发的。第二个异常发生的地址在 0x77c4186a,也就是 printf() 函数的地址。断点处理之后,进程将恢复循环。现在我们的调试器已经支持软件断点,接下 来轮到硬件断点了。

3.4.2 硬件断点

第二种类型的断点是硬件断点,通过设置相对应的 CPU 调试寄存器来实现。我们在之 前的章节已经详细的讲解了过程,现在来具体的实现它们。有一件很重要的事情要记住,当 我们使用硬件断点的时候要跟踪四个可用的调试寄存器哪个是可用的哪个已经被使用了。必 须确保我们使用的那个寄存器是空的,否则硬件断点就不能在我们希望的地方触发。

让我们开始枚举进程里的所有线程,然后获取它们的 CPU 内容拷贝。通过得到内容拷 贝,我们能够定义 DR0 到 DR3 寄存器的其中一个,让它包含目标断点地址。之后我们在 DR7 寄存器的相应的位上设置断 点的属性和长度。

设置断点的代码之前我们已经完成了,剩下的就是修改处理调试事件的主函数,让它能 够处理由硬件断点引发的异常。我们知道硬件断点由 INT1 (或者说是步进事件),所以我们就 只要就当的添加另一个异常处理函数到调试循环里。让我们设置断点。

#my_debugger.py
...
class debugger():
    def init (self):
        self.h_process = None
        self.pid = None 
        self.debugger_active = False 
        self.h_thread = None
        self.context = None 
        self.breakpoints = {} 
        self.first_breakpoint= True 
        self.hardware_breakpoints = {}
        ...
    def bp_set_hw(self, address, length, condition):
        # Check for a valid length value 
        if length not in (1, 2, 4):
            return False
        else:
            length -= 1
        # Check for a valid condition
        if condition not in (HW_ACCESS, HW_EXECUTE, HW_WRITE): return False
        # Check for available slots
        if not self.hardware_breakpoints.has_key(0): 
            available = 0
        elif not self.hardware_breakpoints.has_key(1): 
            available = 1
        elif not self.hardware_breakpoints.has_key(2): 
            available = 2
        elif not self.hardware_breakpoints.has_key(3): 
            available = 3
        else:
            return False
        # We want to set the debug register in every thread 
        for thread_id in self.enumerate_threads():
            context = self.get_thread_context(thread_id=thread_id)
            # Enable the appropriate flag in the DR7
            # register to set the breakpoint 
            context.Dr7 |= 1 << (available * 2)
        # Save the address of the breakpoint in the
        # free register that we found 
        if available == 0:
            context.Dr0 = address 
        elif available == 1:
            context.Dr1 = address 
        elif available == 2:
            context.Dr2 = address 
        elif available == 3:
            context.Dr3 = address
        # Set the breakpoint condition
        context.Dr7 |= condition << ((available * 4) + 16)
        # Set the length
        context.Dr7 |= length << ((available * 4) + 18)
        # Set thread context with the break set 
        h_thread = self.open_thread(thread_id)
        kernel32.SetThreadContext(h_thread,byref(context))
        # update the internal hardware breakpoint array at the used
        # slot index.
        self.hardware_breakpoints[available] = (address,length,condition)
        return True

通过确认全局的硬件断点字典,我们选择了一个空的调试寄存器存储硬件断点。一 旦我们得到空位,接下来做的就是将硬件断点的地址填入调试寄存器,然后对 DR7 的标志 位进行更新适当的更新,启动断点。现在我们已经能够处理硬件断点了,让我们更新事件处 理函数添加一个 INT1 中断的异常处理。

#my_debugger.py
...
class debugger():
    ...
    ...
    def get_debug_event(self):
        if self.exception == EXCEPTION_ACCESS_VIOLATION: 
            print "Access Violation Detected."
        elif self.exception == EXCEPTION_BREAKPOINT: 
            continue_status = self.exception_handler_breakpoint()
        elif self.exception == EXCEPTION_GUARD_PAGE: 
            print "Guard Page Access Detected."
        elif self.exception == EXCEPTION_SINGLE_STEP: 
            self.exception_handler_single_step()
    def exception_handler_single_step(self):
        # Comment from PyDbg:
        # determine if this single step event occurred in reaction to a
        # hardware breakpoint and grab the hit breakpoint.
        # according to the Intel docs, we should be able to check for
        # the BS flag in Dr6\. but it appears that Windows
        # isn't properly propagating that flag down to us.
        if self.context.Dr6 & 0x1 and self.hardware_breakpoints.has_key(0): 
            slot = 0
        elif self.context.Dr6 & 0x2 and self.hardware_breakpoints.has_key(1): 
            slot = 1
        elif self.context.Dr6 & 0x4 and self.hardware_breakpoints.has_key(2): 
            slot = 2
        elif self.context.Dr6 & 0x8 and self.hardware_breakpoints.has_key(3): 
            slot = 3
        else:
            # This wasn't an INT1 generated by a hw breakpoint
            continue_status = DBG_EXCEPTION_NOT_HANDLED
        # Now let's remove the breakpoint from the list
        if self.bp_del_hw(slot):
            continue_status = DBG_CONTINUE 
        print "[*] Hardware breakpoint removed." 
        return continue_status
    def bp_del_hw(self,slot):
        # Disable the breakpoint for all active threads 
        for thread_id in self.enumerate_threads():
            context = self.get_thread_context(thread_id=thread_id)
            # Reset the flags to remove the breakpoint 
            context.Dr7 &= ~(1 << (slot * 2))
            # Zero out the address 
            if slot == 0:
                context.Dr0 = 0x00000000 
            elif slot == 1:
                context.Dr1 = 0x00000000 
            elif slot == 2:
                context.Dr2 = 0x00000000 
            elif slot == 3:
                context.Dr3 = 0x00000000
            # Remove the condition flag
            context.Dr7 &= ~(3 << ((slot * 4) + 16))
            # Remove the length flag
            context.Dr7 &= ~(3 << ((slot * 4) + 18))
            # Reset the thread's context with the breakpoint removed 
            h_thread = self.open_thread(thread_id) 
            kernel32.SetThreadContext(h_thread,byref(context))
        # remove the breakpoint from the internal list. 
        del self.hardware_breakpoints[slot]
        return True

代码很容易理解;当 INT1 被击中(触发)的时候,查看是否有调试寄存器能够设置硬 件断点(通过检测 DR6)。如果有能够使用的就继续。接着如果在发生异常的地址发现一个 硬件断点,就将 DR7 的标志位置零,在其中的一个寄存器中填入断点的地址。让我们修改 my_test.py 并在 printf()上设置硬件断点看看。

#my_test.py
import my_debugger
from my_debugger_defines import * 
debugger = my_debugger.debugger()
pid = raw_input("Enter the PID of the process to attach to: ") debugger.attach(int(pid))
printf = debugger.func_resolve("msvcrt.dll","printf") 
print "[*] Address of printf: 0x%08x" % printf
debugger.bp_set_hw(printf,1,HW_EXECUTE) debugger.run()

这个测试模块在 printf()上设置了一个断点,只要调用函数,就会触发调试事件。断点 的长度是一个字节。你应该注意到在这个模块中我们导入了 my_debugger_defines.py 文件; 为的是访问 HW_EXECUTE 变量,这样书写能使代码更清晰。

运行后输出结果如下:

Enter the PID of the process to attach to: 2504 
[*] Address of printf: 0x77c4186a
Event Code: 3 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 6 Thread ID: 3704
Event Code: 2 Thread ID: 2228
Event Code: 1 Thread ID: 2228
[*] Exception address: 0x7c901230 
[*] Hit the first breakpoint.
Event Code: 4 Thread ID: 2228
Event Code: 1 Thread ID: 3704
[*] Hardware breakpoint removed.

Listing 3-4: 处理一个硬件断点事件的顺序

一切都在预料中,程序抛出异常,处理程序移除断点。事件处理完之后,程序继续 循环执行代码。现在我们的轻量级调试器已经支持硬件和软件断点了,最后来实现内存断点 吧。

3.4.3 内存断点

最后一个要实现的功能是内存断点。大概流程如下;首先查询一个内存块以并找到基地 址(页面在虚拟内存中的起始地址)。一旦确定了页面大小,接着就设置页面权限,使其成 为保护(guard)页。当 CPU 尝试访问这块内存时,就会抛出一个 GUARD_PAGE_EXCEPTION 异常。我们用对应的异常处理函数,将页面权限恢复到以前,最后让程序继续执行。

为了能准确的计算出页面的大小,就要向系统查询信息获得一个内存页的默认大小。这 由 GetSystemInfo()函数完成,函数会装填一个 SYSTEM_INFO 结构,这个结构包含 wPageSize 成员,这就是操作系统内存页默认大小。

#my_debugger.py
...
class debugger():
    def init (self):
        self.h_process = None
        self.pid = None 
        self.debugger_active = False 
        self.h_thread = None
        self.context = None 
        self.breakpoints = {} 
        self.first_breakpoint= True self.hardware_breakpoints = {}
        # Here let's determine and store
        # the default page size for the system 
        system_info = SYSTEM_INFO() 
        kernel32.GetSystemInfo(byref(system_info)) 
        self.page_size = system_info.dwPageSize
        ...

已经获得默认页大小,那剩下的就是查询和控制页面的权限。第一步让我们查询出内存断点存在于内存里的哪一个页面。调用 VirtualQueryEx() 函数,将会填充一个 MEMORY_BASIC_INFORMATION 结构,这个结构中包含了页的信息。函数和结构定义如 下:

SIZE_T WINAPI VirtualQuery( 
    HANDLE hProcess, 
    LPCVOID lpAddress,
    PMEMORY_BASIC_INFORMATION lpBuffer,
    SIZE_T dwLength
);
typedef struct MEMORY_BASIC_INFORMATION{ 
    PVOID BaseAddress;
    PVOID AllocationBase; 
    DWORD AllocationProtect; 
    SIZE_T RegionSize; 
    DWORD State;
    DWORD Protect;
    DWORD Type;
}

上面的结构中 BaseAddress 的值就是我们要设置权限的页面的开始地址。接下来用 VirtualProtectEx()设置权限,函数原型如下:

BOOL WINAPI VirtualProtectEx( 
    HANDLE hProcess,
    LPVOID lpAddress, 
    SIZE_T dwSize, 
    DWORD flNewProtect, 
    PDWORD lpflOldProtect
);

让我们着手写代码。我们将创建 2 个全局列表,其中一个包含所有已经设置了好了 的保护页,另一个包含了所有的内存断点,在处理 GUARD_PAGE_EXCEPTION 异常的时 候将用得着。之后我们将在断点地址上,以及周围的区域设置权限。(因为断点地址有可能 横跨 2 个页面)。

#my_debugger.py
...
class debugger():
    def init (self):
        ...
        self.guarded_pages = [] 
        self.memory_breakpoints = {}
        ...
    def bp_set_mem (self, address, size):
        mbi = MEMORY_BASIC_INFORMATION()
        # If our VirtualQueryEx() call doesn’t return
        # a full-sized MEMORY_BASIC_INFORMATION
        # then return False
        if kernel32.VirtualQueryEx(self.h_process,
                                    address, 
                                    byref(mbi),
                                    sizeof(mbi)) < sizeof(mbi):
            return False
        current_page = mbi.BaseAddress
        # We will set the permissions on all pages that are
        # affected by our memory breakpoint.
        while current_page <= address + size:
            # Add the page to the list; this will
            # differentiate our guarded pages from those
            # that were set by the OS or the debuggee process self.guarded_pages.append(current_page)
            old_protection = c_ulong(0)
            if not kernel32.VirtualProtectEx(self.h_process, 
                                            current_page, 
                                            size,
                                            mbi.Protect | PAGE_GUARD, 
                                            byref(old_protection)): 
                return False
            # Increase our range by the size of the
            # default system memory page size 
            current_page += self.page_size
        # Add the memory breakpoint to our global list self.memory_breakpoints[address] = (address, size, mbi)
        return True

现在我们已经能够设置内存断点了。如果用以前的 printf() 循环作为测试对象,你将看 到测试模块只是简单的输出 Guard Page Access Detected。不过有一件好事,就是系统替我们 完成了扫尾工作,一旦保护页被访问,就会抛出一个异常,这时候系统会移除页面的保护属 性,然后允许程序继续执行。不过你能做些别的,在调试的循环代码里,加入特定的处理过 程,在断点触发的时候,重设断点,读取断点处的内存,喝瓶‘蚁力神’(这个不强求,哈), 或者干点别的。

总结

目前为止我们已经开发了一个基于 Windows 的轻量级调试器。不仅对创建调试器有了 深刻的领会,也学会了很多重要的技术,无论将来做不做调试都非常有用。至少在用别的调 试器的时候你能够明白底层做了些什么,也能够修改调试器,让它更好用。这些能让你更强! 更强!

下一步是展示下调试器的高级用法,分别是 PyDbg 和 Immunity Debugger,它们成熟稳 定而且都有基于 Windows 的版本。揭开 PyDbg 工作的方式,你将得到更多的有用的东西, 也将更容易的深入了解它。Immunity 调试器结构有轻微的不同,却提供了非常多不同的优 点。明白这它们实现特定调试任务的方法对于我们实现自动化调试非常重要。接下来轮到 PyDbg 上产。好戏开场。我先睡觉 ing。