ptrace使用和调试
1. 文章参考
[原创]一窥GDB原理-Pwn
Linux ptrace系统调用详解:利用 ptrace 设置硬件断点
<<软件调试>> 张银奎
<<程序员的自我修养>> 俞甲子 石凡 潘爱民
2. ptrace函数原型
enum __ptrace_request
{PTRACE_TRACEME = 0, //被调试进程调用PTRACE_PEEKDATA = 2, //查看内存PTRACE_PEEKUSER = 3, //查看struct user 结构体的值PTRACE_POKEDATA = 5, //修改内存PTRACE_POKEUSER = 6, //修改struct user 结构体的值PTRACE_CONT = 7, //让被调试进程继续PTRACE_SINGLESTEP = 9, //让被调试进程执行一条汇编指令PTRACE_GETREGS = 12, //获取一组寄存器(struct user_regs_struct)PTRACE_SETREGS = 13, //修改一组寄存器(struct user_regs_struct)PTRACE_ATTACH = 16, //附加到一个进程PTRACE_DETACH = 17, //解除附加的进程PTRACE_SYSCALL = 24, //让被调试进程在系统调用前和系统调用后暂停
};long int ptrace (enum __ptrace_request __request, ...)
这个枚举值只列出了一部分,更多的功能可以去查看man手册
3. 基本用法和被调用进程的信息获取
3.1 寄存器信息获取
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <unistd.h>int main(int argc,char *argv[])
{pid_t pid;int status = 0;//一组寄存器的值struct user_regs_struct regs;pid = fork();if (pid == 0) { //子进程if (ptrace(PTRACE_TRACEME,0,NULL,NULL) < 0) {perror("ptrace TRACEME err");return -1;}pid_t pid = getpid();//给自己发信号,让父进程wait返回if (kill(pid,SIGSTOP) != 0) {perror("kill sigstop err");}printf("child exit\n");return 0;} else if (pid < 0) {perror("fork err");return -1;}//监听子进程的状态wait(&status);if (WIFEXITED(status))return 0;//获取子进程的寄存器if (ptrace(PTRACE_GETREGS,pid,NULL,®s) < 0) {perror("get regs err");}printf("rax = %llx\n",regs.rax);printf("rip = %llx\n",regs.rip);printf("rbp = %llx\n",regs.rbp);printf("rsp = %llx\n",regs.rsp);sleep(2);//让子进程继续执行if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("CONT err");}return 0;
}
输出:
rax = 0
rip = 7f20cf9d92a7
rbp = 7fff166972a0
rsp = 7fff16697198
child exit
这里使用了ptrace的三个枚举值
PTRACE_TRACEME:这个是被调试的进程使用的,使用之后父进程才可以去跟踪子进程
PTRACE_CONT:让暂停的子进程继续执行
PTRACE_GETREGS:获取对应的一组寄存器的值,(struct user_regs_struct)这个结构体定义在<sys/user.h>中,这个结构体保存了一组寄存器的信息
struct user_regs_struct
{__extension__ unsigned long long int r15;__extension__ unsigned long long int r14;__extension__ unsigned long long int r13;__extension__ unsigned long long int r12;__extension__ unsigned long long int rbp;__extension__ unsigned long long int rbx;__extension__ unsigned long long int r11;__extension__ unsigned long long int r10;__extension__ unsigned long long int r9;__extension__ unsigned long long int r8;__extension__ unsigned long long int rax;__extension__ unsigned long long int rcx;__extension__ unsigned long long int rdx;__extension__ unsigned long long int rsi;__extension__ unsigned long long int rdi;__extension__ unsigned long long int orig_rax;__extension__ unsigned long long int rip;__extension__ unsigned long long int cs;__extension__ unsigned long long int eflags;__extension__ unsigned long long int rsp;__extension__ unsigned long long int ss;__extension__ unsigned long long int fs_base;__extension__ unsigned long long int gs_base;__extension__ unsigned long long int ds;__extension__ unsigned long long int es;__extension__ unsigned long long int fs;__extension__ unsigned long long int gs;
};
跟踪的流程
3.1.1 系统调用信息获取
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>int main(int argc,char *argv[])
{pid_t pid;int orig_rax;int iscalling = 0;int status = 0;uint64_t arg1,arg2,arg3;//一组寄存器的值struct user_regs_struct regs;pid = fork();if (pid == 0) { //子进程if (ptrace(PTRACE_TRACEME,0,NULL,NULL) < 0) {perror("ptrace TRACEME err");return -1;}pid_t pid = getpid();if (kill(pid,SIGSTOP) != 0) {perror("kill sigstop err");}write(STDOUT_FILENO,"aaaa -> ",8);write(STDOUT_FILENO,"bbbb -> ",8);write(STDOUT_FILENO,"cccc -> ",8);return 0;} else if (pid < 0) {perror("fork err");return -1;}//监听子进程的状态wait(&status);if (WIFEXITED(status))return 0;//让子进程在调用系统调用时暂停if (ptrace(PTRACE_SYSCALL,pid,NULL,NULL) < 0) {perror("ptrace SYSCALL err");return -1;}while (1) {wait(&status);if (WIFEXITED(status))break;ptrace(PTRACE_GETREGS,pid,NULL,®s);//获取寄存器整个结构orig_rax = regs.orig_rax;//获取系统调用号if (orig_rax == SYS_write) {if (!iscalling) {//系统调用前iscalling = 1;arg1 = regs.rdi;arg2 = regs.rsi;arg3 = regs.rdx;} else {//系统调用后printf("%lld = write(%ld,\"%s\",%ld)\n",regs.rax,arg1,(char *)arg2,arg3);iscalling = 0;}}//让子进程在调用系统调用时暂停if (ptrace(PTRACE_SYSCALL,pid,NULL,NULL) < 0) {perror("CONT err");return -1;}}return 0;
}
输出:
aaaa -> 8 = write(1,"aaaa -> ",8)
bbbb -> 8 = write(1,"bbbb -> ",8)
cccc -> 8 = write(1,"cccc -> ",8)
这里又使用了一个新的枚举值
PTRACE_SYSCALL:这个值表示子进程在调用系统调用前和调用系统调用后的时候暂停。
利用这个枚举,我们在系统调用前去获取调用的参数,在系统调用后去获取它的返回值。
系统调用的方式:
- syscall:
寄存器 rax 中存放系统调用号,同时系统调用返回值也存放在 rax 中
当系统调用参数小于等于6个时,参数则必须按顺序放到寄存器 rdi,rsi,rdx,r10,r8,r9中 - int 0x80 :
寄存器 rax 中存放系统调用号,同时返回值也存放在 rax 中
当系统调用参数小于等于6个时,参数则必须按顺序放到寄存器 rbx,rcx,rdx,rsi,rdi ,rbp中
在我的电脑上用的是syscall的调用方式,所以我在系统调用前保存了write系统调用了 rdi,rsi,rdx的值。在系统调用后去输出整个write调用的参数
regs.orig_rax 保存了系统调用号,可以利用系统调用的特性去获得调用时的参数。这个宏也是 ‘strace’的实现原理
3.2 内存信息
3.2.1 内存读取
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>int main(int argc,char *argv[])
{pid_t pid;int status = 0;uint64_t num = 0;pid = fork();if (pid == 0) { //子进程if (ptrace(PTRACE_TRACEME,0,NULL,NULL) < 0) {perror("ptrace TRACEME err");return -1;}pid_t pid = getpid();num = 20;if (kill(pid,SIGSTOP) != 0) {perror("kill sigstop err");}return 0;} else if (pid < 0) {perror("fork err");return -1;}//监听子进程的状态wait(&status);if (WIFEXITED(status))return 0;uint64_t tem = 0;tem = ptrace(PTRACE_PEEKDATA,pid,&num,NULL);printf("read num = %ld\n",tem);printf("this num = %ld\n",num);//让子进程继续跑if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("ptrace SYSCALL err");return -1;}return 0;
}
输出:
read num = 20
this num = 0
PTRACE_PEEKTEXT/PTRACE_PEEKDATA: 这两个宏是没有什么区别。表示读取某个地址的的值,读取宽度是8字节(64位)
我们定义了一个全局变量’num’,在子进程修改值后发送信号,然后父进程去读取这个地址的信息。(这里利用了fork()后父子进程的地址是一样的)
3.2.2 内存修改
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>int main(int argc,char *argv[])
{pid_t pid;int status = 0;uint64_t num = 0;pid = fork();if (pid == 0) { //子进程if (ptrace(PTRACE_TRACEME,0,NULL,NULL) < 0) {perror("ptrace TRACEME err");return -1;}printf("not write num = %ld\n",num);pid_t pid = getpid();if (kill(pid,SIGSTOP) != 0) {perror("kill sigstop err");}printf("write end num = %ld\n",num);return 0;} else if (pid < 0) {perror("fork err");return -1;}//监听子进程的状态wait(&status);if (WIFEXITED(status))return 0;ptrace(PTRACE_POKEDATA,pid,&num,120);//让子进程继续跑if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("ptrace SYSCALL err");return -1;}return 0;
}
输出:
not write num = 0
write end num = 120
PTRACE_POKETEXT/PTRACE_POKEDATA:和之前的读取宏一样,这个宏也是一样的。表示写入某个地址的数据,写入的宽度是8字节(64位)
定义一个变量’num’,在父进程修改前后输出
3.3 断点插入
3.3.1 软件断点
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>#define INT_3 0xccvoid bar(int num)
{printf("num = %d\n",num);
}int main(int argc,char *argv[])
{pid_t pid;int status = 0;struct user_regs_struct regs;pid = fork();if (pid == 0) { //子进程if (ptrace(PTRACE_TRACEME,0,NULL,NULL) < 0) {perror("ptrace TRACEME err");return -1;}pid_t pid = getpid();if (kill(pid,SIGSTOP) != 0) {perror("kill sigstop err");}for (int i = 0;i < 10;i++) {printf("%d\n",i);bar(111);}return 0;} else if (pid < 0) {perror("fork err");return -1;}//监听子进程的状态wait(&status);if (WIFEXITED(status))return 0;//保存原来的字节码uint64_t orig_code = ptrace(PTRACE_PEEKTEXT,pid,(void *)bar,0);//在地址的开头插入0xcc(插入软件断点)ptrace(PTRACE_POKETEXT, pid, (void *)bar, (orig_code & 0xFFFFFFFFFFFFFF00) | INT_3);//让子进程继续跑if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("ptrace SYSCALL err");return -1;}while (1) {wait(&status);if (WIFEXITED(status))return 0;//对比输出子进程断下的地址和bar地址ptrace(PTRACE_GETREGS,pid,NULL,®s);printf("0x%llx\n",regs.rip);printf("%p\n",(void *)bar);//恢复之前的代码值ptrace(PTRACE_POKETEXT,pid,(void *)bar,orig_code);//0xcc占一个字节,让ip寄存器恢复regs.rip = regs.rip - 1;//设置寄存器的值(主要用于恢复ip寄存器)ptrace(PTRACE_SETREGS,pid,0,®s);//用于看效果,确保子进程是真的断下sleep(1);//执行一条汇编指令,然后子进程会暂停ptrace(PTRACE_SINGLESTEP,pid,0,0);//等待子进程停止wait(NULL);//断点恢复ptrace(PTRACE_POKETEXT, pid, (void *)bar, (orig_code & 0xFFFFFFFFFFFFFF00) | INT_3);//子进程继续执行if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("ptrace SYSCALL err");return -1;}}return 0;
}
输出:
0
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
1
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
2
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
3
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
4
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
5
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
6
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
7
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
8
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
9
0x55bfcbdda8ab
0x55bfcbdda8aa
num = 111
PTRACE_SINGLESTEP:执行一条汇编指令,然后暂停。
软件断点的核心是要在代码段插入0xcc字节码(也就是int 3,3号中断)
我们这个程序在bar这个函数的开头插入0xcc使得子进程每次调用bar函数的时候断下,然后去恢复之前的指令去执行。执行完成之后继续插入断点
3.3.2 硬件断点
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>#define DR_OFFSET(num) ((void *) (& ((struct user *) 0)->u_debugreg[num]))void bar(int num)
{printf("num = %d\n",num);
}int main(int argc,char *argv[])
{pid_t pid;int status = 0;struct user_regs_struct regs;pid = fork();if (pid == 0) { //子进程if (ptrace(PTRACE_TRACEME,0,NULL,NULL) < 0) {perror("ptrace TRACEME err");return -1;}pid_t pid = getpid();if (kill(pid,SIGSTOP) != 0) {perror("kill sigstop err");}for (int i = 0;i < 10;i++) {printf("%d\n",i);bar(111);}return 0;} else if (pid < 0) {perror("fork err");return -1;}//监听子进程的状态wait(&status);if (WIFEXITED(status))return 0;//设置dr0寄存器为bar的地址if (ptrace(PTRACE_POKEUSER, pid, DR_OFFSET(0), (void *)bar) < 0) {perror("tracer, faile to set DR_0\n");}uint64_t dr_7 = 0;//设置对应的标准位使得dr0寄存器的地址生效dr_7 = dr_7 | 0x01;//L0位,局部dr_7 = dr_7 | 0x02;//G0位,全局//设置dr7寄存器的值if (ptrace(PTRACE_POKEUSER, pid, DR_OFFSET(7), dr_7) < 0) {perror("tracer, faile to set DR_7\n");}//让子进程继续跑if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("ptrace SYSCALL err");return -1;}while (1) {wait(&status);if (WIFEXITED(status))return 0;ptrace(PTRACE_GETREGS,pid,NULL,®s);//对比子进程当前断下的地址和函数bar的地址printf("function bar() = %p\n",(void *)bar);printf("break rip = %llx\n",regs.rip);//观察子进程是否暂停sleep(1);if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("ptrace SYSCALL err");return -1;}}return 0;
}
输出:
0
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
1
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
2
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
3
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
4
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
5
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
6
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
7
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
8
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
9
function bar() = 0x55a4ef5908aa
break rip = 55a4ef5908aa
num = 111
PTRACE_POKEUSER:用于写如入struct user结构体的值,第3个参数是该结构体的成员的偏移位置,第4个参数表示写入的值。
这是这个结构体的定义
struct user
{struct user_regs_struct regs;int u_fpvalid;struct user_fpregs_struct i387;__extension__ unsigned long long int u_tsize;__extension__ unsigned long long int u_dsize;__extension__ unsigned long long int u_ssize;__extension__ unsigned long long int start_code;__extension__ unsigned long long int start_stack;__extension__ long long int signal;int reserved;__extension__ union{struct user_regs_struct* u_ar0;__extension__ unsigned long long int __u_ar0_word;};__extension__ union{struct user_fpregs_struct* u_fpstate;__extension__ unsigned long long int __u_fpstate_word;};__extension__ unsigned long long int magic;char u_comm [32];__extension__ unsigned long long int u_debugreg [8];
};
其中第一个成员是我们之前使用过的寄存器组,最后一个成员是调试寄存器。
与之对应的还有PTRACE_PEEKUSER宏,这个宏是用来读取struct user结构体成员的值用的。
调试寄存器
dr0 - dr3寄存器:这个4个寄存器用于写入地址用的
dr4 - dr6寄存器:详情请看<<软件调试>>
dr7:
L0 - L3位:分别对应dr0-dr3,设置断点作用范围,如果被置位,那么将只对当前任务有效
G0-G3位:分别对应dr0-dr3,那么所有的任务都有效
(在我实验中好像这两个位没什么区别,如果知道原因的请留言,感谢)
R/W0-R/W3:读写位
00 执行断点
01 写入数据断点
10 I/O端口断点(只用于pentium+,需设置CR4的DE位)
11 读或写数据断点
LEN0-LEN3:指定内存操作的大小
00:1字节(执行断点只能是1字节长)
01:2字节
10:未定义或者是8字节(和cpu的系列有关系)
11:4字节
关于更多调试寄存器的理论知识推荐看《软件调试》
理解这些理论之后,在去观看写的demo,可以看到我们设置dr0寄存器的地址为bar函数的地址,然后设置dr7寄存器的L0和G0位让这个地址生效,之后子进程执行bar函数是就会断下
4. 调试程序
之前的demo都是在一个进程调试自己fork出来的子进程。如果我们要调试在文件系统中的程序或者真在运行的程序怎么办呢?
例如写一个test程序的代码
#include <stdio.h>
#include <unistd.h>int main(int argc,char *argv[])
{write(STDOUT_FILENO,"aaaa\n",5);write(STDOUT_FILENO,"bbbb\n",5);write(STDOUT_FILENO,"cccc\n",5);return 0;
}
我们编译成test可执行程序,我们如何去调试这个程序?
我们可以fork出一个子进程执行完ptrace后再去调用exec族的函数。
把我们之前调试系统调用的代码改一下
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdint.h>
#include <unistd.h>int main(int argc,char *argv[])
{pid_t pid;int orig_rax;int iscalling = 0;int status = 0;uint64_t arg1,arg2,arg3;//一组寄存器的值struct user_regs_struct regs;pid = fork();if (pid == 0) { //子进程if (ptrace(PTRACE_TRACEME,0,NULL,NULL) < 0) {perror("ptrace TRACEME err");return -1;}//调试当前的test程序execl("./test","./test",NULL);return 0;} else if (pid < 0) {perror("fork err");return -1;}//监听子进程的状态wait(&status);if (WIFEXITED(status))return 0;//让子进程在调用系统调用时暂停if (ptrace(PTRACE_SYSCALL,pid,NULL,NULL) < 0) {perror("ptrace SYSCALL err");return -1;}while (1) {wait(&status);if (WIFEXITED(status))break;ptrace(PTRACE_GETREGS,pid,NULL,®s);//获取寄存器整个结构orig_rax = regs.orig_rax;//获取系统调用号if (orig_rax == SYS_write) {if (!iscalling) {//系统调用前iscalling = 1;arg1 = regs.rdi;arg2 = regs.rsi;arg3 = regs.rdx;} else {//系统调用后char buf[16] = {0};//读取子进程write参数2地址的值*((uint64_t *)buf) = ptrace(PTRACE_PEEKDATA,pid,(void *)arg2,NULL);*((uint64_t *)(buf + 8)) = ptrace(PTRACE_PEEKDATA,pid,(void *)arg2,NULL);printf("%lld = write(%ld,\"%s\",%ld)\n",regs.rax,arg1,buf,arg3);iscalling = 0;}}//让子进程在调用系统调用时暂停if (ptrace(PTRACE_SYSCALL,pid,NULL,NULL) < 0) {perror("CONT err");return -1;}}return 0;
}
输出:
aaaa
5 = write(1,"aaaa
",5)
bbbb
5 = write(1,"bbbb
",5)
cccc
5 = write(1,"cccc
",5)
在子进程执行完exec函数的时候会暂停,此时父进程wait返回,设置系统调用暂停的宏。然后进行系统调用参数获取。需要注意的是如果获取的值是个地址,需要获取其内容的话需要去读取这个地址的内存的值。这里用的是取巧的方式,由于我们知道这个内存的长度是6,然后调用了两次读的操作。
4.1 程序符号表获取
想要获取一个可执行程序的符号就必须知道这个可执行程序的结构,在linux中这种结构一般是elf文件格式,在windows中是pe文件格式。
这种可执行文件里面包含了字符串表,调试信息,全局变量,代码指令等
4.1.1 elf结构
elf结构是以’段’来组织结构的包含代码段、数据段等,
在elf最开始的地方有一个’文件头’,这个文件头包含了文件属性,是否可以被执行、目标的硬件、操作系统其中最重要的是包含了一个段表信息,这个段表信息可以让我们找到这个可执行程序的所有段的信息。
4.1.2 elf头
所有的elf相关的信息都定义在了/usr/include/elf.h
文件头的结构
typedef struct
{unsigned char e_ident[EI_NIDENT]; //用于确定平台信息,操作系统、大小端等信息Elf64_Half e_type; //文件类型,Elf64_Half e_machine; //CPU平台属性Elf64_Word e_version; //版本信息Elf64_Addr e_entry; //入口地址Elf64_Off e_phoff; //Elf64_Off e_shoff; //段表在文件中的偏移Elf64_Word e_flags; //Elf64_Half e_ehsize; //elf文件头的的大小Elf64_Half e_phentsize; /* Program header table entry size */Elf64_Half e_phnum; /* Program header table entry count */Elf64_Half e_shentsize; //段表描述符的大小 sizeof(Elf64_Shdr)Elf64_Half e_shnum; //段表数量Elf64_Half e_shstrndx; //字符串表的位置(段表的下表)
} Elf64_Ehdr;
其中我们关注的几个信息:
e_shoff 段表在文件中的偏移,用于找到所有的段
e_shnum 段表数量
e_shstrndx 字符串表的位置(段表的下表)
4.1.3 段表
段表是一个数组,数组的大小为段表头里的e_shnum
段表每一个数组项的结构
typedef struct
{Elf64_Word sh_name; //段表名 (在字符串表的索引)Elf64_Word sh_type; /* Section type */Elf64_Xword sh_flags; /* Section flags */Elf64_Addr sh_addr; //加载后的段表的虚拟地址Elf64_Off sh_offset; //段在文件中的偏移Elf64_Xword sh_size; //段的大小Elf64_Word sh_link; /* Link to another section */Elf64_Word sh_info; /* Additional section information */Elf64_Xword sh_addralign; //段地址对齐(这个值的2的指数)Elf64_Xword sh_entsize; /* Entry size if section holds table */
} Elf64_Shdr;
段表需要关注的信息;
sh_name 段表的名称(这个名称是字符串表的下标)
sh_offset 段在文件中的偏移
4.1.3 字符串表
字符串表的格式就是一个 char 类型的数组,每个段的名字可以根据下标去获得值。
下面写一个获取所有段名和段位置的程序
文件相关的代码
//获取文件大小
int get_file_size(FILE *fp)
{if(fp == NULL) return -1;fseek(fp, 0, SEEK_END);int length = ftell(fp);fseek(fp, 0, SEEK_SET);return length;
}
//获取文件内容
int get_file_word(FILE *fp,char *word)
{if(fp == NULL || word == NULL) return -1;int seek = 0;while (!feof(fp)) {seek += fread(word+seek,1,1024,fp);}return 0;
}char *open_file(const char *filepath)
{FILE *fp = fopen(filepath,"r");if(fp == NULL){perror("fopen error");return NULL;}int filesize = get_file_size(fp);char *const word = (char *)calloc(1,filesize+1); get_file_word(fp,word);fclose(fp);return word;
}void close_file(char *p)
{free(p);
}
elf段表获取
int main(int argc,char *argv[])
{if (argc < 2) return 0;//获取文件内容char *word = open_file(argv[1]);//文件头Elf64_Ehdr *ehdr = (Elf64_Ehdr *)word;int table_max = ehdr->e_shnum;//段表的数量int shstrndx = ehdr->e_shstrndx;//字符串表在段表中的下标Elf64_Shdr *stables = (Elf64_Shdr *)(word + ehdr->e_shoff);//段表数组//找到字符串表的位置char *shstrtab_addr = word + stables[shstrndx].sh_offset;//输出段表名和段表位置for (int i = 0;i < table_max;i++) {printf("%3d %20s 0x%lx\n",i,shstrtab_addr + stables[i].sh_name,stables[i].sh_offset);}//释放资源close_file(word);return 0;
}
写一个demo测试
#include <stdio.h>int add(int a,int b)
{return a + b;
}int sub(int a,int b)
{return a - b;
}int main(int argc,char *argv[])
{return 0;
}
把两个程序编译
我把elf程序编译为test,demo编译为code
运行 ./test code
输出:
0 0x0
1 .interp 0x318
2 .note.gnu.property 0x338
3 .note.gnu.build-id 0x358
4 .note.ABI-tag 0x37c
5 .gnu.hash 0x3a0
6 .dynsym 0x3c8
7 .dynstr 0x458
8 .gnu.version 0x4d6
9 .gnu.version_r 0x4e8
10 .rela.dyn 0x508
11 .init 0x1000
12 .plt 0x1020
13 .plt.got 0x1030
14 .text 0x1040
15 .fini 0x11e8
16 .rodata 0x2000
17 .eh_frame_hdr 0x2004
18 .eh_frame 0x2050
19 .init_array 0x2df0
20 .fini_array 0x2df8
21 .dynamic 0x2e00
22 .got 0x2fc0
23 .data 0x3000
24 .bss 0x3010
25 .comment 0x3010
26 .symtab 0x3040
27 .strtab 0x3640
28 .shstrtab 0x3838
可以使用readelf -S code 命令去对比
4.1.4 符号表
刚刚我们用到一个段表就是字符串表
char *shstrtab_addr = word + stables[shstrndx].sh_offset;
我们用这种方式去获取,很明显字符串表就是一个字符数组
但不是每个段表都是字符数组,例如符号表就不是
符号表的结构
typedef struct
{Elf64_Word st_name; //符号名字(在.strtab表的索引)unsigned char st_info; /* Symbol type and binding */unsigned char st_other; /* Symbol visibility */Elf64_Section st_shndx; /* Section index */Elf64_Addr st_value; /* Symbol value */Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;
我们需要的信息:
st_name 符号名
st_value 值(函数就是地址)
st_info 低四位是符号的类型,高28位标识符号绑定的信息
st_size 段的大小
符号段包含了全局变量、函数等符号,我们只要提取函数的部分
struct func_org
{char *name;uint64_t addr;
};struct fun_arr
{char *word;int size;struct func_org data[0];
};void *get_section_addr(Elf64_Shdr *stables,char *word,int table_max,int shstrndx,char *sectionname,int *sectionsize)
{if(stables == NULL || word == NULL || sectionsize == NULL) return NULL;//字符串表char *shstrtab_addr = word + stables[shstrndx].sh_offset;//查找段表名int i = 0;for(i = 0;i < table_max;i++){if(strcmp(sectionname,shstrtab_addr + stables[i].sh_name) == 0) break;}if( i != table_max){*sectionsize = stables[i].sh_size;return word + stables[i].sh_offset;}return NULL;
}int get_all_func(Elf64_Sym *syns,char *strtab_addr,struct fun_arr *funs,uint64_t base)
{int count = 0;//遍历符号表,找到函数符号for(int i = 0;i < funs->size;i++) {if ((syns[i].st_info & 0xf) == STT_FUNC && syns[i].st_value != 0) {funs->data[count].name = strtab_addr + syns[i].st_name;funs->data[count].addr = syns[i].st_value + base;count++;}}funs->size = count;return 0;
}int main(int argc,char *argv[])
{if (argc < 2) return 0;char *word = open_file(argv[1]);Elf64_Ehdr *ehdr = (Elf64_Ehdr *)word;int table_max = ehdr->e_shnum;int shstrndx = ehdr->e_shstrndx;Elf64_Shdr *stables = (Elf64_Shdr *)(word + ehdr->e_shoff);char *shstrtab_addr = word + stables[shstrndx].sh_offset;int size = 0;//获取符号表的位置Elf64_Sym *syns = (Elf64_Sym *)get_section_addr(stables,word,table_max,shstrndx,".symtab",&size);int strsize = 0;//回去符号名称的位置char *strtab_addr = get_section_addr(stables,word,table_max,shstrndx,".strtab",&strsize);//Elf64_Sym 数组的大小size = size/sizeof(Elf64_Sym);struct fun_arr *funs = (struct fun_arr *)calloc(1,sizeof(struct fun_arr) + sizeof(struct func_org) * size);funs->size = size;funs->word = word;//获取函数符号和地址get_all_func(syns,strtab_addr,funs,0);struct func_org *p = funs->data;for (int i = 0;i < funs->size;i++) {printf("%-30s 0x%lx\n",p->name,p->addr);p++;}close_file(word);return 0;
}
./test code
输出:
deregister_tm_clones 0x1070
register_tm_clones 0x10a0
__do_global_dtors_aux 0x10e0
frame_dummy 0x1120
_init 0x1000
__libc_csu_fini 0x11e0
add 0x1129
_fini 0x11e8
__libc_csu_init 0x1170
_start 0x1040
main 0x1157
sub 0x1141
4.2 程序基地址获取
我们修改一下demo
#include <stdio.h>int add(int a,int b)
{return a + b;
}int sub(int a,int b)
{return a - b;
}int main(int argc,char *argv[])
{printf("main = %p\n",(void *)main);printf("add = %p\n",(void *)add);printf("sub = %p\n",(void *)sub);return 0;
}
然后不断执行:
main = 0x7f369e329177
add = 0x7f369e329149
sub = 0x7f369e329161
yh•~/Code» ./code [15:46:51]
main = 0x7f38dc36e177
add = 0x7f38dc36e149
sub = 0x7f38dc36e161
yh•~/Code» ./code [15:46:52]
main = 0x7f90c8548177
add = 0x7f90c8548149
sub = 0x7f90c8548161
yh•~/Code» ./code [15:46:52]
main = 0x7fcc94f37177
add = 0x7fcc94f37149
sub = 0x7fcc94f37161
可以看到每次的运行的地址都不一样,我们看一下我们获取的函数地址
…
add 0x1149
_fini 0x1258
__libc_csu_init 0x11e0
_start 0x1060
main 0x1177
sub 0x1161
可以发现这个地址只是加了一个base,偏移量是完全一样的。
获取程序基地址的方法
在linux中程序运行时会创建/proc/pid 目录,其中这个目录包含了maps文件和exe软连接(执行程序的路径)。maps文件可以获取程序的基地址
第一列是映射地址的范围,第二列是内存的访问权限,第三列是文件映射的地址。
也就是我们只有找到文件映射的地址为0,并且程序名称是对应程序的那一列就可以获得程序的基地址
获取函数编写
uint64_t get_pid_base(pid_t pid)
{char buf[BUFSIZE] = {0};char *pro_maps_path = buf;// open /proc/pid/mapspro_maps_path += sprintf(pro_maps_path,"%s","/proc");pro_maps_path += sprintf(pro_maps_path,"/%d",pid);sprintf(pro_maps_path,"/%s","maps");FILE *fp = fopen(buf,"rb");if (!fp) {perror("open file err");return -1;}// read /proc/pid/exememset(buf,0,BUFSIZE);char *pro_exe_path = buf;pro_exe_path += sprintf(pro_exe_path,"%s","/proc");pro_exe_path += sprintf(pro_exe_path,"/%d",pid);sprintf(pro_exe_path,"/%s","exe");char target[100] = {0};int target_len = readlink(buf,target,100);target[target_len] = 0;memset(buf,0,BUFSIZE);char *pro_addr = buf;char *pro_maps = buf + 100;char *pro_name = pro_maps + 100;char *p = pro_name + 256;char data[512] = {0};while (!feof(fp)) { fgets(data,sizeof(data),fp);sscanf(data,"%[^ ] %[^ ] %[^ ] %[^ ] %[^ ] %[^ ]",pro_addr,p,pro_maps,p,p,pro_name);//printf("pro_addr %s pro_maps %s pro_name %s --> %d %d\n",pro_addr,pro_maps,pro_name,memcmp(pro_name,target,target_len-1),memcmp(pro_maps,"00000000",8));if (memcmp(pro_name,target,target_len-1) == 0 && memcmp(pro_maps,"00000000",8) == 0) {fclose(fp);memset(p,0,10);sscanf(pro_addr,"%[^-]",p);uint64_t num = 0;str2uint64(p,num);return num;}memset(data,0,sizeof(data));}printf("not find addr\n");fclose(fp);return 0;
}
封装好了函数我们写一个测试程序验证一下
int main(int argc,char *argv[])
{pid_t pid = fork();if (pid == 0) {execl("./code","code",NULL);} else if (pid < 0) {perror("fork err");return -1;}sleep(1); //保证子进程先运行uint64_t base = get_pid_base(pid);printf("base = 0x%lx\n",base);return 0;
}
demo 程序简单的修改
#include <stdio.h>
#include <unistd.h>int add(int a,int b)
{return a + b;
}int sub(int a,int b)
{return a - b;
}int main(int argc,char *argv[])
{printf("main = %p\n",(void *)main);printf("add = %p\n",(void *)add);printf("sub = %p\n",(void *)sub);sleep(2);//运行完先不退出,等父进程运行完return 0;
}
我们把demo编译成code,在父进程中执行exec然后去获取子进程的基地址验证。运行是要主要两点
- 保证子进程exec完,如果不运行完,/proc/pid和父进程是类似的
- 子进程要比父进程后面退出,保证父进程获取到基地址
程序输出:
main = 0x5633033f6197
add = 0x5633033f6169
sub = 0x5633033f6181
base = 0x5633033f5000
我们观察我们获得的符号地址:
deregister_tm_clones 0x10b0
register_tm_clones 0x10e0
__do_global_dtors_aux 0x1120
frame_dummy 0x1160
_init 0x1000
__libc_csu_fini 0x1280
add 0x1169
_fini 0x1288
__libc_csu_init 0x1210
_start 0x1080
main 0x1197
sub 0x1181
可以对比一下,说明我们的基地址获取是正确的
4.3 read_pro_mem和write_pro_mem的封装
在之前用ptrace修改内存时,我们必须值操作8自己的内存数据,因此我们插入断点时需要先读,然后再写。如果内存大于8字节又需要重复去调用。这样的操作异常的繁杂,所以我们封装read_pro_mem和write_pro_mem 方便我们的内存读写
#define WORD (sizeof(void *))int read_pro_mem(pid_t child,uint64_t addr,char *str,int len)
{int i = 0;int n = len / WORD;char *current_p = str;uint64_t data;//能被整除的部分for (;i < n;i++) {if ((data = ptrace(PTRACE_PEEKTEXT,child,addr+i*WORD,NULL)) < 0) {perror("read mem err");return -1;}memcpy(current_p,&data,WORD);current_p += WORD;}//余数的部分int remainder = len % WORD;if (remainder != 0) {if ((data = ptrace(PTRACE_PEEKTEXT,child,addr+i*WORD,NULL)) < 0) {perror("read mem err");return -1;}//拷贝剩余字节数memcpy(current_p,&data,remainder);}return 0;
}int write_pro_mem(pid_t child,uint64_t addr,char *str,int len)
{int i = 0;int n = len / WORD;char *current_p = str;uint64_t data;//能被整除的部分for (;i < n;i++) {memcpy(&data,current_p,WORD);if (ptrace(PTRACE_POKETEXT,child,addr +i*WORD,data) < 0) {perror("write mem err");return -1;}current_p += WORD;}//余数的部分int remainder = len % WORD;if (remainder != 0) {//先读整个内存段if ((data = ptrace(PTRACE_PEEKTEXT,child,addr+i*WORD,NULL)) < 0) {perror("read mem err");return -1;}//把需要改的部分填充掉memcpy(&data,current_p,remainder);if (ptrace(PTRACE_POKETEXT,child,addr+i*WORD,data) < 0) {perror("write mem err");return -1;}}return 0;
}
这两个函数的封装很简单,只要分出整除部分和余数部分就可以了
整除部分只要循环调用就行
余数部分的读操作只要拷贝部分数据就行,写操作需要先读-修改-写入的步骤
4.4 断点插入
继续写一个测试的demo
#include <stdio.h>
#include <stdlib.h>
#include <string.h>int print_int(int a)
{printf("%d\n",a);return 0;
}int main(int argc,char *argv[])
{printf("print_int = %p\n",(void *)print_int);for (int i = 0;i < 10;i++) {print_int(i);}return 0;
}
我们用我们之前编写的技术点值实现这个程序每掉一次print_int 我们就断下断点输出当前的地址值并且睡眠
- 使用fork + execl执行这个程序
- 获取这个程序的基地址
- 获取这个程序函数的符号表
- 找到print_int函数的地址,使用read_pro_mem保存原字节码,使用write_pro_mem写入断点(0xcc)
技术点都已经有了,只剩下把之前的代码整合。
已经实现的函数不再写一遍了
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <elf.h>#define INT_3 0xcc
#define BUFSIZE 1024
#define str2uint64(str,num) sscanf(str,"%lx",&num)
#define WORD (sizeof(void *))struct fun_arr *dbuf(const char *path,uint64_t base)
{char *word = open_file(path);Elf64_Ehdr *ehdr = (Elf64_Ehdr *)word;int table_max = ehdr->e_shnum;int shstrndx = ehdr->e_shstrndx;Elf64_Shdr *stables = (Elf64_Shdr *)(word + ehdr->e_shoff);char *shstrtab_addr = word + stables[shstrndx].sh_offset;int size = 0;//获取符号表的位置Elf64_Sym *syns = (Elf64_Sym *)get_section_addr(stables,word,table_max,shstrndx,".symtab",&size);int strsize = 0;//回去符号名称的位置char *strtab_addr = get_section_addr(stables,word,table_max,shstrndx,".strtab",&strsize);//Elf64_Sym 数组的大小size = size/sizeof(Elf64_Sym);struct fun_arr *funs = (struct fun_arr *)calloc(1,sizeof(struct fun_arr) + sizeof(struct func_org) * size);funs->size = size;funs->word = word;//获取函数符号和地址get_all_func(syns,strtab_addr,funs,base);return funs;
}void close_dbug(struct fun_arr *p)
{close_file(p->word);free(p);
}int find_func(struct fun_arr *funs,const char *func)
{if (!funs || !func) return -1;for (int i = 0;i < funs->size;i++) {if (memcmp(funs->data[i].name,func,strlen(func)) == 0) {return i;}}return -1;
}int main(int argc,char *argv[])
{pid_t pid;int orig_rax;int iscalling = 0;int status = 0;//一组寄存器的值struct user_regs_struct regs;pid = fork();if (pid == 0) { //子进程if (ptrace(PTRACE_TRACEME,0,NULL,NULL) < 0) {perror("ptrace TRACEME err");return -1;}//调试deno,这个是我编译后的名字execl("./dbuf_break_test","dbuf_break_test",NULL);return 0;} else if (pid < 0) {perror("fork err");return -1;}//监听子进程的状态wait(&status);if (WIFEXITED(status))return 0;//找到print_int的地址uint64_t base = get_pid_base(pid);struct fun_arr *funs = dbuf("./dbuf_break_test",base);int index = find_func(funs,"print_int");uint64_t fun_addr = funs->data[index].addr;//往print_int函数插入断点uint8_t orig_code = 0;read_pro_mem(pid,fun_addr,(void *)&orig_code,1);char bit = INT_3;write_pro_mem(pid,fun_addr,&bit,1);if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("CONT err");return -1;}while (1) {wait(&status);if (WIFEXITED(status))break;ptrace(PTRACE_GETREGS,pid,NULL,®s);//获取寄存器整个结构printf("0x%llx\n",regs.rip);//恢复之前的代码值write_pro_mem(pid,fun_addr,&orig_code,1);//0xcc占一个字节,让ip寄存器恢复regs.rip = regs.rip - 1;//设置寄存器的值(主要用于恢复ip寄存器)ptrace(PTRACE_SETREGS,pid,0,®s);//用于看效果,确保子进程是真的断下sleep(1);//执行一条汇编指令,然后子进程会暂停ptrace(PTRACE_SINGLESTEP,pid,0,0);//等待子进程停止wait(NULL);//断点恢复char bit = INT_3;write_pro_mem(pid,fun_addr,&bit,1);//让子进程在调用系统调用时暂停if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("CONT err");return -1;}}close_dbug(funs);return 0;
}
输出:
print_int = 0x56131eca364a
0x56131eca364b
0
0x56131eca364b
1
0x56131eca364b
2
0x56131eca364b
3
0x56131eca364b
4
0x56131eca364b
5
0x56131eca364b
6
0x56131eca364b
7
0x56131eca364b
8
0x56131eca364b
9
4.5 attach一个进程
attach附加调试,我们使用调试最多的一种手段,常用于解决服务未响应、死循环、死锁等问题。ptrace提供也提供了附加进程的模式。
我们先延长一下被调试进程的时间让我们有充足的时间去attach进程
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>int print_int(int a)
{printf("%d\n",a);return 0;
}int main(int argc,char *argv[])
{printf("print_int = %p\n",(void *)print_int);printf("pid = %d\n",getpid());sleep(10);for (int i = 0;i < 10;i++) {print_int(i);}return 0;
}
调试进程修改为attach
int main(int argc,char *argv[])
{pid_t pid;int orig_rax;int iscalling = 0;int status = 0;//一组寄存器的值struct user_regs_struct regs;if (argc < 2) return 0;pid = atoi(argv[1]);//attachif (ptrace(PTRACE_ATTACH,pid,NULL,NULL) < 0) {perror("attch err");}//监听子进程的状态waitpid(pid,&status,0);if (WIFEXITED(status))return 0;//找到print_int的地址uint64_t base = get_pid_base(pid);struct fun_arr *funs = dbuf("./dbuf_break_test",base);int index = find_func(funs,"print_int");uint64_t fun_addr = funs->data[index].addr;//往print_int函数插入断点uint8_t orig_code = 0;read_pro_mem(pid,fun_addr,(void *)&orig_code,1);char bit = INT_3;write_pro_mem(pid,fun_addr,&bit,1);if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("CONT err");return -1;}while (1) {wait(&status);if (WIFEXITED(status))break;ptrace(PTRACE_GETREGS,pid,NULL,®s);//获取寄存器整个结构printf("0x%llx\n",regs.rip);//恢复之前的代码值write_pro_mem(pid,fun_addr,&orig_code,1);//0xcc占一个字节,让ip寄存器恢复regs.rip = regs.rip - 1;//设置寄存器的值(主要用于恢复ip寄存器)ptrace(PTRACE_SETREGS,pid,0,®s);//用于看效果,确保子进程是真的断下sleep(1);//执行一条汇编指令,然后子进程会暂停ptrace(PTRACE_SINGLESTEP,pid,0,0);//等待子进程停止wait(NULL);//断点恢复char bit = INT_3;write_pro_mem(pid,fun_addr,&bit,1);//让子进程在调用系统调用时暂停if (ptrace(PTRACE_CONT,pid,NULL,NULL) < 0) {perror("CONT err");return -1;}}close_dbug(funs);return 0;
}
编译完直接 ‘./attach pid’ 就可以看到现象了
具体的代码可以看我GitHub上面 https://github.com/huoyang11/test_dbug
我把整个demo整理了一个添加了简单的命令
目前封装的命令有
dbug 程序路径(调试一个程序)
attach pid (附加一个进程)
这俩个命令必须一开始调用
其他命令:
b 函数名 (软件断点)
c (进程继续执行)
show (查看程序的符号)
showreg (查看当前的寄存器值)
watch (硬件断点)
q 退出
exit 退出
quit 退出
showbps 查看断点
ptrace使用和调试相关推荐
- 一文带你看透 GDB 的 实现原理 -- ptrace真香
文章目录 Ptrace 的使用 GDB 的基本实现原理 Example1 通过ptrace 修改 被追踪进程的内存数据 Example2 通过ptrace 对被追踪进程进行单步调试 Ptrace的实现 ...
- Linux 2.6 下通过 ptrace 和 plt 实现用户态 API Hook
(转载兼整理)Linux 2.6 下通过 ptrace 和 plt 实现用户态 API Hook 这厮此文写的相当实用,不知道为啥不好好整理一下,得,我代劳了吧.作者:l04m33@gmail.com ...
- (转载兼整理)Linux 2.6 下通过 ptrace 和 plt 实现用户态 API Hook
这厮此文写的相当实用,不知道为啥不好好整理一下,得,我代劳了吧.作者:l04m33@gmail.com,原文.去看一眼就知道我干嘛干这个脏活儿了... 感觉这篇文章有上首页的素质,可惜不是我自己写的, ...
- 调试器原理_调试器的工作原理
调试器原理 调试器是大多数(如果不是每种)开发人员在软件工程生涯中至少使用一次的软件之一,但是你们当中有多少人知道它们的实际工作原理? 在悉尼举行的linux.conf.au 2018上的演讲中,我将 ...
- GDB的工作原理及skyeye远程调试
目录 01.GDB简介 02.Ptrace简介 03.GDB三种调试方式 04.GDB调试的基础-信号 05.SkyEye支持GDB远程调试 01.GDB简介 GDB:GNU debugger 是UN ...
- android的反调试方法,Android平台融合多特征的APP反调试方法与流程
本发明涉及Android平台融合多特征的APP反调试方法,属于计算机与信息科学技术领域. 背景技术: 应用程序本身并不具备反调试的功能,但是动态调试是动态分析应用逻辑.动态脱壳等攻击方式所采取的必要手 ...
- 转载 调试器工作原理
调试器工作原理--基础篇 本文是一系列探究调试器工作原理的文章的第一篇.我还不确定这个系列需要包括多少篇文章以及它们所涵盖的主题,但我打算从基础知识开始说起. 关于本文 我打算在这篇文章中介绍关于Li ...
- 调试器工作原理——基础篇
#include <stdio.h>int main(){printf("Hello, world!n");return 0;} 本文是一系列探究调试器工作原理的文章的 ...
- python调试器原理_调试器工作原理——基础篇
本文是一系列探究调试器工作原理的文章的第一篇.我还不确定这个系列需要包括多少篇文章以及它们所涵盖的主题,但我打算从基础知识开始说起. 关于本文 我打算在这篇文章中介绍关于Linux下的调试器实现的主要 ...
最新文章
- canonicalize_url()方法格式化 url
- wikioi 3027 线段覆盖 2
- render在python中的含义_python-/ render()上的Django TypeError获得了意外的...
- 深度学习入门篇(二)Lenet网络在caffe+QtCreator上部署应用
- 智能技术可以帮助解决人口老龄化问题吗?
- 《鸿蒙理论知识05》HarmonyOS概述之下载与安装软件
- Mint-UI框架router-link返回上一页的方法 - 踩坑篇
- 如果人类的历史共有100万年,假设这等于一天
- java常用的正则表达式
- mysql 防重复提交_怎样防止刷新重复提交、防后退
- tailf 命令安装
- POJ3494Largest Submatrix of All 1’s[单调栈]
- (附源码)小程序 平衡膳食小程序 毕业设计 250859
- 解决Keil4与Keil5在同系统不能共存的问题
- VOIP技术与应用学习分享
- RH Timer pro for Mac(定时计时器软件)
- 菜鸟教程之html5学习,Canvas画布、渐变,数学公式、符号的书写
- centos执行yum命令报错,There are no enable repos
- Win10安装Kali子系统
- 我看好金融IT业的几个理由
热门文章
- 微信皮肤css,微信小程序实现皮肤功能(夜间模式)
- 接口自动化覆盖率统计——Jacoco使用
- java ajax轮询_ajax轮询(ajax轮询实现聊天)
- Matlab_R2014a的安装与破解
- 方舟手游怎么看最新服务器机柜销售,方舟生存进化PVX服务器怎么玩 PVX服务器规则一览...
- 微信小程序口红项目新手练习Day1
- C++十六进制转八进制
- word常用的快捷键
- dnn降噪_万魔降噪双旗舰耳机分享:顺带聊聊如何挑选一款无线耳机
- 修改confirm样式