调试器工作原理

来源:互联网 发布:windows活动目录管理 编辑:程序博客网 时间:2024/05/17 04:38

from:http://www.alexonlinux.com/how-debugger-works

(翻牆。。)

Introduction

In this article, I’d like to tell you how real debugger works.What happens under the hood and why it happens. We’ll even writeour own small debugger and see it in action.

在这文章里面,我将会告诉你们真正打调试器是怎么工作的。hook的过程发生了什么并且为什么会这样发生。甚至我们会写一个我们自己的小小调试器并看它打运行。

I will talk about Linux, although same principles apply to otheroperating systems. Also, we’ll talk about x86 architecture. This isbecause it is the most common architecture today. On the other hand,even if you’re working with other architecture, you will find thisarticle useful because, again, same principles work everywhere.

我将会讨论linux系统,虽然同样的原理也适用于其他操作系统。同样我们会讨论关于x86架构。因为这是现代最为普遍打架构。另一方面,即使你会适用其他的架构,但是你会发现这篇文章同样是很有用打,因为在每个地方的原理都是一样打。

Kernel support

Actual debugging requires operating system kernel support andhere’s why. Think about it. We’re living in a world where oneprocess reading memory belonging to another process is a serioussecurity vulnerability. Yet, when debugging a program, we would liketo access a memory that is part of debugged process’s (debuggie)memory space, from debugger process. It is a bit of a problem, isn’tit? We could, of course, try somehow to use same memory space forboth debugger and debuggie, but then what if debuggie itself createsprocesses. This really complicates things.

事实上,调试需要操作系统内核的支持,下面就是解释为什么。设想一下,我们生活在一个世界,这个世界一个进程去读取另外一个进程的内存是一件很严重的漏洞。然而,当调试一个程序打时候,我们想要通过被调试进程的内存去操作,然而这是属于被调试的进程的内存空间中的一部分。所以这就出现一点问题了,不是吗?当然,我们能够做到,试图以某种方式去使用调试者和被调试者相同的内存空间,但是,如果被调试者它自己创造进程,这将是一个复杂的问题。



Debugger support has to be part of the operating system kernel.Kernel able to read and write memory that belongs to each and everyprocess in the system. Furthermore, as long as process is notrunning, kernel can see value of its registers and debugger have tobe able to know values of the debuggie registers. Otherwise it won’tbe able to tell you where the debuggie has stopped (when we pressedCTRL-C in gdb for instance).

调试程序支持的必须是操作系统内核的一部分。内核能够读和写那些属于各自以及每一个在操作系统中的进程的内存。此外,只要进程没有在运行,那么内核能够看到寄存器中的每一个值,同时调试者必须能够知道这些被调试寄存器中的值。否则,它将不能够告诉你被调试的程序哪里会停止(当我们按下CTRL+C的时候)。



As we spoke about where debugger support starts we alreadymentioned several of the features that we need in order to havedebugging support in operating system. We don’t want just anyprocess to be able to debug other processes. Someone has to monitordebuggers and debuggies. Hence the debugger has to tell the kernelthat it is going to debug certain process and kernel has to eitherpermit or deny this request. Therefore, we need an ability to tellthe kernel that certain process is a debugger and it is about todebug other process. Also we need an ability to query and set valuesfrom debuggie’s memory space. And we need an ability to query andset values of the debuggie’s registers, when it stops.

当我们谈到调试程序在哪里开始调试,我们已经涉及到一些特征,我们为了在操作系统中有调试的支持。我们不想要只是任何进程都能够去调试其他进程。有些人需要模拟调试者和被调试者。因此,调试程序需要告诉内核,要开始调试一个确定的进程啦,并且内核同样需要有着对这一信号发出允许或拒绝的反映。因此,我们需要能够告诉内核:某一个进程是调试进程,并且正打算去调试其他的进程。同样的,我们需要有能力从被调试者打内存空间中去询问以及设置一些值。并且,当被调试进程停止的时候,我们需要有能力去询问以及设置被调试者寄存器中的值。



And operating system lets us to do all this. Each operating systemdoes it in it’s manner of course. Linux provides single system callnamedptrace() (defined in sys/ptrace.h), whichallows to do all these operations and much more.

然而,操作系统允许我们去做这全部打操作。每一个操作系统以它打方式来实现这一操作。Linux中,它提供一个单一的系统调用,叫ptrace()(被定义在sys/ptrace.h中),这一函数允许做上面所有的操作或者更多。



ptrace()

ptrace() accepts four arguments. First is one of thevalues from enum __ptrace_request that defined insys/ptrace.h. This argument specifies what operation wewould like to do, whether it is reading debuggie registers oraltering values in its memory. Second argument specifies pidof the debuggie process. It’s not very obvious, but single processcan debug several other processes. Thus we have to tell exactly whatprocess we’re referring. Last two arguments are optional argumentsfor the call.

ptrace()接收四个参数,第一个值来自于枚举类型_ptrace_request,这个类型被定义在sys/ptrace.h中。这个参数指定着我们要进行什么样打操作,究竟是想要进行读取被调试者打寄存器或者替换它内存中的值。第二个参数指定被调试进程的pid。这并不是很明显,但是这样就能够使得单个进程能够调试多个其他进程。因此我们要告诉究竟是具体哪个进程需要被调试。最后两个是可选择参数。





Starting to debug

One of the first things debuggers do to start debugging certainprocess is attaching to it or running it. There is aptrace()operation for each one of these cases.

调试程序去调试一个进程所做的第一件事就是去附加上这个进程或者让之运行。这里有一个ptrace()操作,来实现每种情形。



First called PTRACE_TRACEME, tells the kernel thatcalling process wants its parent to debug itself. I.e. me callingptrace( PTRACE_TRACEME ) means I want my dad to debug me.This comes handy when you want debugger process to spawn thedebuggie. In this case you do fork() creating a new process,then ptrace( PTRACE_TRACEME ) and then you callexec()or execve().

首先调用PTRACE_TRACEME,告诉内核调用进程想要它的父进程来调试它自己。如,当我调用ptrace(PTRACE_TRACEME),这意味着我想要我的父进程来调试我。在你想要调试进程去孵化被调试进程的时候,这将会变得便利。在这种情形,你用fork()去创建一个新的进程,然后,ptrace(PTRACE_TRACEME)再调用exec()或者execve()



Second operation called PTRACE_ATTACH. It tells thekernel that calling process should become debugging parent of theprocess being called. Debugging parent means debugger and a parentprocess.

第二个操作就是调用PTRACE_ATTACH。这将告诉内核调用进程将要调试被调用的进程。前面说的调试进程就是去调试的程序以及其父进程。



Debugger-debuggiesynchronization

Alright. Now we told operating system that we are going to debugcertain process. Operating system made it our child process. Good.This is a great time for us to have the debuggie stopped and us doingpreparations before we actually start to debug. We may want to, forinstance, analyze executable that we run and place a breakpointsbefore we actually start debugging. So, how do we stop the debuggieand let debugger do its thing?

好吧,现在我们告诉操作系统,我们正要去调试一个指定的进程。操作系统会使之成为我们的子进程。好的,让被调试程序停止运行并且在我们开始调试之前做一些操作,对于我们来说这是一个重要的时刻。我们可能想要做一些事,例如:分析我们程序的可运行行以及在开始调试之前放置断点。然而,怎样来让之停止并且让调试器来做这些事情呢?

Operating system does that for us using signals. Actually,operating system notifies us, the debugger, about all kinds of eventsthat occur in debuggie and it does all that with signals. Thisincludes the “debuggie is ready to shoot” signal. In particular,if we attach to existing process it receives SIGSTOP and wereceive SIGCHLD once it actually stops. If we spawn a newprocess and it didptrace( PTRACE_TRACEME ) it will receiveSIGTRAP signal once it attempts toexec() orexecve(). We will be notified with SIGCHLD aboutthis, of course.

操作系统利用信号来做这些事情。事实上,操作系统会通知调试器,利用信号的形式来将所有在被调试程序出现的事件来通知。这包括“被调试程序要开始调试”的信号。特别的,如果我们附加到一个存在的进程,它将会收到SIGSTOP的信号,这个信号是在它停止运行的时候发出的。如果我们孵化出一个新的进程并且这个进程执行ptrace(PTRACE_TRACEME),那么一旦这个进程打算开始执行exec()或者execve()的时候,这个被调试的进程将会接收SIGTRAP信号。当然,我们的调试程序也会被这个SIGCHLD信号通知。



A newdebugger was born

Now lets see code that actually demonstrates that. Completelisting can be found here.

The debuggie does the following…

... if (ptrace( PTRACE_TRACEME, 0, NULL, NULL ))    {              perror( "ptrace" );               return;     }  execve( "/bin/ls", argv, envp );...

Note the ptrace( PTRACE_TRACEME ) followed by execve().This is what real debuggers do to spawn the process that going to bedebugged. As you know,execve() replaces current executableimage and memory of the current process with the executable andmemory space belonging to program that beingexecve()‘d.Once kernel finishes this operation, it sends SIGTRAP tocalling process and SIGCHLD to the debugger. The debuggerreceives appropriate notifications via signals and viawait()that returns. Here is the debugger’s code.

注意在ptrace(PTRACE_TRACEME)后面跟随着execve()函数。这就是真正的调试程序所做的孵化出一个正要被调试的进程。正如你所知道的,execve()所执行调用的程序会替换掉当前正在执行的镜像以及当前进程的内存空间。一旦内核结束这个操作,它会发送SIGTRAP信号去通知这个被调试的进程,同时发送SIGCHLD信号给调试程序。当调试程序接收到适当的通知后会通过信号和wait()返回相应的东西。下面就是调试程序代码。



...    do {            child = wait( &status );            printf( "Debugger exited wait()\n" );    if (WIFSTOPPED( status ))        {                    printf( "Child has stopped due to signal %d\n",                WSTOPSIG( status ) );            }            if (WIFSIGNALED( status ))        {                    printf( "Child %ld received signal %d\n",                    (long)child,                    WTERMSIG(status) );            }    }while (!WIFEXITED( status )); ...

Compiling and running listing1.c produces following output:

In debuggie process 14095In debugger process 14094Process 14094 received signal 17Debugger exited wait()Child has stopped due to signal 5

Here we can clearly see that debugger indeed receives a signal andgets notified viawait(). If we want to place a breakpointbefore we start to debug the process, this is our chance. Lets talkabout how we can do something like that.

在这里我们能够清晰的看到调试程序接收到一个信号并且通过wait()来通知。如果我们想要在我们调试之前放置一个断点,那么下面我们将会做一些改变。让我们来讲述怎么做这件事吧。



The magicbehind INT 3

It is time to dig a bit into subject that is not adored by most ofthe programmers and that is assembler language. I am afraid we don’thave much choice because breakpoints work on assembler level.

现在是时候挖掘一些比较底层的东西,然而这些东西正是不被大多数程序员所推崇的,这就是汇编语言。我觉得我们没有另外的选择了,因为断点是运作在汇编语言上的东西。



We have to understand that each our compiled program is actually aset of instructions that tells CPU what to do. Some of our Cexpressions translated into single instruction, while others may betranslated into hundreds and even thousands of instructions.Instruction may be bigger or smaller. From 1 byte up to 15 bytes longfor modern CPUs (Intel x86_64).

我们必须知道每一个复杂的程序事实上都是一个指令集,这些指令告诉CPU该做什么。一些C语言表达式正是被编译成简单的指令,然而有一些程序将会被编译成成千上万条指令。指令有大有小。在现代的CPU中指令长度从1byte15byte



Debuggers mostly operate on CPU instruction level. The matter offact that gdb understands C/C++ code and allows you to placebreakpoints at certain C/C++ line is only an enhancement overgdb‘sbasic ability to place breakpoints on certain instruction.

调试程序基本都是操作在CPU指令这一层级上的。事实上,gdb了解CC++代码,并且允许你放置断点在确定的C/C++代码的某一行上,这是比gdb的基本能力——把断点放置在确定的汇编指令上,更为高级。



There are several ways to place breakpoints. The most widely usedis the INT 3 instruction. It is a single byte operation codeinstruction that once reached by CPU, tells it to call specialbreakpoint interrupt handler, provided by operating system during itsinitialization. Since INT 3 instruction operation code is so small,we can safely substitute any instruction with it. Once operatingsystem’s interrupt handler called, it figures what process reacheda breakpoint and notifies it and its debugging process via signals.

这里有几种方法来放置断点。最常见的就是使用INT3指令。这是一个单byte指令,其一旦在CPU上执行,将会告诉它调用中断向量表中的某一项,这个中断向量表是在系统初始化的时候提供的。因为INT3指令操作代码占用空间很小,所以我们能够很方便安全的将其他指令与之进行替换。一旦操作系统的中断句柄被调用,它将会指出哪个进程到达断点处并且以信号的形式通知它以及它的调试程序。



Breakpointshands on

Lets return to our debuggie/debugger friends. As we mentioneddebugger does have a chance to place a breakpoint before letting thedebuggie process to run. Lets see how this can be done.

让我们返回到之前提及到的调试程序。前面我们提及到调试程序中会有个改变就是在程序运行之前会放置一个断点在程序中。下面让我们来看它是如何运作的。



Breakpoints placed with INT 3 instruction. Before writing theactual 0xcc (INT 3 operation code), we should figure where to placethe instruction. For purpose of this article we will do it manually.On the contrary, real debuggers include complex logic that calculateswhere and when to place the breakpoints. gdb places severalbreakpoints by itself, without you even knowing about it. Andobviously it has functionality that places breakpoints once you askit to do so.

设置断点使用INT3指令。在写入真实的INT3指令(0xcc)之前,我们需要指出在哪里放置这个指令。在这篇文章中我们会手动的来放置。相反的,真正的调试程序包含复杂的逻辑运算,它需要计算在哪里以及什么时候放置断点。gdb能够在你不知情的情况下,自己放置一些断点。同时,明显的它能够在你想要的地方放置一些断点。



In our previous example we had our debuggie process executing ls.It is not suitable for our next demonstration. We will need a sampleprogram that would let us easily demonstrate breakpoints in action.Here it is.

在我们先前的例子中,我们有我们调试的程序执行ls指令。这并不适合我们接下来的例子。我们需要一个简单的程序来让我们能够容易的理解断点运行的机制。如下:



#include <stdio.h>int main(){        printf( "~~~~~~~~~~~~> Before breakpoint\n" );        // The breakpoint        printf( "~~~~~~~~~~~~> After breakpoint\n" );        return 0;}

And here is the disassembler output of the main() routine.

下面是main()函数的汇编语言显示结果:



0000000000400508 <main>:  400508:       55                      push   %rbp400509:       48 89 e5                mov    %rsp,%rbp  40050c:       bf 18 06 40 00          mov    $0x400618,%edi  400511:       e8 12 ff ff ff          callq  400428 <puts@plt>  400516:       bf 2a 06 40 00          mov    $0x40062a,%edi  40051b:       e8 08 ff ff ff          callq  400428 <puts@plt>  400520:       b8 00 00 00 00          mov    $0x0,%eax  400525:       c9                      leaveq  400526:       c3                      retq

We can see that if we will place a breakpoint at address 0×400516,we will see a printout before reaching the breakpoint and right afterreaching it. For the sake of our demonstration, we will place abreakpoint at this address. Once we will reach the breakpoint, wewill sleep and then let the debuggie running. We should see debuggieproducing first printout, then sleeping for a few seconds and thenproducing second printout.

我们会发现,如果我们放置一个断点在地址0x400516中,我们只能看到在断点之前的那个输出内容。为了方便我们的示范,我们会放置一个断点在这个地址上。一旦我们运行到这个断点,这个进程会停止,然后让调试程序运行。我们将会看到被调试程序显示第一个输出内容,然后睡眠一段几秒后再输出第二个内容。



We’ll achieve our goal in several steps.

  1. First of all, we should fork()off the debuggie. We already did something similar.

  2. Next step is to intercept theexecve() call in debuggie. Been there, done that.

  3. Here’s something new. We shouldmodify a byte at address 0×400516 from 0xbf to 0xcc, savingoriginal value (0xbf). This is how we place the breakpoint.

  4. Next, we’re going to wait()for the process. Once it will reach the breakpoint, we’ll benotified.

  5. Once the debuggie reaches thebreakpoint we want to restore the code we broke with our 0xcc to itsoriginal state.

  6. In addition, we want to fix valueof RIP register. This register tells CPU what is the location inmemory of next meaningful instruction for it to execute. It’svalue will be 0×400517, one byte after 0xcc that we placed. We wantto set the RIP register to 0×400516 value because we don’t wantthe CPU to skip over that MOV instruction that we broke with our0xcc.

  7. Finally, we want to wait five seconds for the sake ofdemonstration and let the debuggie continue running.

通过下面几个步骤来实现:

1.我们应当fork()处一个子进程来作为被调试程序,前面已经做过了。

2.第二步是拦截子进程中execve()的调用,同样和前面做的一样。

3.我们需要修改地址为0x400516处的内容,将0xbf改为0xcc,并保存原始的值(那个0xbf)。这就是我们放置断点的过程。

4.接下来,我们的调试程序处于wait()阶段,来等待被调试的进程。一旦那进程到达断点处,它将会发出信号给调试程序。

5.一旦被调试的程序运行到达断点处,我们需要恢复原来的代码,将0xcc替换会原始值。

6.另外的,我们想要修改RIP寄存器的值。这个寄存器是用来告诉CPU下一条指令在内存中的所在位置。此时这个值是0x400516,被一个byte0xcc所替换的。我们想要设置RIP寄存器的值位0x400516,因为我们并不想要CPU漏掉那条MOV指令,这条指令是先前被替换成0xcc的。





First things first. Lets see how we do step 3.

...        addr = 0x400516;data = ptrace( PTRACE_PEEKTEXT, child, (void *)addr, NULL );orig_data = data;        data = (data & ~0xff) | 0xcc;        ptrace( PTRACE_POKETEXT, child, (void *)addr, data );...

Again, we can see how ptrace() does the job for us. First wepeek 8 (sizeof( long )) bytes from address 0×400516. Onsome architectures this could cause lots of headache because ofunaligned memory access. Luckily, we’re on x86_64 and unalignedmemory accesses are permitted. Next we set the lowest byte  tobe 0xcc – INT 3 instruction. Finally, we place 8 bytes back totheir place.

We’ve seen how we can wait for certain event in debuggie. Also,we now know how to restore the original value at address 0×400516.So we can skip over steps 4-5 and jump right into step 6. This issomething that we haven’t done so far.

接下来,我们能够看到ptrace()是怎么来为我们工作的。首先我们从地址为0x400516的位置获取8个字节(sizeof(long))。在一些架构中这会导致很多问题,因为非法访问了内存。幸运的是,我们在x86_64架构中并且非同盟访问内存是被允许的。其次,我们将第字节处的内容设置位0xcc,也就是INT3指令。

最后,我们将这被修改过后的8bytes放回原来的位置。我们会看到如何让调试器来等待一个确定的事件。同样的,我们现在知道怎么去恢复这个地址为0x400516处的原始值。所以我们跳过第4-5步骤,直接到第6步。这就是我们之前没做的几个步骤。



What we have to do is to read debuggie registers, change them andwrite them back. Againptrace() does all the job for us.

我们需要做的就是去读取被调试程序的寄存器,并改变他们的值。同样利用ptrace()来做这些工作。



...        struct user_regs_struct regs;...        ptrace( PTRACE_GETREGS, child, NULL, &regs );        regs.rip = addr;        ptrace( PTRACE_SETREGS, child, NULL, &regs );...

Things are not too well documented here. For instance ptrace()documentation never mentionsstruct user_regs_struct,however this is what ptrace() system call expects to receivein kernel. Once we know what we should use asptrace()arguments, it is easy. We use PTRACE_GETREGS operation toobtain values of debuggie’s registers, we modify the RIP registerand write them back withPTRACE_SETREGS operation. Clear andsimple.

这些东西并没有比较好的文档来介绍。例如介绍ptrace()的文档里面并没有提及到user_regs_struct这个结构体,然而这就是通过ptrace()系统调用通过内核接收到的东西。一旦我们知道我们需要使用ptrace()的哪些参数,这将会变得容易。我们使用PTRACE_CETREGS去获取被调试进程的寄存器中的值,我们修改PIP寄存器并用PTRACE_SETREGS操作将值写回寄存器。清晰且简单。



Lets see how things actually work. You can find complete listingof debugger process here. Compiling and running listing2.c, producesfollowing output.

让我们来看这些东西究竟是怎么运作的。你能够在这里找到所有调试进程的列表。编译运行listing2.c,并能看到相应的输出内容。



In debuggie process 29843In debugger process 29842Process 29842 received signal 17~~~~~~~~~~~~> Before breakpointProcess 29842 received signal 17RIP before resuming child is 400517Time before debugger falling asleep: 1206346035Time after debugger falling asleep: 1206346040. Resuming debuggie...~~~~~~~~~~~~> After breakpointProcess 29842 received signal 17Debuggie exited...Debugger exiting...

You can see that “Before breakpoint” printout appears 5 secondsbefore “After breakpoint” printout. The “RIP before resumingchild is 400517″ clearly indicates that the debuggie has stopped onaddress 0×400517, as we expected.

我回看到在“Beforebreakpoint”会比“Afterbreakpoint”早输出5秒。RIP寄存器被恢复子进程之前的值是400517,清晰的被指明被调试进程在地址位0x400517处停止,这和我们想要的结果是一致的。



Single steps

After seeing how easy to place a breakpoint, you can guess thatstepping over one line of C/C++ code is simply a matter of placing abreakpoint on the next line of code. This is exactly whatgdbdoes when you want it to single step over some expression.

在看了放置断点是如此简单的事情之后,你会知道在c/c++代码中将断点放置在下一行的代码中。这就是当你想要简单的放置断点在某个代码处的时候,gdb帮你做的内容。



Conclusion

Debuggers and how they work often associated with some kind ofmagic.

Debuggers, and gdb as an example, are exceptionallycomplicated piece of software. Placing breakpoints and singlestepping is only a small fraction of what it is able to do. gdbin particular works on dozens of hardware architectures. It supportsremote debugging. It is perhaps the most advanced and complicatedexecutable analyzer. It knows when a program loads dynamic libraryand analyzes the code of that library automatically. It supportsbunch of programming languages – from C/C++ to ADA. And these arejust few out of its features.

On the contrary, we’ve seen how easy to start debugging certainprocess, place a breakpoint, etc. The basic functionality that allowsdebugging is in the operating system and in the CPU, waiting for usto use it.



0 0
原创粉丝点击