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,&regs) < 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,&regs);//获取寄存器整个结构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,&regs);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,&regs);//用于看效果,确保子进程是真的断下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,&regs);//对比子进程当前断下的地址和函数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,&regs);//获取寄存器整个结构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,&regs);//获取寄存器整个结构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,&regs);//用于看效果,确保子进程是真的断下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,&regs);//获取寄存器整个结构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,&regs);//用于看效果,确保子进程是真的断下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使用和调试相关推荐

  1. 一文带你看透 GDB 的 实现原理 -- ptrace真香

    文章目录 Ptrace 的使用 GDB 的基本实现原理 Example1 通过ptrace 修改 被追踪进程的内存数据 Example2 通过ptrace 对被追踪进程进行单步调试 Ptrace的实现 ...

  2. Linux 2.6 下通过 ptrace 和 plt 实现用户态 API Hook

    (转载兼整理)Linux 2.6 下通过 ptrace 和 plt 实现用户态 API Hook 这厮此文写的相当实用,不知道为啥不好好整理一下,得,我代劳了吧.作者:l04m33@gmail.com ...

  3. (转载兼整理)Linux 2.6 下通过 ptrace 和 plt 实现用户态 API Hook

    这厮此文写的相当实用,不知道为啥不好好整理一下,得,我代劳了吧.作者:l04m33@gmail.com,原文.去看一眼就知道我干嘛干这个脏活儿了... 感觉这篇文章有上首页的素质,可惜不是我自己写的, ...

  4. 调试器原理_调试器的工作原理

    调试器原理 调试器是大多数(如果不是每种)开发人员在软件工程生涯中至少使用一次的软件之一,但是你们当中有多少人知道它们的实际工作原理? 在悉尼举行的linux.conf.au 2018上的演讲中,我将 ...

  5. GDB的工作原理及skyeye远程调试

    目录 01.GDB简介 02.Ptrace简介 03.GDB三种调试方式 04.GDB调试的基础-信号 05.SkyEye支持GDB远程调试 01.GDB简介 GDB:GNU debugger 是UN ...

  6. android的反调试方法,Android平台融合多特征的APP反调试方法与流程

    本发明涉及Android平台融合多特征的APP反调试方法,属于计算机与信息科学技术领域. 背景技术: 应用程序本身并不具备反调试的功能,但是动态调试是动态分析应用逻辑.动态脱壳等攻击方式所采取的必要手 ...

  7. 转载 调试器工作原理

    调试器工作原理--基础篇 本文是一系列探究调试器工作原理的文章的第一篇.我还不确定这个系列需要包括多少篇文章以及它们所涵盖的主题,但我打算从基础知识开始说起. 关于本文 我打算在这篇文章中介绍关于Li ...

  8. 调试器工作原理——基础篇

    #include <stdio.h>int main(){printf("Hello, world!n");return 0;} 本文是一系列探究调试器工作原理的文章的 ...

  9. python调试器原理_调试器工作原理——基础篇

    本文是一系列探究调试器工作原理的文章的第一篇.我还不确定这个系列需要包括多少篇文章以及它们所涵盖的主题,但我打算从基础知识开始说起. 关于本文 我打算在这篇文章中介绍关于Linux下的调试器实现的主要 ...

最新文章

  1. canonicalize_url()方法格式化 url
  2. wikioi 3027 线段覆盖 2
  3. render在python中的含义_python-/ render()上的Django TypeError获得了意外的...
  4. 深度学习入门篇(二)Lenet网络在caffe+QtCreator上部署应用
  5. 智能技术可以帮助解决人口老龄化问题吗?
  6. 《鸿蒙理论知识05》HarmonyOS概述之下载与安装软件
  7. Mint-UI框架router-link返回上一页的方法 - 踩坑篇
  8. 如果人类的历史共有100万年,假设这等于一天
  9. java常用的正则表达式
  10. mysql 防重复提交_怎样防止刷新重复提交、防后退
  11. tailf 命令安装
  12. POJ3494Largest Submatrix of All 1’s[单调栈]
  13. (附源码)小程序 平衡膳食小程序 毕业设计 250859
  14. 解决Keil4与Keil5在同系统不能共存的问题
  15. VOIP技术与应用学习分享
  16. RH Timer pro for Mac(定时计时器软件)
  17. 菜鸟教程之html5学习,Canvas画布、渐变,数学公式、符号的书写
  18. centos执行yum命令报错,There are no enable repos
  19. Win10安装Kali子系统
  20. 我看好金融IT业的几个理由

热门文章

  1. 微信皮肤css,微信小程序实现皮肤功能(夜间模式)
  2. 接口自动化覆盖率统计——Jacoco使用
  3. java ajax轮询_ajax轮询(ajax轮询实现聊天)
  4. Matlab_R2014a的安装与破解
  5. 方舟手游怎么看最新服务器机柜销售,方舟生存进化PVX服务器怎么玩 PVX服务器规则一览...
  6. 微信小程序口红项目新手练习Day1
  7. C++十六进制转八进制
  8. word常用的快捷键
  9. dnn降噪_万魔降噪双旗舰耳机分享:顺带聊聊如何挑选一款无线耳机
  10. 修改confirm样式