了解GDB调试的核心:分析PTRACE的原理及其应用程序方案!

一、ptrace系统调用

PTRACE()系统调用是Linux为调试过程提供的工具。 PTRACE()系统调用非常强大。它为我们提供了许多调试方法来调试某个过程。以下是ptrace()系统调用的定义:

Long Ptrace(enum ___ptrace_request请求,pid_t pid,void *addr,void *data);以下解释了ptrace()每个参数的作用:

请求:指定调试说明。有许多类型的说明,例如:ptrace_traceme,ptrace_peekuser,ptrace_cont,ptrace_getregs等。以下内容将介绍不同指令的功能。 PID:过程ID(这是不需要解释的)。 ADDR:过程的某个地址空间,您可以使用此参数读取或编写过程的地址。数据:根据不同的说明,它具有不同的用途,将在下面介绍。独自编写Strace的第一步是了解PTRACE()系统调用的使用。让我们看一下ptrace()系统调用的定义:

int ptrace(长要求,长PID,长addr,长数据); ptrace()系统调用用于跟踪该过程的操作。以下是其各种参数的含义:

请求:指定跟踪操作。也就是说,可以通过传递不同的请求参数在过程中执行不同的跟踪操作。可选值为:ptrace_tracemeptrace_peektrace_peektrace_poketextptrace_contptrace_singlesteppid:指定要跟踪的过程PID。 ADDR:指定要读取或修改的内存地址。数据:数据对不同的请求操作具有不同的功能,将在下面介绍。如前所述,有两种使用Strace跟踪过程的方法。一个是通过strace命令启动该过程,另一个是指定要通过-P跟踪的过程。

PTRACE()系统调用还提供了两个请求,以实现上述两种方法:

第一个使用ptrace_traceme实现了第二个使用ptrace_attach来实现新文件strace.c。输入代码如下:

int main(int argc,char *argv []){pid_t child; child=fork(); if(child==0){//子进程.} else {//父进程.}返回0;}以上代码通过调用fork()创建子进程,但什么也不做。之后,我们将在子进程中运行跟踪程序,并运行在父进程中跟踪过程的代码。

运行跟踪程序

如前所述,需要在子进程中运行跟踪程序,并且要运行程序,您可以调用execl()系统调用。因此,您可以通过以下代码在子过程中运行LS命令:

#include unistd.h包括stdlib.h int main(int argc,char *argv []){pid_t child; child=fork(); if(child==0){execl(\’/bin/ls\’,\’/bin/ls\’,null);出口(0); } else {//父进程.}返回0;} execl()用于执行指定的程序。如果执行成功,它将不会返回,因此将不会执行Execl(.)的下一行退出(0)。

由于我们需要在执行LS命令之前跟踪LS命令,因此我们必须调用PTRACE(PTRACE_TRACEME,0,NULL,NULL),以告诉系统我们需要跟踪此过程。代码如下:

#include sys/ptrace.h包括unistd.hinclude stdlib.h int main(int argc,char *argv []){pid_t child; child=fork(); if(child==0){ptrace(ptrace_traceme,0,null,null); execl(\’/bin/ls\’,\’/bin/ls\’,null);出口(0); } else {//父进程.}返回0;}以这种方式,跟踪过程的代码已完成,然后启动了跟踪过程的代码。

编写过程跟踪代码

如果您编译并运行上述代码,您会发现没有效果。这是因为当孩子进程调用ptrace(ptrace_traceme,0,null,null)并调用execl()系统调用时,子进程将将sigchld信号发送到父程进程(跟踪过程)并停止运行自身,并且直到父母发送debug process发送一个调试命令。

由于在上面的代码中,父进程(跟踪过程)退出运行而不会发送任何调试命令,因此儿童进程(跟踪过程)在不运行的情况下退出,然后将不会看到效果。

现在,我们开始编写代码以跟踪过程。

由于跟踪过程将向跟踪的过程发送Sigchld消息,因此我们必须首先在跟踪过程的代码中接收Sigchld信号。通过使用Wait()系统调用来完成接收信号。代码如下:

#include sys/ptrace.h包括unistd.hinclude stdlib.hinclude stdio.h int main(int argc,char *argv []){pid_t child; int状态; child=fork(); if(child==0){ptrace(ptrace_traceme,0,null,null); execl(\’/bin/ls\’,\’/bin/ls\’,null);出口(0); } else {wait(status); //接收被子过程发送的Sigchld信号}返回0;}上面的代码称为Wait()系统调用用于接收由跟踪过程发送的Sigchld信号。接下来,有必要开始将调试命令发送到跟踪过程以调试跟踪过程。

获取流程寄存器的价值

Linux系统调用通过CPU寄存器通过参数,因此为了获取哪个系统调用,必须获得过程寄存器的值。可以通过ptrace()系统调用的ptrace_getregs命令来实现进程寄存器的值。代码如下:

#include sys/ptrace.hinclude sys/user.h包括unistd.hinclude stdlib.hinclude stdio.h int main(int argc,char *argv []){pid_t child; int状态; struct user_regs_struct regs; int orig_rax; child=fork(); if(child==0){ptrace(ptrace_traceme,0,null,null); execl(\’/bin/ls\’,\’/bin/ls\’,null);出口(0); } else {wait(status); //接收被子过程发送的SIGCHLD信号//1。将PTRACE_SYSCALL命令发送到跟踪过程(可以在调用系统调用之前可以获得系统调用的参数)等待(状态); //接收被子过程PTRACE发送的Sigchld信号(ptrace_getregs,child,0,s); //获取跟踪过程寄存器的值orig_rax=regs.orig_rax; //获取RAX寄存器printf的值(\’orig_rax:d \\ n\’,orig_rax); //获取跟踪过程寄存器的值orig_rax=regs.orig_rax; //获取RAX寄存器printf的值(\’orig_rax:d \\ n\’,orig_rax); //打印RAX寄存器的值//2。将PTRACE_SYSCALL命令发送到跟踪过程(调用系统调用后,您可以获取系统调用的返回值)等待(状态); //接收由被子进程发送的Sigchld信号}返回0;}以上代码通过调用PTRACE(PTRACE_GETREGS,CHILD,0,REGS)获取过程寄存器的值。 ptrace_getregs命令需要通过数据参数中的user_regs_struct的结构的指针,user_regs_struct在结构中定义如下(在文件sys/user.h中):

struct user_regs_struct {unsigned long r15,r14,r13,r12,rbp,rbp,rbx,r11,r10;未签名的长R9,R8,RAX,RCX,RDX,RSI,RDI,orig_rax;未签名的长RIP,CS,Eflags;未签名的长RSP,SS;未签名的长fs_base,gs_base;未签名的长ds,es,fs,gs;};如果User_regs_struct结构的orig_rax保存系统调用号码,因此我们可以知道通过orig_rax的值调用哪个系统调用。

编译并运行上述代码并输出结果:orig_rax: 12,这意味着当前调用是一个系统呼叫编号12。那么,哪个系统调用是系统调用号码12?

通过查找系统呼叫表,您可以知道系统呼叫编号12是BRK(),如下所示:

系统呼叫号码函数名称输入点源代码. 12 BRK sys_brk mm/mmap.c .上面程序仅跟踪一个系统调用,那么如何跟踪所有系统调用?这很简单,只需将跟踪的代码放入无限的循环中即可。代码如下:

#include sys/ptrace.hinclude sys/user.h包括unistd.hinclude stdlib.hinclude stdio.h int main(int argc,char *argv []){pid_t child; int状态; struct user_regs_struct regs; int orig_rax; child=fork(); if(child==0){ptrace(ptrace_traceme,0,null,null); execl(\’/bin/ls\’,\’/bin/ls\’,null);出口(0); } else {wait(status); //接收被子进程发送的Sigchld信号,而(1){//1。将PTRACE_SYSCALL命令发送到跟踪的过程(可以在调用系统调用之前可以获得系统调用的参数)PTRACE(PTRACE_SYSCALL,child,child,null,null,null,null,null,null);等待(状态); //如果(wirsxited(status)){//如果子进程退出,则接收由被子过程发送的sigchld信号,然后终止跟踪中断; } ptrace(ptrace_getregs,child,0,s); //获取跟踪过程寄存器的值orig_rax=regs.orig_rax; //获取RAX寄存器printf的值(\’orig_rax:d \\ n\’,orig_rax); //打印RAX寄存器的值//2。将PTRACE_SYSCALL命令发送到跟踪过程(调用系统调用后,您可以获取系统调用的返回值)等待(状态); //如果(wirsxited(status)){//如果子进程退出,则接收由被子过程发送的sigchld信号,然后终止跟踪中断;从执行结果判断,返回0;}打印系统调用号码不是很直观,那么我们如何优化它?

我们可以在系统调用号码和系统调用名称之间定义一个相应的表,以获得更清晰的输出结果,如下所示:

#include sys/ptrace.hinclude sys/user.h包括unistd.hinclude stdlib.hinclude stdio.h struct syscall {int code; char *name;} syscall_table []={{{{0,\’read\’},{1,\’write\’},{2,{2,\’Open\’},{3,{3,\’Close\’},{4,\’stat\’},\’stat\’},{5,{5,\’fstat\’},\’fstat\’},\’fstat\’},{6,{6,\’lstat\’lstat\’},\’},} {-1,null},} char *find_syscall_symbol(int code){struct syscall *sc; for(sc=syscall_table; sc-code=0; sc ++){if(sc-code==code){return sc-name; }} return null;} int main(int argc,char *argv []){pid_t child; int状态; struct user_regs_struct regs; int orig_rax; child=fork(); if(child==0){ptrace(ptrace_traceme,0,null,null); execl(\’/bin/ls\’,\’/bin/ls\’,null);出口(0); } else {wait(status); //接收被子进程发送的Sigchld信号,而(1){//1。将PTRACE_SYSCALL命令发送到跟踪的过程(可以在调用系统调用之前可以获得系统调用的参数)PTRACE(PTRACE_SYSCALL,child,child,null,null,null,null,null,null);等待(状态); //如果(wirsxited(status)){//如果子进程退出,则接收由被子过程发送的sigchld信号,然后终止跟踪中断; } ptrace(ptrace_getregs,child,0,s); //获取跟踪过程寄存器的值orig_rax=regs.orig_rax; //获取RAX寄存器printf的值(\’syscall:s()\\ n\’,find_syscall_symbol(orig_rax)); //打印系统调用//2。等待(状态); //接收子进程发送的sigchld信号if(wirsxited(status)){//如果子进程退出,则终止跟踪中断; }}}}}}}};}上面的示例添加了一个funption_syscall_symbol()以获取与系统调用号码相对应的系统调用名称,并且实现也相对简单。编译和运行后的输出结果如下:

[root@localhost lixusong] $ ./stracesyscall: brk()syscall: mmap()syscall: access() open()syscall: read()syscall: fstat()syscall: mmap()syscall: mprotect()syscall: mmap()syscall: mmap()syscall: close(). Judging from the execution results, you can now print the name of the system call, but we know that the strace命令还将打印系统调用参数的值。我们可以通过ptrace_peektext和ptrace()系统调用的ptrace_peekdata获得参数的值,因此,如果您有兴趣,可以自己实现此效果。

#include sys/ptrace.hinclude sys/user.h包括unistd.hinclude stdlib.hinclude stdio.h struct syscall {int code; char *name;} syscall_table []={{{{0,\’read\’},{1,\’write\’},{2,{2,\’open\’},{3,\’close\’},{4,\’stat\’},\’stat\’},{5,{5,\’fstat\’},\’fstat\’},\’fstat\’},\’fstat\’},{6,{6,\’lstat\’lstat\’},\’},\’\’ {9,\’mmap\’},{10,\’mprotect\’},{11,\’munmap\’},{12,\’brk\’},{13,\’rt_sigaction\’},{14,\’rt_sigprocmask\’} \’pread64\’},{18,\’pwrite64\’},{19,\’readv\’},{20,\’writev\’},{21,\’access\’},{22,\’pipe\’},{23,{23,\’select\’select\’},\’select\’},{24,sched_yield\’},\’sched_yield\’},\’ \’mincore\’},{28,\’madvise\’},{29,\’shmget\’},{30,\’shmat\’},{31,\’shmctl\’},{32,\’dup\’},{33,{33,dup2\’} \’getItimer\’},{37,\’alarm\’},{38,\’setItimer\’},{39,\’getpid\’},{40,\’sendfile\’},{41,\’socket\’},\’socket\’},{42,{42,\’connect\’},\’connect\’},\’},{43,\’apcct\’},\’},\’},\’},{44,\’\’\’\’\’\’ \’recvFrom\’},{46,\’sendmsg\’},{47,\’recvmsg\’},{48,\’shutdown\’},{49,\’bind\’},{50,\’listh\’\’},{51,{51,\’getsockname\’},\’getsockname\’},{52,52,{52,\’getpepeernem\’\’ \’setSockopt\’},{55,\’getockopt\’},{56,\’clone\’},{57,\’fork\’},{58,\’vfork\’},{59,{59,\’execve\’},\’},{60,{60,\’exit\’},\’exit\’},\’exit\’},{61,{61,\’fait4\’\’\’},{62,{62,{62,\'{62,\’\’ \’uname\’},{64,\’semget\’},{65,\’semop\’},{66,\’semctl\’},{67,\’shmdt\’},{68,\’msgget\’},{69,{69,{69,\’msgsnd\’} {72,\’fcntl\’},{73,\’Flock\’},{74,\’fsync\’},{75,\’fdataSync\’},{76,\’truncate\’},{77,{77,\’ftruncate \’Chdir\’},{8

1, \”fchdir\”}, {82, \”rename\”}, {83, \”mkdir\”}, {84, \”rmdir\”}, {85, \”creat\”}, {86, \”link\”}, {87, \”unlink\”}, {88, \”symlink\”}, {89, \”readlink\”}, {90, \”chmod\”}, {91, \”fchmod\”}, {92, \”chown\”}, {93, \”fchown\”}, {94, \”lchown\”}, {95, \”umask\”}, {96, \”gettimeofday\”}, {97, \”getrlimit\”}, {98, \”getrusage\”}, {99, \”sysinfo\”}, {100, \”times\”}, {101, \”ptrace\”}, {102, \”getuid\”}, {103, \”syslog\”}, {104, \”getgid\”}, {105, \”setuid\”}, {106, \”setgid\”}, {107, \”geteuid\”}, {108, \”getegid\”}, {109, \”setpgid\”}, {110, \”getppid\”}, {111, \”getpgrp\”}, {112, \”setsid\”}, {113, \”setreuid\”}, {114, \”setregid\”}, {115, \”getgroups\”}, {116, \”setgroups\”}, {117, \”setresuid\”}, {118, \”getresuid\”}, {119, \”setresgid\”}, {120, \”getresgid\”}, {121, \”getpgid\”}, {122, \”setfsuid\”}, {123, \”setfsgid\”}, {124, \”getsid\”}, {125, \”capget\”}, {126, \”capset\”}, {127, \”rt_sigpending\”}, {128, \”rt_sigtimedwait\”}, {129, \”rt_sigqueueinfo\”}, {130, \”rt_sigsuspend\”}, {131, \”sigaltstack\”}, {132, \”utime\”}, {133, \”mknod\”}, {134, \”uselib\”}, {135, \”personality\”}, {136, \”ustat\”}, {137, \”statfs\”}, {138, \”fstatfs\”}, {139, \”sysfs\”}, {140, \”getpriority\”}, {141, \”setpriority\”}, {142, \”sched_setparam\”}, {143, \”sched_getparam\”}, {144, \”sched_setscheduler\”}, {145, \”sched_getscheduler\”}, {146, \”sched_get_priority_max\”}, {147, \”sched_get_priority_min\”}, {148, \”sched_rr_get_interval\”}, {149, \”mlock\”}, {150, \”munlock\”}, {151, \”mlockall\”}, {152, \”munlockall\”}, {153, \”vhangup\”}, {154, \”modify_ldt\”}, {155, \”pivot_root\”}, {156, \”_sysctl\”}, {157, \”prctl\”}, {158, \”arch_prctl\”}, {159, \”adjtimex\”}, {160, \”setrlimit\”}, {161, \”chroot\”}, {162, \”sync\”}, {163, \”acct\”}, {164, \”settimeofday\”}, {165, \”mount\”}, {166, \”umount2\”}, {167, \”swapon\”}, {168, \”swapoff\”}, {169, \”reboot\”}, {170, \”sethostname\”}, {171, \”setdomainname\”}, {172, \”iopl\”}, {173, \”ioperm\”}, {174, \”create_module\”}, {175, \”init_module\”}, {176, \”delete_module\”}, {177, \”get_kernel_syms\”}, {178, \”query_module\”}, {179, \”quotactl\”}, {180, \”nfsservctl\”}, {181, \”getpmsg\”}, {182, \”putpmsg\”}, {183, \”afs_syscall\”}, {184, \”tuxcall\”}, {185, \”security\”}, {186, \”gettid\”}, {187, \”readahead\”}, {188, \”setxattr\”}, {189, \”lsetxattr\”}, {190, \”fsetxattr\”}, {191, \”getxattr\”}, {192, \”lgetxattr\”}, {193, \”fgetxattr\”}, {194, \”listxattr\”}, {195, \”llistxattr\”}, {196, \”flistxattr\”}, {197, \”removexattr\”}, {198, \”lremovexattr\”}, {199, \”fremovexattr\”}, {200, \”tkill\”}, {201, \”time\”}, {202, \”futex\”}, {203, \”sched_setaffinity\”}, {204, \”sched_getaffinity\”}, {205, \”set_thread_area\”}, {206, \”io_setup\”}, {207, \”io_destroy\”}, {208, \”io_getevents\”}, {209, \”io_submit\”}, {210, \”io_cancel\”}, {211, \”get_thread_area\”}, {212, \”lookup_dcookie\”}, {213, \”epoll_create\”}, {214, \”epoll_ctl_old\”}, {215, \”epoll_wait_old\”}, {216, \”remap_file_pages\”}, {217, \”getdents64\”}, {218, \”set_tid_address\”}, {219, \”restart_syscall\”}, {220, \”semtimedop\”}, {221, \”fadvise64\”}, {222, \”timer_create\”}, {223, \”timer_settime\”}, {224, \”timer_gettime\”}, {225, \”timer_getoverrun\”}, {226, \”timer_delete\”}, {227, \”clock_settime\”}, {228, \”clock_gettime\”}, {229, \”clock_getres\”}, {230, \”clock_nanosleep\”}, {231, \”exit_group\”}, {232, \”epoll_wait\”}, {233, \”epoll_ctl\”}, {234, \”tgkill\”}, {235, \”utimes\”}, {236, \”vserver\”}, {237, \”mbind\”}, {238, \”set_mempolicy\”}, {239, \”get_mempolicy\”}, {240, \”mq_open\”}, {241, \”mq_unlink\”}, {242, \”mq_timedsend\”}, {243, \”mq_timedreceive\”}, {244, \”mq_notify\”}, {245, \”mq_getsetattr\”}, {246, \”kexec_load\”}, {247, \”waitid\”}, {248, \”add_key\”}, {249, \”request_key\”}, {250, \”keyctl\”}, {251, \”ioprio_set\”}, {252, \”ioprio_get\”}, {253, \”inotify_init\”}, {254, \”inotify_add_watch\”}, {255, \”inotify_rm_watch\”}, {256, \”migrate_pages\”}, {257, \”openat\”}, {258, \”mkdirat\”}, {259, \”mknodat\”}, {260, \”fchownat\”}, {261, \”futimesat\”}, {262, \”newfstatat\”}, {263, \”unlinkat\”}, {264, \”renameat\”}, {265, \”linkat\”}, {266, \”symlinkat\”}, {267, \”readlinkat\”}, {268, \”fchmodat\”}, {269, \”faccessat\”}, {270, \”pselect6\”}, {271, \”ppoll\”}, {272, \”unshare\”}, {273, \”set_robust_list\”}, {274, \”get_robust_list\”}, {275, \”splice\”}, {276, \”tee\”}, {277, \”sync_file_range\”}, {278, \”vmsplice\”}, {279, \”move_pages\”}, {280, \”utimensat\”}, {281, \”epoll_pwait\”}, {282, \”signalfd\”}, {283, \”timerfd_create\”}, {284, \”eventfd\”}, {285, \”fallocate\”}, {286, \”timerfd_settime\”}, {287, \”timerfd_gettime\”}, {288, \”accept4\”}, {289, \”signalfd4\”}, {290, \”eventfd2\”}, {291, \”epoll_create1\”}, {292, \”dup3\”}, {293, \”pipe2\”}, {294, \”inotify_init1\”}, {295, \”preadv\”}, {296, \”pwritev\”}, {297, \”rt_tgsigqueueinfo\”}, {298, \”perf_event_open\”}, {299, \”recvmmsg\”}, {300, \”fanotify_init\”}, {301, \”fanotify_mark\”}, {302, \”prlimit64\”}, {303, \”name_to_handle_at\”}, {304, \”open_by_handle_at\”}, {305, \”clock_adjtime\”}, {306, \”syncfs\”}, {307, \”sendmmsg\”}, {308, \”setns\”}, {309, \”getcpu\”}, {310, \”process_vm_readv\”}, {311, \”process_vm_writev\”}, {312, \”kcmp\”}, {313, \”finit_module\”}, {314, \”sched_setattr\”}, {315, \”sched_getattr\”}, {316, \”renameat2\”}, {317, \”seccomp\”}, {318, \”getrandom\”}, {319, \”memfd_create\”}, {320, \”kexec_file_load\”}, {321, \”bpf\”}, {322, \”execveat\”}, {323, \”userfaultfd\”}, {324, \”membarrier\”}, {325, \”mlock2\”}, {326, \”copy_file_range\”}, {327, \”preadv2\”}, {328, \”pwritev2\”}, {329, \”pkey_mprotect\”}, {330, \”pkey_alloc\”}, {331, \”pkey_free\”}, {332, \”statx\”}, {333, \”io_pgetevents\”}, {334, \”rseq\”}, {424, \”pidfd_send_signal\”}, {425, \”io_uring_setup\”}, {426, \”io_uring_enter\”}, {427, \”io_uring_register\”}, {428, \”open_tree\”}, {429, \”move_mount\”}, {430, \”fsopen\”}, {431, \”fsconfig\”}, {432, \”fsmount\”}, {433, \”fspick\”}, {434, \”pidfd_open\”}, {435, \”clone3\”}, {436, \”close_range\”}, {437, \”openat2\”}, {438, \”pidfd_getfd\”}, {439, \”faccessat2\”}, {440, \”process_madvise\”}, {512, \”rt_sigaction\”}, {513, \”rt_sigreturn\”}, {514, \”ioctl\”}, {515, \”readv\”}, {516, \”writev\”}, {517, \”recvfrom\”}, {518, \”sendmsg\”}, {519, \”recvmsg\”}, {520, \”execve\”}, {521, \”ptrace\”}, {522, \”rt_sigpending\”}, {523, \”rt_sigtimedwait\”}, {524, \”rt_sigqueueinfo\”}, {525, \”sigaltstack\”}, {526, \”timer_create\”}, {527, \”mq_notify\”}, {528, \”kexec_load\”}, {529, \”waitid\”}, {530, \”set_robust_list\”}, {531, \”get_robust_list\”}, {532, \”vmsplice\”}, {533, \”move_pages\”}, {534, \”preadv\”}, {535, \”pwritev\”}, {536, \”rt_tgsigqueueinfo\”}, {537, \”recvmmsg\”}, {538, \”sendmmsg\”}, {539, \”process_vm_readv\”}, {540, \”process_vm_writev\”}, {541, \”setsockopt\”}, {542, \”getsockopt\”}, {543, \”io_setup\”}, {544, \”io_submit\”}, {545, \”execveat\”}, {546, \”preadv2\”}, {547, \”pwritev2\”}, {-1, NULL},}; char *find_syscall_symbol(int code) { struct syscall *sc; for (sc = syscall_table; sc->code >= 0; sc++) { if (sc->code == code) { return sc->name; } } return NULL;} int main(int argc, char *argv[]){ pid_t child; int status; struct user_regs_struct regs; int orig_rax; child = fork(); if (child == 0) { ptrace(PTRACE_TRACEME, 0, NULL, NULL); execl(\”/bin/ls\”, \”/bin/ls\”, NULL); exit(0); } else { wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号 while (1) { // 1. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用前,可以获取系统调用的参数) ptrace(PTRACE_SYSCALL, child, NULL, NULL); wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号 if(WIFEXITED(status)) { // 如果子进程退出了, 那么终止跟踪 break; } ptrace(PTRACE_GETREGS, child, 0, ®s); // 获取被跟踪进程寄存器的值 orig_rax = regs.orig_rax; // 获取rax寄存器的值 printf(\”syscall: %s()\\n\”, find_syscall_symbol(orig_rax)); // 打印rax寄存器的值 // 2. 发送 PTRACE_SYSCALL 命令给被跟踪进程 (调用系统调用后,可以获取系统调用的返回值) ptrace(PTRACE_SYSCALL, child, NULL, NULL); wait(&status); // 接收被子进程发送过来的 SIGCHLD 信号 if(WIFEXITED(status)) { // 如果子进程退出了, 那么终止跟踪 break; } } } return 0;}

二、ptrace使用示例

下面通过一个简单例子来说明 ptrace() 系统调用的使用,这个例子主要介绍怎么使用 ptrace() 系统调用获取当前被调试(追踪)进程的各个寄存器的值,代码如下(ptrace.c):

#include <sys/ptrace.h>#include <sys/types.h>#include <sys/wait.h>#include <unistd.h>#include <sys/user.h>#include <stdio.h> int main(){ pid_t child; struct user_regs_struct regs; child = fork(); // 创建一个子进程 if(child == 0) { // 子进程 ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 表示当前进程进入被追踪状态 execl(\”/bin/ls\”, \”ls\”, NULL); // 执行 `/bin/ls` 程序 } else { // 父进程 wait(NULL); // 等待子进程发送一个 SIGCHLD 信号 ptrace(PTRACE_GETREGS, child, NULL, ®s); // 获取子进程的各个寄存器的值 printf(\”Register: rdi[%ld], rsi[%ld], rdx[%ld], rax[%ld], orig_rax[%ld]\\n\”, regs.rdi, regs.rsi, regs.rdx,regs.rax, regs.orig_rax); // 打印寄存器的值 ptrace(PTRACE_CONT, child, NULL, NULL); // 继续运行子进程 sleep(1); } return 0;}通过命令 gcc ptrace.c -o ptrace 编译并运行上面的程序会输出如下结果:

Register: rdi[0], rsi[0], rdx[0], rax[0], orig_rax[59]ptrace ptrace.c上面结果的第一行是由父进程输出的,主要是打印了子进程执行 /bin/ls 程序后各个寄存器的值。而第二行是由子进程输出的,主要是打印了执行 /bin/ls 程序后输出的结果。

下面解释一下上面程序的执行流程:

主进程调用 fork() 系统调用创建一个子进程。子进程调用 ptrace(PTRACE_TRACEME,…) 把自己设置为被追踪状态,并且调用 execl() 执行 /bin/ls 程序。被设置为追踪(TRACE)状态的子进程执行 execl() 的程序后,会向父进程发送 SIGCHLD 信号,并且暂停自身的执行。父进程通过调用 wait() 接收子进程发送过来的信号,并且开始追踪子进程。父进程通过调用 ptrace(PTRACE_GETREGS, child, …) 来获取到子进程各个寄存器的值,并且打印寄存器的值。父进程通过调用 ptrace(PTRACE_CONT, child, …) 让子进程继续执行下去。从上面的例子可以知道,通过向 ptrace() 函数的 request 参数传入不同的值时,就有不同的效果。比如传入 PTRACE_TRACEME 就可以让进程进入被追踪状态,而传入 PTRACE_GETREGS 时,就可以获取被追踪的子进程各个寄存器的值等。

三、调试工具

3.1基础知识

动机

要了解我们要做什么,请尝试想象调试器需要什么才能完成其工作。调试器可以启动某个进程并对其进行调试,或者将其自身附加到现有进程。它可以单步执行代码、设置断点并运行它们、检查变量值和堆栈跟踪。许多调试器具有高级功能,例如在调试进程的地址空间中执行表达式和调用函数,甚至动态更改进程的代码并观察效果。

尽管现代调试器是复杂的野兽,但令人惊讶的是它们的构建基础却如此简单。调试器一开始只提供操作系统和编译器/链接器提供的一些基本服务,其余的只是简单的编程问题。

Linux调试——ptrace

Linux 调试器的瑞士军刀是ptrace系统调用。它是一种多功能且相当复杂的工具,允许一个进程控制另一个进程的执行并窥探其内部结构。ptrace需要一本中等大小的书才能完整解释,这就是为什么我只在示例中重点介绍它的一些实际用途。

单步执行流程的代码

我现在将开发一个在“跟踪”模式下运行进程的示例,其中我们将单步执行其代码 – 由 CPU 执行的机器代码(汇编指令)。我将分部分展示示例代码,逐一进行解释,在文章末尾,您将找到一个下载完整 C 文件的链接,您可以编译、执行和使用该文件。高级计划是编写代码,将其分为一个执行用户提供的命令的子进程和一个跟踪子进程的父进程。

主要功能:

int main ( int argc, char ** argv){ pid_t 子进程pid; if (argc < 2 ) { fprintf(stderr, \”需要一个程序名称作为参数\\n\” ); 返回- 1; } child_pid = fork(); 如果(child_pid == 0) run_target(argv[ 1 ]); 否则 如果(child_pid > 0 ) run_debugger(child_pid); 否则{ perror( “分叉” ); 返回- 1; } 返回 0;}非常简单:我们使用fork 启动一个新的子进程。后续条件的if分支运行子进程(此处称为“目标”),else if分支运行父进程(此处称为“调试器”)。

表示该进程将被其父进程跟踪。传递给该进程的任何信号(SIGKILL 除外)都会导致该进程停止,并通过 wait() 通知其父进程。此外,此进程对 exec() 的所有后续调用都会导致向其发送 SIGTRAP,从而使父进程有机会在新程序开始执行之前获得控制权。如果进程的父进程不希望跟踪它,则它可能不应该发出此请求。 (pid、addr 和 data 被忽略。)

我在这个例子中强调了我们感兴趣的部分。请注意, run_target在ptrace之后执行的下一件事是使用execl调用作为参数提供给它的程序。正如突出显示的部分所解释的,这会导致操作系统内核在开始执行execl中的程序并向父进程发送信号之前停止该进程。

因此,时机成熟了,看看父母会做什么:

无效 run_debugger(pid_t child_pid){ int wait_status; 无符号icounter = 0; procmsg( “调试器已启动\\n” ); /* 等待子进程停止执行第一个指令 */ 等待(&等待状态); while (WIFSTOPPED(wait_status)) { icounter++; /* 让子进程执行另一条指令 */ if (ptrace(PTRACE_SINGLESTEP, child_pid, 0 , 0 ) < 0 ) { perror( “ptrace” ); 返回; } /* 等待子进程停止执行下一条指令 */ 等待(&等待状态); } procmsg( \”子进程执行了 %u 条指令\\n\” , icounter);}想一下上面的内容,一旦子进程开始执行exec调用,它将停止并发送SIGTRAP信号。这里的父级在第一个等待调用中等待这种情况发生。一旦发生有趣的事情, wait将返回,并且父进程检查是否是因为子进程被停止(如果子进程通过传递信号而停止,则WIFSTOPPED返回 true)。

请注意,icounter计算子进程执行的指令数量。因此,我们的简单示例实际上做了一些有用的事情 – 在命令行上给定程序名称,它会执行该程序并报告从开始运行到结束所需的 CPU 指令量。让我们看看它的实际效果。

试运行

我编译了以下简单程序并在跟踪器下运行它:

#include <stdio.h>int 主函数(){ printf( \”你好,世界!\\n\” ); 返回 0;}令我惊讶的是,跟踪器运行了很长时间,并报告执行了超过 100,000 条指令。对于一个简单的printf调用?是什么赋予了?答案很有趣。默认情况下, Linux 上的gcc动态地将程序链接到 C 运行时库。这意味着,执行任何程序时首先运行的事情之一就是查找所需共享库的动态库加载器。这是相当多的代码 – 请记住,我们的基本跟踪器会查看每条指令,不仅仅是主函数,而是整个过程。

因此,当我使用-static标志链接测试程序时(并验证可执行文件的重量增加了约 500KB,这对于 C 运行时的静态链接来说是合乎逻辑的),跟踪仅报告了 7,000 条左右的指令。这仍然很多,但如果您还记得libc初始化仍然必须在main之前运行,并且清理必须在main之后运行,那就完全有意义了。此外,printf是一个复杂的函数。

深入指令流

通过汇编编写的程序,我可以向您介绍ptrace的另一个强大用途- 仔细检查所跟踪进程的状态。这是run_debugger函数的另一个版本:

无效 run_debugger(pid_t child_pid){ int wait_status; 无符号icounter = 0; procmsg( “调试器已启动\\n” ); /* 等待子进程停止执行第一个指令 */ 等待(&等待状态); while (WIFSTOPPED(wait_status)) { icounter++; struct user_regs_struct regs; ptrace(PTRACE_GETREGS, child_pid, 0 , ®s); 无符号指令 = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0 ); procmsg( \”icounter = %u.EIP = 0x%08x.instr = 0x%08x\\n\” , icounter、regs.eip、instr); /* 让子进程执行另一条指令 */ if (ptrace(PTRACE_SINGLESTEP, child_pid, 0 , 0 ) < 0 ) { perror( “ptrace” ); 返回; } /* 等待子进程停止执行下一条指令 */ 等待(&等待状态); } procmsg( \”子进程执行了 %u 条指令\\n\” , icounter);}唯一的区别在于while循环的前几行。有两个新的ptrace调用。第一个将进程寄存器的值读入结构中。user_regs_struct在sys/user.h中定义。现在这是有趣的部分 – 如果你查看这个头文件,靠近顶部的评论说:

/* 这个文件的全部目的是为了 GDB 和 GDB 而已。 不要过度解读它。除非知道自己在做什么,否则不要将其用于 GDB 以外的任何用途 。 */现在,我不了解你的情况,但这让我觉得我们走在正确的轨道上:-)无论如何,回到这个例子。一旦我们在regs中拥有了所有寄存器,我们就可以通过使用PTRACE_PEEKTEXT调用ptrace来查看进程的当前指令,并将regs.eip(x86 上的扩展指令指针)作为地址传递给它。我们返回的是指令。让我们看看这个新的跟踪器在我们的汇编代码片段上运行:

$ simple_tracer 追踪_helloworld[5700]调试器已启动[5701]目标开始了。将运行“traced_helloworld”[5700] icounter = 1。EIP = 0x08048080。指令 = 0x00000eba[5700] icounter = 2。EIP = 0x08048085。指令 = 0x0490a0b9[5700] icounter = 3。EIP = 0x0804808a。指令 = 0x000001bb[5700] icounter = 4。EIP = 0x0804808f。指令 = 0x000004b8[5700] icounter = 5。EIP = 0x08048094。指令 = 0x01b880cd你好世界![5700] icounter = 6。EIP = 0x08048096。指令 = 0x000001b8[5700] icounter = 7。EIP = 0x0804809b。指令 = 0x000080cd【5700】孩子执行了7条指令好的,现在除了icounter之外,我们还可以看到指令指针以及它在每一步指向的指令。如何验证这是否正确?通过在可执行文件上使用objdump-d :

$ objdump -d traced_helloworldtraced_helloworld:文件格式 elf32-i386.text 节的反汇编:08048080 <.文本>: 8048080: ba 0e 00 00 00 mov $0xe,%edx 8048085:b9 a0 90 04 08 mov $0x80490a0,%ecx 804808a: bb 01 00 00 00 mov $0x1,%ebx 804808f: b8 04 00 00 00 mov $0x4,%eax 8048094:cd 80 int $0x80 8048096:b8 01 00 00 00 mov $0x1,%eax 804809b:cd 80 int $0x80这和我们的跟踪输出之间的对应关系很容易观察到。

附加到正在运行的进程

如您所知,调试器还可以附加到已经运行的进程。现在您不会惊讶地发现这也是通过ptrace完成的,它可以获取PTRACE_ATTACH请求。我不会在这里展示代码示例,因为考虑到我们已经完成的代码,它应该很容易实现。出于教育目的,这里采用的方法更方便(因为我们可以在子进程开始时停止它)。

代码

单步执行代码很有用,但仅限于一定程度。采取 C “你好,世界!”我上面演示的示例。要进入main,可能需要执行数千条 C 运行时初始化代码指令。这不太方便。理想情况下,我们希望能够在main 的入口处放置一个断点,然后从那里开始执行。很公平,在本系列的下一部分中,我打算展示断点是如何实现的。

3.2断点

软件中断

了解GDB调试的核心:分析PTRACE的原理及其应用程序方案!

为了在 x86 架构上实现断点,需要使用软件中断(也称为“陷阱”)。在深入讨论细节之前,我想先解释一下中断和陷阱的一般概念。

CPU 具有单个执行流,一条一条地执行指令[1]。为了处理 IO 和硬件定时器等异步事件,CPU 使用中断。硬件中断通常是附有特殊“响应电路”的专用电信号。该电路注意到中断的激活,并使CPU停止当前的执行,保存其状态,并跳转到中断处理程序例程所在的预定义地址。当处理程序完成其工作时,CPU 从停止处恢复执行。

软件中断在原理上类似,但在实践中有些不同。 CPU 支持允许软件模拟中断的特殊指令。当执行这样的指令时,CPU 将其视为中断 – 停止其正常执行流程,保存其状态并跳转到处理程序例程。这些“陷阱”使得现代操作系统的许多奇迹(任务调度、虚拟内存、内存保护、调试)得以有效实现。

一些编程错误(例如除以 0)也会被 CPU 视为陷阱,并且通常称为“异常”。这里硬件和软件之间的界限变得模糊,因为很难说这种异常是真正的硬件中断还是软件中断。但我已经离主题太远了,所以是时候回到断点了。

理论上int 3

写完上一节后,我现在可以简单地说,断点是通过一个名为int 3的特殊陷阱在 CPU 上实现的。int是 x86 术语,意为“陷阱指令”——调用预定义的中断处理程序。 x86支持int指令,其8位操作数指定发生的中断编号,因此理论上支持256个陷阱。前 32 个由 CPU 为其自身保留,而第 3 个是我们在这里感兴趣的 – 它称为“调试器陷阱”。

话不多说,我将引用圣经本身:

INT 3 指令生成一个特殊的单字节操作码 (CC),用于调用调试异常处理程序。 (这个单字节形式很有价值,因为它可以用来用断点替换任何指令的第一个字节,包括其他单字节指令,而无需覆盖其他代码)。

实践中的 int 3

是的,了解事物背后的理论固然很好,但这到底意味着什么呢?我们如何使用int 3来实现断点呢?或者解释一下常见的编程问答术语 -请告诉我代码!

这就是全部内容——诚实!现在回想一下本系列的第一部分,跟踪(调试器)进程会收到其子进程(或其附加的用于调试的进程)获得的所有信号的通知,并且您可以开始了解我们要去的地方。就这样,不再有计算机体系结构 101 jabber。现在是示例和代码的时候了。

手动设置断点

我想在第一个打印输出之后、第二个打印输出之前设置一个断点。假设就在mov edx, len2指令上的第一个int 0x80 [4]之后。首先,我们需要知道该指令映射到什么地址。运行objdump -d:

Traced_printer2:文件格式 elf32-i386部分:Algn 中的 Idx 名称大小 VMA LMA 文件 0.文本00000033 08048080 08048080 00000080 2**4 内容、分配、加载、只读、代码 1.数据0000000e 080490b4 080490b4 000000b4 2**2 内容、分配、加载、数据.text 节的反汇编:08048080 <.文本>: 8048080: ba 07 00 00 00 mov $0x7,%edx 8048085:b9 b4 90 04 08 mov $0x80490b4,%ecx 804808a: bb 01 00 00 00 mov $0x1,%ebx 804808f: b8 04 00 00 00 mov $0x4,%eax 8048094:cd 80 int $0x80 8048096: ba 07 00 00 00 mov $0x7,%edx 804809b: b9 bb 90 04 08 mov $0x80490bb,%ecx 80480a0: bb 01 00 00 00 移动 $0x1,%ebx 80480a5: b8 04 00 00 00 mov $0x4,%eax 80480aa:cd 80 int $0x80 80480ac: b8 01 00 00 00 mov $0x1,%eax 80480b1:cd 80 int $0x80所以,我们要设置断点的地址是0x8048096。等等,这不是真正的调试器的工作方式,对吗?真正的调试器在代码行和函数上设置断点,而不是在某些裸内存地址上设置断点?非常正确。但我们距离目标还很远 – 要像真正的调试器一样设置断点,我们仍然必须首先介绍符号和调试信息,并且需要本系列中的另一部分或两部分来讨论这些主题。现在,我们必须处理裸内存地址。

说到这里我真的很想再跑题了,所以你有两个选择。如果您确实有兴趣了解为什么地址是 0x8048096 以及它的含义,请阅读下一节。如果没有,并且您只想继续处理断点,则可以安全地跳过它。

使用 int 3 在调试器中设置断点

要在跟踪进程中的某个目标地址处设置断点,调试器将执行以下操作:

记住目标地址存储的数据将目标地址的第一个字节替换为 int 3 指令然后,当调试器要求操作系统运行该进程(如我们在上一篇文章中看到的PTRACE_CONT)时,该进程将运行并最终遇到 int 3 ,在那里它将停止,操作系统将向其发送一个信号。这是调试器再次介入的地方,接收到其子进程(或跟踪进程)已停止的信号。然后它可以:

将目标地址处的int 3指令替换为原指令将跟踪进程的指令指针回滚 1。这是必需的,因为指令指针现在指向int 3之后,并且已经执行了它。允许用户以某种方式与进程交互,因为进程仍然在所需的目标地址处停止。这是调试器允许您查看变量值、调用堆栈等的部分。当用户想要继续运行时,调试器将负责将断点放回目标地址(因为它在步骤 1 中被删除),除非用户要求取消断点。有关 int 3 的更多信息

现在是回来检查int 3和英特尔手册中那个奇怪的注释的好时机。又是这样:这种单字节形式很有价值,因为它可以用来用断点替换任何指令的第一个字节,包括其他单字节指令,而无需覆盖其他代码

x86 上的int指令占用两个字节 – 0xcd后跟中断号[6]。 int 3可以被编码为cd 03,但是有一个为其保留的特殊单字节指令 – 0xcc。

为什么这样?因为这允许我们插入断点而无需覆盖多个指令。这很重要。考虑这个示例代码:

..一些代码.. 富杰 十进制富: 呼叫栏 ..一些代码..假设我们想在dec eax上放置一个断点。这恰好是一条单字节指令(操作码为0x48)。如果替换断点指令的长度超过 1 个字节,我们将被迫覆盖下一条指令 ( call ) 的一部分,这会使其出现乱码,并可能产生完全无效的结果。但是jz foo 的分支是什么?然后, CPU不会在dec eax处停止,而是直接执行其后的无效指令。

对int 3使用特殊的 1 字节编码可以解决这个问题。由于 1 字节是 x86 上一条指令可以得到的最短指令,因此我们保证只有我们想要中断的指令才会改变。

封装一些血淋淋的细节

上一节的代码示例中显示的许多低级细节可以轻松封装在方便的 API 后面。我已经将一些封装到一个名为debuglib的小型实用程序库中- 它的代码可以在文章末尾下载。在这里,我只想演示一个其用法的示例,但有所不同。我们将跟踪用 C 编写的程序。

跟踪 C 程序

到目前为止,为了简单起见,我主要关注汇编语言目标。现在是时候更上一层楼,看看我们如何跟踪用 C 编写的程序了。

事实证明,情况并没有太大不同 – 只是找到放置断点的位置有点困难。考虑这个简单的程序:

#include <stdio.h>无效 do_stuff (){ printf( “你好,” );}int 主函数(){ for ( int i = 0 ; i < 4 ; ++i) 做东西(); printf( \”世界!\\n\” ); 返回 0;}假设我想在do_stuff的入口处放置一个断点。我将使用老朋友objdump来反汇编可执行文件,但其中有很多内容。特别是,查看文本部分有点无用,因为它包含很多我目前不感兴趣的 C 运行时初始化代码。因此,让我们在转储中查找do_stuff :

080483e4 <do_stuff>: 80483e4: 55 推 %ebp 80483e5: 89 e5 mov %esp,%ebp 80483e7: 83 ec 18 子 $0x18,%esp 80483ea: c7 04 24 f0 84 04 08 movl $0x80484f0,(%esp) 80483f1: e8 22 ff ff ff 呼叫 8048318 <puts@plt> 80483f6:c9离开 80483f7:c3 ret好吧,我们将断点放置在 0x080483e4 处,这是do_stuff的第一条指令。此外,由于该函数是在循环中调用的,因此我们希望一直在断点处停止,直到循环结束。我们将使用debuglib库来简化此操作。这是完整的调试器功能:

无效 run_debugger(pid_t child_pid){ procmsg( “调试器已启动\\n” ); /* 等待子进程在执行第一条指令时停止 */ wait( 0 ); procmsg( \”子进程现在的 EIP = 0x%08x\\n\” , get_child_eip(child_pid)); /* 创建断点并运行到它*/ debug_breakpoint* bp = create_breakpoint(child_pid, ( void *) 0x080483e4 ); procmsg( \”已创建断点\\n\” ); ptrace(PTRACE_CONT, child_pid, 0 , 0 ); 等待(0); /* 只要子进程没有退出就循环 */ while ( 1 ) { /* 子进程在断点处停止。恢复其 ** 执行,直到退出或 再次遇到 ** 断点。 */ procmsg( \”子进程在断点处停止。EIP = 0x%08X\\n\” , get_child_eip(child_pid)); procmsg( “正在恢复\\n” ); int rc =resume_from_breakpoint(child_pid, bp); 如果(rc== 0){ procmsg( \”子进程退出\\n\” ); 打破; } 否则 if (rc == 1 ) { 继续; } 否则{ procmsg( “意外:%d\\n”,rc); 打破; } } cleanup_breakpoint(bp);}我们不必亲自修改 EIP 和目标进程的内存空间,而只需使用create_breakpoint、resume_from_breakpoint和cleanup_breakpoint。让我们看看跟踪上面显示的简单 C 代码时会打印什么:

$ bp_use_lib traced_c_loop[13363] 调试器已启动[13364] 目标开始。将运行“traced_c_loop”[13363] 孩子现在在 EIP = 0x00a37850[13363] 断点已创建[13363] 孩子停在断点处。电子IP = 0x080483E5[13363] 恢复你好,[13363] 孩子停在断点处。电子IP = 0x080483E5[13363] 恢复你好,[13363] 孩子停在断点处。电子IP = 0x080483E5[13363] 恢复你好,[13363] 孩子停在断点处。电子IP = 0x080483E5[13363] 恢复你好,世界![13363] 孩子退出了如预期的那样!

代码

这是这部分的完整源代码文件。在档案中您会发现:

debuglib.h 和 debuglib.c – 用于封装调试器的一些内部工作的简单库bp_use_lib.c – 将debuglib用于其大部分代码,如用于跟踪 C 程序中的循环的第二个代码示例中所示。我们已经介绍了如何在调试器中实现断点。虽然不同操作系统的实现细节有所不同,但当您使用 x86 时,它基本上都是同一主题的变体 – 将int 3替换为我们希望进程停止的指令。

也就是说,我确信有些读者,就像我一样,对于指定要中断的原始内存地址不会感到兴奋。我们想说“在do_stuff上中断”,甚至“在do_stuff中的这一行上中断”并让调试器执行此操作。

3.3调试信息

现代编译器可以很好地将高级代码(具有良好的缩进和嵌套控制结构以及任意类型的变量)转换为一大堆称为机器代码的位,其唯一目的是在计算机上尽可能快地运行目标CPU。大多数 C 代码行都会被转换成多个机器代码指令。变量被塞到各处——堆栈中、寄存器中,或者完全优化掉。结构和对象甚至不存在于生成的代码中——它们只是一个抽象,被转换为硬编码的偏移量到内存缓冲区中。

那么,当您要求调试器在某个函数的入口处中断时,调试器如何知道在哪里停止呢?当您向它询问变量的值时,它如何设法找到要显示的内容?答案是——调试信息。

ELF中的矮人

ELF 文件中的调试部分

寻找函数

调试时我们要做的最基本的事情之一就是在某个函数处放置断点,期望调试器在其入口处中断。为了能够执行此功能,调试器必须在高级代码中的函数名称与机器代码中该函数的指令开始的地址之间具有某种映射。

可以通过查看.debug_info部分从 DWARF 获取此信息。在我们进一步讨论之前,先介绍一些背景知识。 DWARF 中的基本描述实体称为调试信息条目 (DIE)。每个 DIE 都有一个标签 – 它的类型和一组属性。 DIE 通过兄弟链接和子链接相互链接,并且属性值可以指向其他 DIE。

让我们运行:

objdump –dwarf=info tracedprog2输出相当长,对于这个例子,我们只关注这些行:

<1><71>:缩写编号:5(DW_TAG_子程序) <72> DW_AT_外部:1 <73> DW_AT_name : (…): do_stuff <77> DW_AT_decl_file:1 <78> DW_AT_decl_line:4 <79> DW_AT_原型:1 <7a> DW_AT_low_pc:0x8048604 <7e> DW_AT_high_pc:0x804863e <82> DW_AT_frame_base : 0x0(位置列表) <86> DW_AT_sibling:<0xb3><1><b3>:缩写编号:9(DW_TAG_子程序) <b4> DW_AT_external:1 <b5> DW_AT_name : (…): 主要 <b9> DW_AT_decl_file:1 <ba> DW_AT_decl_line : 14 <bb> DW_AT_type : <0x4b> <bf> DW_AT_low_pc:0x804863e <c3> DW_AT_high_pc:0x804865a <c7> DW_AT_frame_base : 0x2c(位置列表)有两个条目(DIE)标记为DW_TAG_subprogram,这是 DWARF 行话中的一个函数。请注意,有一个do_stuff条目和一个main条目。有几个有趣的属性,但我们感兴趣的是DW_AT_low_pc。这是函数开始处的程序计数器( x86 中的EIP)值。请注意,do_stuff的值为0x8048604。现在让我们通过运行objdump-d来看看该地址在可执行文件的反汇编中是什么:

08048604 <do_stuff>: 8048604:55推ebp 8048605: 89 e5 mov ebp,esp 8048607: 83 ec 28 子 esp,0x28 804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 804860d: 83 c0 02 添加 eax,0x2 8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax 8048613: c7 45 (…) mov DWORD PTR [ebp-0x10],0x0 804861a: eb 18 jmp 8048634 <do_stuff+0x30> 804861c: b8 20 (…) mov eax,0x8048720 8048621:8b 55 f0 mov edx,DWORD PTR [ebp-0x10] 8048624: 89 54 24 04 mov DWORD PTR [esp+0x4],edx 8048628: 89 04 24 mov DWORD PTR [esp],eax 804862b: e8 04 (…) 调用 8048534 <printf@plt> 8048630: 83 45 f0 01 添加 DWORD PTR [ebp-0x10],0x1 8048634:8b 45 f0 mov eax,DWORD PTR [ebp-0x10] 8048637:3b 45 f4 cmp eax,DWORD PTR [ebp-0xc] 804863a: 7c e0 jl 804861c <do_stuff+0x18> 804863c:c9 离开 804863d:c3 右事实上,0x8048604是do_stuff的开头,因此调试器可以在函数及其在可执行文件中的位置之间建立映射。

寻找变量

了解GDB调试的核心:分析PTRACE的原理及其应用程序方案!

假设我们确实停在do_stuff内的断点处。我们想让调试器向我们显示my_local变量的值。它怎么知道在哪里可以找到它?事实证明,这比查找函数要棘手得多。变量可以位于全局存储中、堆栈中,甚至寄存器中。此外,具有相同名称的变量在不同的词法作用域中可以具有不同的值。调试信息必须能够反映所有这些变化,DWARF 确实做到了。我不会涵盖所有可能性,但作为示例,我将演示调试器如何在do_stuff中找到my_local。让我们从.debug_info开始,再次查看do_stuff的条目,这次还查看它的几个子条目:

<1><71>:缩写编号:5(DW_TAG_子程序) <72> DW_AT_外部:1 <73> DW_AT_name : (…): do_stuff <77> DW_AT_decl_file:1 <78> DW_AT_decl_line:4 <79> DW_AT_原型:1 <7a> DW_AT_low_pc:0x8048604 <7e> DW_AT_high_pc:0x804863e <82> DW_AT_frame_base : 0x0(位置列表) <86> DW_AT_sibling:<0xb3> <2><8a>:缩写编号:6(DW_TAG_formal_parameter) <8b> DW_AT_name : (…): my_arg <8f> DW_AT_decl_file:1 <90> DW_AT_decl_line:4 <91> DW_AT_类型:<0x4b> <95> DW_AT_位置:(…)(DW_OP_fbreg:0) <2><98>:缩写编号:7 (DW_TAG_variable) <99> DW_AT_name : (…): my_local <9d> DW_AT_decl_file:1 <9e> DW_AT_decl_line : 6 <9f> DW_AT_type:<0x4b> <a3> DW_AT_location : (…) (DW_OP_fbreg: -20)<2><a6>:缩写编号:8 (DW_TAG_variable) <a7> DW_AT_名称:i <a9> DW_AT_decl_file:1 <aa> DW_AT_decl_line : 7 <ab> DW_AT_type : <0x4b> <af> DW_AT_location : (…) (DW_OP_fbreg: -24)请注意每个条目中尖括号内的第一个数字。这是嵌套级别 – 在此示例中,带有<2> 的条目是带有<1>的条目的子项。所以我们知道变量my_local(由DW_TAG_variable标签标记)是do_stuff函数的子函数。调试器还对变量的类型感兴趣,以便能够正确显示它。在my_local的情况下,类型指向另一个 DIE – <0x4b>。如果我们在objdump的输出中查找它,我们会看到它是一个带符号的 4 字节整数。

为了在执行进程的内存映像中实际定位变量,调试器将查看DW_AT_location属性。对于my_local,它显示DW_OP_fbreg: -20。这意味着该变量存储在距其包含函数的DW_AT_frame_base属性的偏移量 -20 处 – 这是该函数的框架的基础。

do_stuff的DW_AT_frame_base属性的值为0x0 (位置列表),这意味着该值实际上必须在位置列表部分中查找。我们来看一下:

$ objdump –dwarf=loc Tracedprog2tracedprog2:文件格式 elf32-i386.debug_loc 部分的内容: 偏移开始结束表达式 00000000 08048604 08048605 (DW_OP_breg4: 4 ) 00000000 08048605 08048607 (DW_OP_breg4: 8 ) 00000000 08048607 0804863e (DW_OP_breg5:8) 00000000 <列表结束> 0000002c 0804863e 0804863f (DW_OP_breg4: 4 ) 0000002c 0804863f 08048641(DW_OP_breg4:8) 0000002c 08048641 0804865a(DW_OP_breg5:8) 0000002c <列表结束>我们感兴趣的位置信息是第一个[4]。对于调试器所在的每个地址,它指定当前帧基址,从该基址计算变量的偏移量作为寄存器的偏移量。对于 x86,bpreg4指esp,bpreg5指ebp。

再次查看do_stuff的前几条指令是有教育意义的:

08048604 <do_stuff>: 8048604:55推ebp 8048605: 89 e5 mov ebp,esp 8048607: 83 ec 28 子 esp,0x28 804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8] 804860d: 83 c0 02 添加 eax,0x2 8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax请注意,ebp仅在执行第二条指令后才变得相关,实际上,对于前两个地址,基址是根据上面列出的位置信息中的esp计算的。一旦ebp有效,就可以方便地计算相对于它的偏移量,因为它保持不变,而esp随着数据从堆栈中压入和弹出而不断移动。那么my_local给我们带来了什么呢?我们只对0x8048610指令之后的值感兴趣(在eax中计算后,它的值被放置在内存中),因此调试器将使用DW_OP_breg5: 8帧基数来查找它。现在是时候回顾一下my_local的DW_AT_location属性 为DW_OP_fbreg: -20。让我们计算一下:距框架基数 -20,即ebp + 8。我们得到ebp – 12。现在再次查看反汇编并注意数据从eax移至何处 – 事实上,ebp – 12是my_local的存储位置。

查找行号

当我们谈到在调试信息中查找函数时,我有点作弊。当我们调试 C 源代码并在函数中放置断点时,我们通常对第一条机器代码指令不感兴趣[5]。我们真正感兴趣的是该函数的第一行C 代码行。这就是为什么 DWARF 对 C 源代码中的行和可执行文件中的机器代码地址之间的完整映射进行编码。此信息包含在.debug_line部分中,可以以可读形式提取,如下所示:

$ objdump –dwarf=decodedline tracedprog2tracedprog2:文件格式 elf32-i386.debug_line 部分调试内容的解码转储:CU:/home/eliben/eli/eliben-code/debugger/tracedprog2.c:文件名 行号 起始地址跟踪prog2.c 5 0x8048604跟踪prog2.c 6 0x804860a跟踪prog2.c 9 0x8048613跟踪prog2.c 10 0x804861c跟踪prog2.c 9 0x8048630追踪prog2.c 11 0x804863c跟踪prog2.c 15 0x804863e追踪prog2.c 16 0x8048647追踪prog2.c 17 0x8048653追踪prog2.c 18 0x8048658不难看出这些信息、C 源代码和反汇编转储之间的对应关系。第 5 行指向do_stuff – 0x8040604的入口点。下一行 6 是调试器在被要求中断do_stuff时真正应该停止的地方,它指向0x804860a,即函数序言后面的位置。此线路信息可以轻松实现线路和地址之间的双向映射:

当要求在某一行放置断点时,调试器将使用它来查找应该将陷阱放置在哪个地址(还记得上一篇文章中我们的朋友int 3吗?)当指令导致分段错误时,调试器将使用它来查找发生该错误的源代码行。libdwarf – 以编程方式使用 DWARF

使用命令行工具访问 DWARF 信息虽然有用,但并不完全令人满意。作为程序员,我们想知道如何编写可以读取格式并从中提取我们需要的内容的实际代码。

当然,一种方法是获取 DWARF 规范并开始破解。现在,还记得每个人都说你永远不应该手动解析 HTML 而应该使用库吗?嗯,对于 DWARF 来说情况更糟。 DWARF比 HTML 复杂得多。我在这里展示的只是冰山一角,让事情变得更加困难的是,大部分信息都以非常紧凑和压缩的方式编码在实际的目标文件中[6]。

因此,我们将采取另一条路并使用库来与 DWARF 一起工作。我知道有两个主要的库(加上一些不太完整的库):

libdwarf – 与其老大哥libelf一起用于 Solaris 和 FreeBSD 操作系统上的工具。我选择libdwarf而不是 BFD,因为它对我来说似乎不那么神秘,而且它的许可证更自由(LGPL与GPL)。

由于libdwarf本身相当复杂,因此需要大量代码来操作。我不会在这里展示所有这些代码,但您可以自己下载并运行它。要编译此文件,您需要安装libelf和libdwarf,并将-lelf和-ldwarf标志传递给链接器。

调试信息原则上是一个简单的概念。实现细节可能很复杂,但最终重要的是我们现在知道调试器如何找到它所需的有关编译其跟踪的可执行文件的原始源代码的信息。有了这些信息,调试器就在用户的世界(根据代码行和数据结构进行思考)和可执行文件的世界(只是寄存器和内存中的一堆机器代码指令和数据)之间建立了桥梁。

四、ptrace实现原理

sys_ptrace() 函数的主体是一个 switch 语句,会传入的 request 参数不同进行不同的操作,如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data){ struct task_struct *child; struct user *dummy = NULL; int i, ret; … read_lock(&tasklist_lock); child = find_task_by_pid(pid); // 获取 pid 对应的进程 task_struct 对象 if (child) get_task_struct(child); read_unlock(&tasklist_lock); if (!child) goto out; if (request == PTRACE_ATTACH) { ret = ptrace_attach(child); goto out_tsk; } … switch (request) { case PTRACE_PEEKTEXT: case PTRACE_PEEKDATA: … case PTRACE_PEEKUSR: … case PTRACE_POKETEXT: case PTRACE_POKEDATA: … case PTRACE_POKEUSR: … case PTRACE_SYSCALL: case PTRACE_CONT: … case PTRACE_KILL: … case PTRACE_SINGLESTEP: … case PTRACE_DETACH: … }out_tsk: free_task_struct(child);out: unlock_kernel(); return ret;}从上面的代码可以看出,sys_ptrace() 函数首先根据进程的 pid 获取到进程的 task_struct 对象。然后根据传入不同的 request 参数在 switch 语句中进行不同的操作。

进入被追踪模式(PTRACE_TRACEME操作)

当要调试一个进程时,需要使进程进入被追踪模式,怎么使进程进入被追踪模式呢?有两个方法:

被调试的进程调用 ptrace(PTRACE_TRACEME, …) 来使自己进入被追踪模式。调试进程(如GDB)调用 ptrace(PTRACE_ATTACH, pid, …) 来使指定的进程进入被追踪模式。第一种方式是进程自己主动进入被追踪模式,而第二种是进程被动进入被追踪模式。

被调试的进程必须进入被追踪模式才能进行调试,因为 Linux 会对被追踪的进程进行一些特殊的处理。下面我们主要介绍第一种进入被追踪模式的实现,就是 PTRACE_TRACEME 的操作过程,代码如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data){ … if (request == PTRACE_TRACEME) { if (current->ptrace & PT_PTRACED) goto out; current->ptrace |= PT_PTRACED; // 标志 PTRACE 状态 ret = 0; goto out; } …}从上面的代码可以发现,ptrace() 对 PTRACE_TRACEME 的处理就是把当前进程标志为 PTRACE 状态。

当然事情不会这么简单,因为当一个进程被标记为 PTRACE 状态后,当调用 exec() 函数去执行一个外部程序时,将会暂停当前进程的运行,并且发送一个 SIGCHLD 给父进程。父进程接收到 SIGCHLD 信号后就可以对被调试的进程进行调试。

我们来看看 exec() 函数是怎样实现上述功能的,exec() 函数的执行过程为 sys_execve() -> do_execve() -> load_elf_binary():

static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs){ … if (current->ptrace & PT_PTRACED) send_sig(SIGTRAP, current, 0); …}从上面代码可以看出,当进程被标记为 PTRACE 状态时,执行 exec() 函数后便会发送一个 SIGTRAP 的信号给当前进程。

我们再来看看,进程是怎么处理 SIGTRAP 信号的。信号是通过 do_signal() 函数进行处理的,而对 SIGTRAP 信号的处理逻辑如下:

nt do_signal(struct pt_regs *regs, sigset_t *oldset) { for (;;) { unsigned long signr; spin_lock_irq(¤t->sigmask_lock); signr = dequeue_signal(¤t->blocked, &info); spin_unlock_irq(¤t->sigmask_lock); // 如果进程被标记为 PTRACE 状态 if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) { /* 让调试器运行 */ current->exit_code = signr; current->state = TASK_STOPPED; // 让自己进入停止运行状态 notify_parent(current, SIGCHLD); // 发送 SIGCHLD 信号给父进程 schedule(); // 让出CPU的执行权限 … } }}里面的代码主要做了3件事:

如果当前进程被标记为 PTRACE 状态,那么就使自己进入停止运行状态。发送 SIGCHLD 信号给父进程。让出 CPU 的执行权限,使 CPU 执行其他进程。执行以上过程后,被追踪进程便进入了调试模式,过程如下图:

父进程(调试进程)接收到 SIGCHLD 信号后,表示被调试进程已经标记为被追踪状态并且停止运行,那么调试进程就可以开始进行调试了。

获取被调试进程的内存数据(PTRACE_PEEKTEXT / PTRACE_PEEKDATA)

调试进程(如GDB)可以通过调用 ptrace(PTRACE_PEEKDATA, pid, addr, data) 来获取被调试进程 addr 处虚拟内存地址的数据,但每次只能读取一个大小为 4字节的数据。

我们来看看 ptrace() 对 PTRACE_PEEKDATA 操作的处理过程,代码如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data){ … switch (request) { case PTRACE_PEEKTEXT: case PTRACE_PEEKDATA: { unsigned long tmp; int copied; copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0); ret = -EIO; if (copied != sizeof(tmp)) break; ret = put_user(tmp, (unsigned long *)data); break; } …}从上面代码可以看出,对 PTRACE_PEEKTEXT 和 PTRACE_PEEKDATA 的处理是相同的,主要是通过调用 access_process_vm() 函数来读取被调试进程 addr 处的虚拟内存地址的数据。

access_process_vm() 函数的实现主要涉及到 内存管理 相关的知识,可以参考我以前对内存管理分析的文章,这里主要大概说明一下 access_process_vm() 的原理。

access_process_vm() 函数的实现这里就不分析了,有兴趣的读者可以参考我之前对内存管理分析的文章自行进行分析。

单步调试模式(PTRACE_SINGLESTEP)

单步调试是一个比较有趣的功能,当把被调试进程设置为单步调试模式后,被调试进程没执行一条CPU指令都会停止执行,并且向父进程(调试进程)发送一个 SIGCHLD 信号。

我们来看看 ptrace() 函数对 PTRACE_SINGLESTEP 操作的处理过程,代码如下:

asmlinkage int sys_ptrace(long request, long pid, long addr, long data){ … switch (request) { case PTRACE_SINGLESTEP: { /* set the trap flag. */ long tmp; … tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG; put_stack_long(child, EFL_OFFSET, tmp); child->exit_code = data; /* give it a chance to run. */ wake_up_process(child); ret = 0; break; } …}要把被调试的进程设置为单步调试模式,英特尔的 X86 CPU 提供了一个硬件的机制,就是通过把 eflags 寄存器的 Trap Flag 设置为1即可。

当把 eflags 寄存器的 Trap Flag 设置为1后,CPU 每执行一条指令便会产生一个异常,然后会触发 Linux 的异常处理,Linux 便会发送一个 SIGTRAP 信号给被调试的进程。eflags 寄存器的各个标志如下图:

从上图可知,eflags 寄存器的第8位就是单步调试模式的标志。

所以 ptrace() 函数的以下2行代码就是设置 eflags 进程的单步调试标志:

tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;put_stack_long(child, EFL_OFFSET, tmp);而 get_stack_long(proccess, offset) 函数用于获取进程栈 offset 处的值,而 EFL_OFFSET 偏移量就是 eflags 寄存器的值。所以上面两行代码的意思就是:

获取进程的 eflags 寄存器的值,并且设置 Trap Flag 标志。把新的值设置到进程的 eflags 寄存器中。设置完 eflags 寄存器的值后,就调用 wake_up_process() 函数把被调试的进程唤醒,让其进入运行状态。单步调试过程如下图:

用户评论


北朽暖栀

终于找到一篇讲ptrace的博文了!之前看了很多资料都云里雾里,这篇把关键点总结得清清楚楚,而且还结合了gdb的实际使用场景,真不错!

    有16位网友表示赞同!


盲从于你

这篇文章干货满满啊,作者对GDB和ptrace的理解非常深入,看得我受益匪浅。之前感觉调试很抽象,现在终于明白它是怎么运作的了!

    有14位网友表示赞同!


愁杀

说的太对了!程序员遇到崩溃问题的时候,ptrace和gdb真是救星啊。这篇文章让我明白了它的原理,以后更有信心解决类似的问题了!

    有6位网友表示赞同!


一个人的荒凉

我一直以为ptrace这个东西很复杂,这篇博文讲得通俗易懂,非常感谢作者的用心! 以后调试程序的时候可以参照一下这些方法了。

    有9位网友表示赞同!


话少情在

GDB调试一直是我头疼的事情,看了这篇文章后觉得有了一种新的思路。不过还是希望能提供一些更具体的例子,帮助我们更好地理解ptrace在实际操作中的应用场景。

    有10位网友表示赞同!


爱情的过失

讲得真好,把ptrace的功能和局限性都分析得很到位,让我对这方面有了更全面了解。 还有个问题想问下:当gdb使用ptrace调用内核函数的时候,是否会影响系统的性能?

    有20位网友表示赞同!


打个酱油卖个萌

我觉得文章写的有点过于理论化了,缺乏实际操作的指导步骤。对于初学者来说,能提供一些简单的案例代码和调试流程说明会更有帮助!

    有6位网友表示赞同!


羁绊你

GDB调试我经常用啊!只是对ptrace原理不太了解,这篇博文算是帮我填补了知识的漏洞。希望以后还能看到更多针对实际问题的应用实例。

    有14位网友表示赞同!


别伤我i

我感觉这篇文章更适合有一定C语言基础的读者理解,对于完全初级小白来说可能会比较难理解ptrace的细节机制。

    有9位网友表示赞同!


花海

很期待作者能够继续更新一些关于GDB 和 ptrace 的内容,比如一些高级调试技巧或者应用场景,这样会更有价值!

    有17位网友表示赞同!


雨后彩虹

看懂了,明白了ptrace的基本原理。但是对于如何实际运用在特定的场景中,文章讲解得还是比较浅显。希望能看到更多具体的应用例子和代码实现。

    有8位网友表示赞同!


看我发功喷飞你

终于有一个解释ptrace的文章!以前我总是困惑它的内部机制到底是如何工作的,这篇博文解开了我心中的疑惑,真是太感谢作者了!

    有14位网友表示赞同!


良人凉人

文章分析得十分透彻,把ptrace的功能和作用范围都说明得很清楚。 希望能提供更多基于 Linux 内核的调试案例, 比如:如何使用 ptrace 追踪进程执行路径, 如何利用 ptrace 读取进程内存数据等。

    有15位网友表示赞同!


涐们的幸福像流星丶

这篇文章让我了解到ptrace不仅仅是调试工具,它可以应用于很多场景,例如系统调用分析、程序监控等等。 对此领域很有启发!

    有16位网友表示赞同!


迁心

文章写的不错,但个人觉得对linux内核模块部分的介绍可以多一些。 毕竟很多时候我们需要结合内核驱动进行开发和调试。

    有7位网友表示赞同!


代价是折磨╳

GDB调试确实很强大,了解了ptrace原理就更有信心去探索更深层的调试功能了! 文章提供的讲解我很认可,希望能看到更多实际操作案例!

    有16位网友表示赞同!

上一篇
下一篇

为您推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

联系我们

联系我们

0898-88881688

在线咨询: QQ交谈

邮箱: email@zhutibaba.com

工作时间:周一至周五,9:00-17:30,节假日休息
关注微信
微信扫一扫关注我们

微信扫一扫关注我们

手机访问
手机扫一扫打开网站

手机扫一扫打开网站

返回顶部