说一说ptrace不可忽略的技术细节
ptrace的API使用起来简单明了,好像上手很容易,但实际开发使用中存在很多细节,容易产生各种各样的错误。本文内容来源于本人使用ptrace开发时遇到问题及解决之后的经验总结。
细节一:多线程处理
ptrace操作的最小单位是线程,这是很多人都忽略的一个点。可以肯定的讲,成熟商用的软件几乎没有一个不是多线程运行的。在LINUX中,线程其实就是进程,进程号就是当前进程的主线程号。
比如使用top -Hp 92964命令查看tomcat进程,得到如下结果
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
92964 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 java
92965 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.57 java
92966 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.04 GC Thread#0
92967 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 CMS Thread#0
92968 root 20 0 6070636 198540 37784 S 0.0 2.4 0:02.61 CMS Main Thread
92969 root 20 0 6070636 198540 37784 S 0.0 2.4 0:07.37 VM Thread
92970 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 Reference Handl
92971 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 Finalizer
92972 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 Signal Dispatch
92973 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 Service Thread
92974 root 20 0 6070636 198540 37784 S 0.0 2.4 0:02.60 C2 CompilerThre
92975 root 20 0 6070636 198540 37784 S 0.0 2.4 0:01.30 C1 CompilerThre
92976 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 Sweeper thread
92977 root 20 0 6070636 198540 37784 S 0.0 2.4 0:58.99 VM Periodic Tas
92978 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.08 Common-Cleaner
92979 root 20 0 6070636 198540 37784 S 0.0 2.4 0:03.75 AsyncFileHandle
92982 root 20 0 6070636 198540 37784 S 0.0 2.4 0:03.54 NioBlockingSele
92985 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.04 GC Thread#1
92986 root 20 0 6070636 198540 37784 S 0.0 2.4 0:02.50 ContainerBackgr
92987 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.04 http-nio-8080-e
92988 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 http-nio-8080-e
92989 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 http-nio-8080-e
92990 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.01 http-nio-8080-e
92991 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 http-nio-8080-e
92992 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 http-nio-8080-e
92993 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 http-nio-8080-e
92994 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 http-nio-8080-e
92995 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 http-nio-8080-e
92996 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.00 http-nio-8080-e
92997 root 20 0 6070636 198540 37784 S 0.0 2.4 0:03.50 http-nio-8080-C
92998 root 20 0 6070636 198540 37784 S 0.0 2.4 0:03.31 http-nio-8080-C
92999 root 20 0 6070636 198540 37784 S 0.0 2.4 0:00.01 http-nio-8080-A
93000 root 20 0 6070636 198540 37784 S 0.0 2.4 0:04.03 http-nio-8080-A
清楚的列出各个线程的ID、CPU占用、内存占用、运行状态(有R、S、D、T、Z、X几种,这里是S)。
所以在做多线程进程进行ptrace操作时,在attach时就要把每一个线程都要attach一遍。这样才能确保整个进程都处于完全停止状态,如果只对进程id(即主线程)进行attach,那其余的线程还是会继续运行。
如何获取进程有哪些线程?还以进程92964为例:
#ls /proc/92964/task/
92964 92967 92970 92973 92976 92979 92986 92989 92992 92995 92998
92965 92968 92971 92974 92977 92982 92987 92990 92993 92996 92999
92966 92969 92972 92975 92978 92985 92988 92991 92994 92997 93000
#ls /proc/92964/task/92967/
attr cmdline exe limits mounts oom_score projid_map setgroups statm
auxv comm fd loginuid net oom_score_adj root smaps status
cgroup cpuset fdinfo maps ns pagemap sched smaps_rollup syscall
children cwd gid_map mem numa_maps patch_state schedstat stack uid_map
clear_refs environ io mountinfo oom_adj personality sessionid stat wchan
细节二:ptrace_attach
ptrace的调用者(tracer)与被调试线程(tracee)是通过信号进行交互的。tracee在被attach之后,会进入STOP状态同时会给tracer发送SIGSTOP信号。很多开发者都会去在attach之后,只是写一个循环调用waitpid等待SIGSTOP,代码如下:
int attach(int tid)
{
if(ptrace(PTRACE_ATTACH, tid, 0, 0) == -1)
{
printf("attach pid %d fail\n", tid);
return -1;
}
int status = 0;
waitpid(tid, &status, __WALL);
int sig = WSTOPSIG(status);
while(!WIFSTOPPED(status) || sig != SIGSTOP)
{
waitpid(tid, &status, __WALL);
}
return 0;
}
这是一个错误的做法,tracee在发送SIGSTOP之前还可能发送其他信号,上面的代码直接把这个信号丢弃了。正确做法是,如果不是SIGSTOP,应该把信号发送回去。
int Debugger::attach(int tid)
{
if(ptrace(PTRACE_ATTACH, tid, 0, 0) == -1)
{
printf("attach pid %d fail\n", tid);
return -1;
}
int status = 0;
waitpid(tid, &status, __WALL);
int sig = WSTOPSIG(status);
while(!WIFSTOPPED(status) || sig != SIGSTOP)
{
//如果在SIGSTOP之前收到其他信号,那么在接收之后要发送回去
tkill(tid, sig);
waitpid(tid, &status, __WALL);
}
return 0;
}
细节三:读写tracee的/proc/pid/mem
我们都知道在对线程自己的寄存器、栈读写或代码段读写时,线程要处于STOP状态。什么事件会使用线程处于STOP状态?如ptrace-attach,ptrace-singlestep,tracee在触发SIGSEGV、SIGTRAP、SIGILL等场景,这里不一一列举。
/proc/pid/mem映射的是整个进程的内存,可以通过操作它来实现读写修改代码段、栈空间、数据段等等功能,它要求主线程处理STOP状态。
从LINUX内核3.2开始提供process_vm_readv和process_vm_writev系统调用,用来实现这一功能,是一种新的IPC机制。
细节四:断点
使用ptrace实现断点的原理就是将某一代码段地址的指令修改为TRAP指令,比如x86的int3。各线程的寄存器、栈帧是独有的,但代码是共享的。
也就是说,通过线程A去下的断点,线程B、线程C都可能会执行到,如果线程B或线程C没有被ptrace attach,执行到此TRAP指令时产生的SIGTRAP就不会被捕捉到,导致进程异常退出。
因此,对于多线程进程的断点实现,要及时跟踪进程中是否有新线程产生并ptrace-attach。
细节五:代码注入
使用ptrace可以修改tracee进程的任意寄存器,如PC、SP,也几乎可以修改任意内存地址,包括映射为只读的代码段,注入代码并执行完全不是难事。
通常注入代码有shellcode和so两种形式,在操作上大同小异,一般都会借用libc中如mmap、libc_dlopen_mode等函数完成。那么如何使用ptrace进行函数调用?一般流程如下:
1.保存tracee当前PC上下文(即所有寄存器)
2.构造参数,修改tracee的PC指针为自定义代码
3.在自定义代码末尾放置TRAP指令
4.ptrace-cont
5.tracer捕获SIGTRAP
6.恢复tracee之前PC上下文
7.ptrace-dettach,结束
那么步骤2、步骤3的实现方式可以有很多种,有用__mmap/malloc申请可执行内存的,有修改代码段放置TRAP指令、执行完再恢复的,其中有一个方法我觉得最简洁,不需要调用函数去申请内存、也不需要修改已有代码段。
分配空间使用栈,而放置TRAP指令替换为把函数返回地址替换为0
比如想要调用system("echo 123"),以x86_64为例,有如下步骤
1.修改sp寄存器,抬高8字节,用来存放字串echo 123\0
2.修改pc寄存器为system地址
3.修改rdi为第1步的栈顶地址
4.将0压栈
5.ptrace-cont
这样在执行完system之后,RET指令使RIP变为0会触发SIGSEGV,此时tracer捕获后恢复PC上下文即可。
细节六:glibc的栈对齐
x86_64在调用libc的函数时,一定要保证SP指针是16字节对齐的,否则在运行到movaps [rbp-0x470],xmm0这种指令时会SIGSEGV。这个问题也经常出现在汇编混合调用C的场景中。
BY:先知论坛
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论