学习内容调试器功能要求进程信号ptrace系统调用
简介ptrace用法 调试软件的实现
调试器实现框架获取调试进程的信号获取调试进程CPU寄存器和内存的内容设置断点
原理插入陷入指令的实现恢复指令的实现 遇到的问题调试器运行截图调试器源码
学习内容- ptrace系统调用使用方法软件调试器的基本原理
- 获取被调试进程的信号在被调试程序中加入断点被调试程序进入断后能够查看被调试进程任意内存区域的内容和任意CPU寄存器的内容进程能够恢复继续运行
信号是一种软中断,用于进程之间的一种异步通信方式。Linux系统中定义了64种信号,可以大致分成可靠信号和不可靠信号。前32种信号是不可靠信号,后32种是可靠信号。
在Linux终端,可以采用命令kill -l来查看信号。
信号可以通过硬件方式(键盘键入Ctrl+C)和软件方式(kill_proc_info)产生。内核处理signial是在当前进程的上下文,所以此时的进程必然是Running状态。当进程唤醒或者调度后获取CPU,则会从内核态转到用户态时检测是否有signal等待处理,处理完,进程会把相应的未决信号从链表中去掉。
ptrace是Linux系统提供的系统调用,其定义在
ptrace函数有四个参数,调用方法如下:
long ptrace(enum _ptrace_request request, pid_t pid, void* addr, void *data)
request:PTRACE_*常量中的一个,其取值的不同决定ptrace函数的不同功能pid:被跟踪的进程的进程idaddr:传送的地址参数data:传送的数据参数
第一个参数是枚举变量,可以取以下数值:
PTRACE_TRACEME, 本进程被其父进程所跟踪。其父进程应该希望跟踪子进程 PTRACE_PEEKTEXT, 从内存地址中读取一个字节,内存地址由addr给出 PTRACE_PEEKDATA, 同上 PTRACE_PEEKUSER, 可以检查用户态内存区域(USER area),从USER区域中读取一个字节,偏移量为addr PTRACE_POKETEXT, 往内存地址中写入一个字节。内存地址由addr给出 PTRACE_POKEDATA, 往内存地址中写入一个字节。内存地址由addr给出 PTRACE_POKEUSER, 往USER区域中写入一个字节,偏移量为addr PTRACE_GETREGS, 读取寄存器 PTRACE_GETFPREGS, 读取浮点寄存器 PTRACE_SETREGS, 设置寄存器 PTRACE_SETFPREGS, 设置浮点寄存器 PTRACE_CONT, 重新运行 PTRACE_SYSCALL, 重新运行 PTRACE_SINGLESTEP, 设置单步执行标志 PTRACE_ATTACH,追踪指定pid的进程 PTRACE_DETACH, 结束追踪调试软件的实现 调试器实现框架
ptrace系统调用代码书写模板如下代码所示:
#include#include #include #include #include int main(int argc, char **argv){ pid_t child_pid; child_pid=fork(); if(child_pid==0){//子进程执行的代码 if(ptrace(PTRACE_TRACEME,0,0,0)<0){ //允许父进程跟踪 perror("ptrace"); } execl(...); //让子进程执行指定的程序 } else if(child_pid>0){//父进程执行 int wait_status; wait(&wait_status);//子进程第一次调用execl族函数时,父进程在此等待,并获取信号 ptrace(PTRACE_CONT,child_pid,0,0);// 让子进程继续执行指定程序 while(true){ wait(&wait_status);//截取信号 if(!WIFSTOPPED(wait_status)) break;//子进程结束,打破循环 ...//在子进程运行过程中你想执行的代码 ptrace(PTRACE_CONT,child_pid,0,0); //让子进程继续执行 } } else{ //fork error } return 0; }
代码中,父函数通过fork()函数创建子进程。子进程调用ptrace(PTRACE_TRACEME,0,0,0),代表子进程请求内核让父进程跟踪自己,任何传给该进程的信号(除了SIGKILL)都将通过wait()方法阻塞该进程并通知父进程。此外,该进程之后只要调用exec()族函数都将导致SIGTRAP信号发送到父进程上,这样,父进程在新的程序执行之前会取得控制权。
从上文描述我们知道,只要子进程因为发送信号而停止,父进程通过wait()得到的wait_status就是子进程发送的信号,同时WIFSTOPPED(wait_status)返回true。由此,子进程开始运行指定程序后,父进程进入循环,等待子进程通过发送信号而进入阻塞态。当子进程发送信号并进入阻塞态,父进程获取子进程发送的信号wait_status,而后父进程执行需要执行的操作(例如修改子进程内存内容或读取寄存器信息),然后让子进程继续运行。
因此,调试器可以基于上述模板进行书写,在while循环之前给子进程设置断点,使子进程运行到断点处给父进程发送信号,父进程利用ptrace的’PEEK and POKE’功能在while循环中执行子进程断后所需操作,例如获取子进程的信号、内存数据和CPU寄存器数据等等。
上文提到,除了SIGKILL信号以外,任何传递给子进程的信号,父进程将通过wait()方法获取。实际上,从wait(&wait_status)得到的wait_status变量是一个整形数,其值描述的是子进程的状态,而非信号值。经过查阅资料,我发现WSTOPSIG函数能将状态值映射成范围为[1,64]的信号值,再通过
void read_signal(int status){ //
int child_signal;
child_signal = WSTOPSIG(status); //WSTOPSIG 如果WIFSTOPPED 非零 则该函数返回信号值
printf("Recieve Signal:%sn",sys_siglist[child_signal]);
}
函数的输入是父进程通过wait(&wait_status)获得的子进程状态值,即wait_status变量。
其实子进程状态还可以通过ptrace系统调用得到,即:ptrace(PTRACE_GETSIGINFO,pid,NULL,&signal);但是我在调用这个方法的时候发现,在调用完后确实能通过signal变量得到子进程的信号值,但作为参数的pid变量却被置0了,程序id丢失影响到了子进程继续运行,我暂时还不知道是什么原因。
经过查阅资料,我了解到子程序断后读取子程序的寄存器和内存的方法是十分相似的。
我们可以利用头文件
读取子进程的内存空间的方法与读取寄存器的方法相似,调用函数ptrace(PTRACE_PEEKTEXT, pid, addr, data),这个函数往子进程的内存空间中写入一字节的内容,其中,pid表示被跟踪的子进程,内存地址由addr给出,data为用户变量地址用于返回读到的数据,相同功能的参数还有PTRACE_PEEKDATA。往内存中写入一个字节由函数PTRACE_POKETEXT和PTRACE_POKEDATA实现,内存地址由addr给出,data为要写入的数据
由此,我通过下述代码获取子进程的寄存器状态。
void read_registers(pid_t pid){
struct user_regs_struct regs;//结构体用于读取cpu寄存器的内容
if (ptrace(PTRACE_GETREGS,pid,NULL,®s) < 0) {
perror("get regs err");
}
else{
printf("Content of registers:n");
printf("rax = %llxn",regs.rax);
printf("rip = %llxn",regs.rip);
printf("rbp = %llxn",regs.rbp);
printf("rsp = %llxn",regs.rsp);
printf("rbx = %llxn",regs.rbx);
///...更多寄存器可以继续读取
}
return;
}
为了方便举例,我们读取寄存器的结果,选取RIP寄存器,里面存着子进程即将执行的指令所在的地址。我们用ptrace把指令读出来
unsigned short int instr=ptrace(PTRACE_PEEKTEXT,child_pid,regs.rip,NULL);
printf("READ from memory: %xn",instr);
设置断点
在常用的软件调试器中,加入断点方法有按行加入断点和按函数名加入断点。按行加断点的方法有些麻烦,可以列入后期优化任务。本节主要阐述实现断点的原理和按函数名实现断点的方法。
原理本文实现断点的思路如下:
- 确定断点地址。父进程通过ptrace调用取得CPU的控制权。父进程读取并保存断点地址对应指令,并用中断指令的机器码0xCC替换。子进程运行到断点处中断,父进程取得CPU控制权,执行读取寄存器数据等操作。父进程将子进程的rip寄存器内容移动至上条指令的地址,恢复原来的指令。
这是软中断的实现原理,难点在于第一步,我们如何得到想要中断代码行对应第一条指令的地址呢?按行来取得指令地址是比较复杂的,所以我们利用readelf工具或者gdb事先查阅需要中断的代码所对应的内存地址,并将其作为父进程的输入,从而通过上述步骤设置断点。
用于测试的子程序test.c如下所示:
#includeint main(){ int a=1; int b=2; printf("Hellon"); int c=6; int d=10; printf("word!n"); return 0; }
输入指令gcc -g -o test test.c编译生成可执行文件test,在该文件所在目录键入命令objdump -d test得到可执行文件装载指令地址,如下图所示:
执行函数unsigned long data =ptrace(PTRACE_PEEKDATA,child_pid,addr,NULL); 其中addr参数来自unsigned long addr=0x115a;该地址是main函数的第一条指令的地址。但是得到的输出是data=0xffffffffffff,也就是说,该函数并不能如我预期那样读出指定内存的数据。经过大量的技术博客查找,终于在stack overflow的一篇技术问答找到了答案。
原文:You have a position-independent executable. As such, ASLR makes it start at a random address in memory. Check the rip register during ptrace and dump the code from there, instead of from the address in your executable.
总结翻译:
- 我的编译器默认生成地址无关可执行文件,导致指令在装载进内存时,并不完全按照elf文件规定的地址进行装载;Linux内置ASLR机制,又称地址空间配置随机加载,是一种防范内存损坏漏洞被利用的计算机安全技术。
第一点使得装载指令的虚拟内存地址相对于objdump -d取读到的内存地址,多了一个偏移量。第二点则使得程序每次运行都从随机的内存地址开始加载。第一点比较好解决,把程序运行时的地址与objdump -d取读到的内存地址作差即可得到偏移量,要解决第二点比较困难,为了完成作业,我用指令sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space"关闭了ASLR机制。再通过测试代码我得到地址偏移量为0x555555554000,具体方法是编写一个程序输出其某个子函数的函数地址,再与objdump -d指令得到的函数地址作差。
解决了这两个问题,调用代码
unsigned long addr=0x555555554000+0x115a;
unsigned long data =ptrace(PTRACE_PEEKDATA,child_pid,addr,NULL);
printf("data = %lxn",data);
得到
data = 0x10ec8348e5894855
由于计算机采用小端存储数据,所以低地址的数据在数据的低位。至此,我成功读出0x115a到0x1161内存的数据。
应用开头提到的设置断点原理,假设我们选取地址0x1183作为中断地址,则应用下述代码可以在该地址处设置断点。
unsigned long long int addr=0x555555554000+0x1183;//得到虚拟地址 unsigned long int data =ptrace(PTRACE_PEEKDATA,child_pid,addr,NULL); //从地址读取8个字节 unsigned long data_with_trap = (data & 0xFFFFFFFFFFFFFF00) | 0xCC; //把第一个字节替换成陷入指令的机器码0xCC ptrace(PTRACE_POKEDATA,child_pid,addr,data_with_trap); //写入新指令
当程序运行到该地址,子程序由于运行陷入指令而中断,把CPU控制权交还给父进程,父进程进行必要操作后,通过下述操作恢复子进程原来的指令,然后令指令寄存器RIP的值减1,子进程重新运行时将运行它本应执行的指令,然后继续正常运行。
struct user_regs_struct regs; ptrace(PTRACE_GETREGS,child_pid,NULL,®s);//读取当前寄存器状态 regs.rip-=1;//指令寄存器回退一个字节 ptrace(PTRACE_SETREGS,child_pid,NULL,®s); //写入ip寄存器 ptrace(PTRACE_POKEDATA,child_pid,addr,data); //恢复指令 ptrace(PTRACE_CONT,child_pid,0,0); //让子进程继续执行
对于测试的子程序而言0x1183刚好介于输出"hello"和"world"之间,我在中断时让父进程输出一句话“这里是父进程”,运行代码之后,测试结果如下所示:
至此,断点已经成功实现。对框架稍加修改即可实现对子进程添加无数个断点。不足之处是用户必须事先对测试程序的可执行文件执行objdump -d指令,查看添加断点的代码所对应的地址。
断点是通过替换指令的方法实现的,断后操作执行完毕,需要在断点处恢复原来的指令才能让测试程序继续运行。因此,我需要存储断点处的地址和指令,并在断后操作结束时恢复断点处的指令。所以我构建了如下结构体:
typedef struct addr_list //建立顺序链表,里边的节点按照地址的大小排序。
{
unsigned long long int addr; //保存修改的地址
unsigned long instr_old;//保存原先的指令
struct addr_list * next; //下一个节点
} Addr_list,*Node; //设置队列存储断点的信息
我们使用软件调试器设置多个断点时,对断点地址的先后顺序没有要求,但是在调试程序时,地址小的指令总是比大地址先中断。根据这样的特性,我需要把上述保存断点信息的节点构造成顺序链表,按断点地址从小到大排序。顺序链表的插入和取出元素代码如下:
//断点顺序链表
Addr_list Head_list;
Node p_Head,p_Tail; //创建一个全局变量链表头
unsigned long long int offset=0x555555554000; //解决地址无关代码问题
void Enlist(Node NewNode){ //输入节点的地址,节点入表
Node probe=p_Head;
while(probe->next&&(NewNode->addr>probe->next->addr))
probe=probe->next; //找到比输入的addr大的节点的前一个位置
if(!probe->next){//如果走到链表尾,直接把节点加入尾表
p_Tail->next=NewNode;
p_Tail=NewNode;
p_Tail->next=NULL;
}
else{ //
NewNode->next=probe->next;
probe->next=NewNode;
}
return;
}
void Delist(Node trash){
Node probe=p_Head;
if(p_Head==p_Tail)
printf("Error: breakpoint list is empty!n");
while(probe->next!=trash)
probe=probe->next; //找到该节点的前一个节点
if(trash==p_Tail)
p_Tail=probe;
probe->next=probe->next->next;
free(trash);
return;
}
用户事先通过objdump -d查找设置断点的地址,将地址输调试器。调试器在调试程序运行之前,根据用户输入的地址插入断点,用户输入断点的顺序可以是随意的。调试其对每个断点生成对应的断点信息节点,断点之间按断点地址从小到大排列成顺序链表。当程序运行到断点处,程序中断,调试器执行断后操作,然后根据regs.rip-1得到断点地址,在顺序链表中查找其对应的节点,并恢复子程序的指令,让子程序继续运行。我称恢复指令的步骤为清理节点,具体代码如下:
void set_breakpoints(pid_t pid){ //设置断点
printf("please input the amount of breakpoints:n");
int count;
scanf("%d",&count); //输入断点个数
if(count<=0)
return;
int index=count;
while(index--){
Node new=(Node)malloc(sizeof(Addr_list)); //建立新的断点节点
printf("Please input the %dth beakpoint's address:",count-index);
unsigned long long int addr;
scanf("%llx",&addr); //读入断点地址
printf("got address: %llxn",addr);
new->addr=addr+offset; //保存断点在内存中的地址
new->next==NULL;
new->instr_old=ptrace(PTRACE_PEEKDATA,pid,new->addr,NULL);//读取指令
unsigned long data_with_trap = (new->instr_old & 0xFFFFFFFFFFFFFF00) | 0xCC;//把最低字节替换成陷入指令/
ptrace(PTRACE_POKEDATA,pid,new->addr,data_with_trap);
Enlist(new);//保存断点信息至顺序链表
}
}
void clean_beakpoints(pid_t pid){ //清除断点,恢复指令
if(p_Tail==p_Head)//没有断点了
return;
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS,pid,NULL,®s); //读取寄存器状态
regs.rip-=1; //此时rip寄存器里存着断点地址
Node probe=p_Head->next;//直接取表头的节点
ptrace(PTRACE_POKEDATA,pid,probe->addr,probe->instr_old); //恢复指令
ptrace(PTRACE_SETREGS,pid,NULL,®s);
Delist(probe); //该断点节点出表
return;
}
遇到的问题
- 运行gcc -o xxxx xxxx.c 之后报
错误形成的原因是因为内核结构的变化使得原先/usr/include/linux/user.h消失而是变为了/usr/include/sys/user.h
因此,调试的时候需要将 #include linux/user.h> 这句变为 #include
运行说明:
- 运行调试器,输入断点个数,然后输入对应个数的断点地址(地址可以通过objdump -d 。/test指令对调试程序的可执行文件进行反汇编读到)调试进行,子程序依次在断点处中断调试器分别输出中断信号类型,当前CPU寄存器地址(挑选了rax,rip,rbp,rsp和rbx进行输出),每次中断调试器都会请求用户输入一个内存地址,程序输出对应地址往后8字节的内容,也可以选择输入0而跳过读取内存的请求。
测试程序代码即内存分布如<插入陷入指令的实现>小节所示,以下是调试器运行截图:
#include#include #include #include #include #include #include #include #include #include typedef struct addr_list //建立顺序链表,里边的节点按照地址的大小排序。 { unsigned long long int addr; //保存修改的地址 unsigned long instr_old;//保存原先的指令 struct addr_list * next; //下一个节点 } Addr_list,*Node; //设置队列存储断点的信息 //断点顺序链表 Addr_list Head_list; Node p_Head,p_Tail; //创建一个全局变量链表头 unsigned long long int offset=0x555555554000; //解决地址无关代码问题 void Enlist(Node NewNode){ //输入节点的地址,节点入表 Node probe=p_Head; while(probe->next&&(NewNode->addr>probe->next->addr)) probe=probe->next; //找到比输入的addr大的节点的前一个位置 if(!probe->next){//如果走到链表尾,直接把节点加入尾表 p_Tail->next=NewNode; p_Tail=NewNode; p_Tail->next=NULL; } else{ // NewNode->next=probe->next; probe->next=NewNode; } return; } void Delist(Node trash){ Node probe=p_Head; if(p_Head==p_Tail) printf("Error: breakpoint list is empty!n"); while(probe->next!=trash) probe=probe->next; //找到该节点的前一个节点 if(trash==p_Tail) p_Tail=probe; probe->next=probe->next->next; free(trash); return; } void set_breakpoints(pid_t pid){ //设置断点 printf("please input the amount of breakpoints:"); int count; scanf("%d",&count); if(count<=0) return; int index=count; while(index--){ Node new=(Node)malloc(sizeof(Addr_list)); //建立新的节点 printf("Please input the %dth beakpoint's address:",count-index); unsigned long long int addr; scanf("%llx",&addr); printf("got address: %llxn",addr); new->addr=addr+offset; new->next==NULL; new->instr_old=ptrace(PTRACE_PEEKDATA,pid,new->addr,NULL); unsigned long data_with_trap = (new->instr_old & 0xFFFFFFFFFFFFFF00) | 0xCC;//把最低字节替换成陷入指令/ ptrace(PTRACE_POKEDATA,pid,new->addr,data_with_trap); Enlist(new); } } void clean_beakpoints(pid_t pid){ //清除断点,恢复指令 if(p_Tail==p_Head)//没有断点了 return; struct user_regs_struct regs; ptrace(PTRACE_GETREGS,pid,NULL,®s); //读取寄存器状态 regs.rip-=1; //此时rip寄存器里存着断点地址 Node probe=p_Head->next;//直取表头节点 ptrace(PTRACE_POKEDATA,pid,probe->addr,probe->instr_old); //恢复指令 ptrace(PTRACE_SETREGS,pid,NULL,®s); Delist(probe); //该断点节点出表 return; } void read_registers(pid_t pid){ struct user_regs_struct regs;//结构体用于读取cpu寄存器的内容 if (ptrace(PTRACE_GETREGS,pid,NULL,®s) < 0) { perror("get regs err"); } else{ printf("Content of registers:n"); printf("rax = %llxn",regs.rax); printf("rip = %llxn",regs.rip); printf("rbp = %llxn",regs.rbp); printf("rsp = %llxn",regs.rsp); printf("rbx = %llxn",regs.rbx); // ...更多寄存器可以继续读取 } return ; } void read_signal(int status){ int child_signal; child_signal = WSTOPSIG(status); //WSTOPSIG 如果WIFSTOPPED 非零 则该函数返回信号值 printf("Recieve Signal:%sn",sys_siglist[child_signal]); } void read_memory(pid_t pid){ unsigned long long int addr=0; printf("Please input address, or input 0 to cancel:"); scanf("%llx",&addr); if(addr==0) return; long data=ptrace(PTRACE_PEEKTEXT,pid,addr+offset,NULL); printf("Read from memory at %llx: %lxn",addr,data); return; } int main(int argc, char **argv){ p_Head=&Head_list; p_Tail=&Head_list; p_Head->next=NULL; p_Head->addr=0x0; //初始化断点列表 pid_t child_pid; child_pid=fork(); if(child_pid==0){//子进程执行的代码 if(ptrace(PTRACE_TRACEME,0,0,0)<0){ //允许父进程跟踪 perror("ptrace"); } execl("./test",argv[0],(char*)0); //让子进程执行指定的程序 这里参数没弄明白 } else if(child_pid>0){//父进程执行 child_pid是子进程的id int wait_status; wait(&wait_status);//子进程第一次调用execl族函数时,父进程在此等待,并获取信号 // unsigned long long int offsets=get_offset(); // printf("father offset:%llxn",offsets); printf("Setting breakpoints.n"); set_breakpoints(child_pid); printf("Child process start here!n"); ptrace(PTRACE_CONT,child_pid,0,0);// 让子进程继续执行指定程序 while(1){ wait(&wait_status);//截取信号 int id =child_pid; if(!WIFSTOPPED(wait_status)){ printf("End!n"); break;//子进程结束,打破循环 } //获取子进程发送的信号 read_signal(wait_status); //读取进程此刻状态的寄存器 read_registers(child_pid); read_memory(child_pid); //让用户输入一个内存地址,然后从内存中读出一个字节 clean_beakpoints(child_pid);//清除断点,恢复指令 printf("child process continue.n"); ptrace(PTRACE_CONT,child_pid,0,0); //让子进程继续执行 } } else{ printf("fork error"); //fork error } return 0; }



