文章目录
什么是库打桩技术?
什么是Hook层?
就是通过 hook
系统的 socket
函数族来实现无需修改代码的异步化改造。简单来说,就是利用动态链接的原理来修改符号指向,从而达到『偷梁换柱』的编程效果。在介绍具体怎么做之前,我们需要回顾一下链接的知识。
先行阅读:Linux下的静态链接与动态链接
我们都知道,编译器可以将我们编写的代码编译成为目标代码,而链接器则负责将多个目标代码收集起来并组合成为一个单一的文件。链接过程可以执行于编译时(compile time),也可以执行于加载时(load time),甚至可以执行于运行时(run time)。执行于编译时的链接被称为静态链接,而执行于加载时和运行时被称为动态链接。
静态链接库
之所以成为【静态库】,是因为在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件(.out)中。因此对应的链接方式称为静态链接。
试想一下,静态库与汇编生成的目标文件一起链接为可执行文件,那么静态库必定跟.o文件格式相似。其实一个静态库可以简单看成是一组目标文件(.o 文件)的集合,即很多目标文件经过压缩打包后形成的一个文件。
静态库特点总结:
1.静态库对函数库的链接是放在编译时期完成的。
2.程序在运行时与函数库再无瓜葛,移植方便。
3.浪费空间和资源,因为所有相关的目标文件与牵涉到的函数库被链接合成一个可执行文件
静态库的缺点:
- 静态库需要像软件一样进行定期的维护和更新,一旦程序员需要使用一个库的最新版本,他们必须显示地将程序与最新库链接
- 静态链接库中的模块总是被多个进程复制到自己的文本段内,造成存储资源的极大浪费。
加载时的动态链接(可省略,编译原理得好好学了,深入理解计算机系统也该看看了)
查看:http://kaiyuan.me/2017/05/03/function_wrapper/
运行时的动态链接
动态库在程序编译时并不会被连接到目标代码中,而是在程序运行是才被载入。不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。动态库在程序运行时才被载入,也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可。这也为应用程序 Hook 系统函数提供了基础。
实例:
假如我们想要统计某一个程序中 malloc
函数的调用次数,但是不能对代码进行侵入式的修改,那我们应该怎么做?最简单的思路就是,我们让应用程序对 malloc 的解析转为调用我们定义的某个函数,然后在自己的函数中计数 malloc 的调用次数,就能达到目的。例如对于如下简单的程序 main.c(无任何意义,仅作为示例使用):
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char** argv) {
int* p = (int *)malloc(sizeof(int));
free(p);
return 0;
}
//从这里开始真的就不太懂了?????程序的运行流程有时间再看看CSAPP吧!
为了让上述程序中对 malloc 的调用重定位到我们自己定义的函数,我们可以利用 dlsym 编写如下文件 dlsym_test_preload.c:
#include <stdio.h>
#include <dlfcn.h>
static unsigned int invoke_times = 0;
void* malloc(size_t sz) {
void* (*my_malloc)(size_t) = dlsym(RTLD_NEXT, "malloc");
// 从调用方链接映射列表中的下一个关联目标文件获取符号,
// 即找到glibc.so中的malloc函数。
invoke_times += 1;
printf("my malloc invoked\n");
return my_malloc(sz);
}
下一步则是要让可执行文件main找到自定义的malloc函数。
(1)使用LD_PRELOAD
在linux操作系统的动态链接库的世界中,LD_PRELOAD就是这样一个环境变量,它可以影响程序的运行时的链接(Runtime linker),它允许你定义在程序运行前优先加载的动态链接库。loader在进行动态链接的时候,会将有相同符号名的符号覆盖成LD_PRELOAD指定的so文件中的符号。换句话说,可以用我们自己的so库中的函数替换原来库里有的函数,从而达到hook的目的。(这才是人话)
编译:
$ gcc -o main main.c
$ gcc -o libmymalloc.so -fPIC -shared -D_GNU_SOURCE myhook.c -ld(这个我没有成功,不过不重要了)
运行:
LD_PRELOAD=./libmymalloc.so ./main
call my malloc
hello world
(2)不使用LD_PRELOAD
样就结束了吗?我们看看libco库是如何实现hook的呢,它的makefile中并没有LD_PRELOAD相关的信息。其秘密在于co_hook_sys_call.cpp,其将 co_enable_hook_sys()的定义在该cpp文件内,这样就把该文件的所有函数都导出了(即导出符号表)。
//co_hook_sys_call.cpp
ssize_t read(int fd, void* buf, size_t bytes)
{
...
}
...
void co_enable_hook_sys() //这函数必须在这里,否则本文件会被忽略!!!
{
stCoRoutine_t *co = GetCurrThreadCo();
if( co )
{
co->cEnableSysHook = 1;
}
}
我们仍然以上面malloc的例子来说明:
// main.c
#include <stdio.h>
#include <stdlib.h>
#include "myhook.h"
int main() {
enable_hook();
void *p = malloc(4);
free(p);
printf("hello world\n");
return 0;
}
// myhook.h
int enable_hook();
// myhook.c
#include <stdlib.h>
#include <dlfcn.h>
#include <stdio.h>
#include "myhook.h"
static int count = 0;
void *malloc(size_t size) {
void *(*myMalloc)(size_t) = dlsym(RTLD_NEXT, "malloc");
printf("call my malloc\n");
count++;
return myMalloc(size);
}
int enable_hook() {
return 1;
}
编译和运行:
$ gcc -o libmymalloc.so -fPIC -shared -D_GNU_SOURCE myhook.c -ldl
$ gcc -o main main.c -L./ -lmymalloc
$ ./main
call my malloc
hello world
这种方式算是对源代码进行了侵入,必须调用特定的函数(即本例中的enable_hook()),才能将hook的函数导出,并链接到现有的可执行文件的内存空间中。
总结:libco中的hook技术
- 全局符号介入
linux下的动态链接器存在以下原则:当共享对象被load
进来的时候,它的符号表会被合并到进程的全局符号表中(这里说的全局符号表并不是指里面的符号全部是全局符号,而是指这是一个汇总的符号表),当一个符号需要加入全局符号表时,如果相同的符号名已经存在,则后面加入的符号被忽略。
由于glibc是c/cpp程序的运行库,因此它是最后以动态链接的形式链接进来的,我们可以保证其肯定是最后加入全局符号表的,由于全局符号介入机制,glibc中的相关socket函数符号被忽略了(但是libco中巧妙的运用RTLD_NEXT参数获取到了其地址),也因此只要最终的可执行文件链接了libco协程库,就可以基本保证相关的socket函数被hook掉了。
libco 最大的特点就是将系统中的关于网络操作的阻塞函数全部进行相应的非侵入式改造,例如对于 read,write 函数,libco 均定义了自己的版本,然后进行运行时地解析,从而来达到阻塞时自动让出协程,并在 IO 事件发生时唤醒协程的目的。
实例: libco中的read函数
ssize_t read( int fd, void *buf, size_t nbyte )
{
HOOK_SYS_FUNC( read );
if( !co_is_enable_sys_hook() )
{
return g_sys_read_func( fd,buf,nbyte );
}
rpchook_t *lp = get_by_fd( fd );
if( !lp || ( O_NONBLOCK & lp->user_flag ) )
{
ssize_t ret = g_sys_read_func( fd,buf,nbyte );
return ret;
}
int timeout = ( lp->read_timeout.tv_sec * 1000 )
+ ( lp->read_timeout.tv_usec / 1000 );
struct pollfd pf = { 0 };
pf.fd = fd;
pf.events = ( POLLIN | POLLERR | POLLHUP );
int pollret = poll( &pf,1,timeout );
ssize_t readret = g_sys_read_func( fd,(char*)buf ,nbyte );
if( readret < 0 )
{
co_log_err("CO_ERR: read fd %d ret %ld errno %d poll ret %d timeout %d",
fd,readret,errno,pollret,timeout);
}
return readret;
}
这个函数内部实际上做了 4 件事:
- 第一步将当前协程注册到定时器上,用于将来处理 read() 函数的读超时。
- 第二步,调用 epoll_ctl() 将自己注册到当前执行环境的 epoll 实例上。这两步注册过程都需要指定一个回调函数,将来用于“唤醒”当前协程。
- 第三步,调用 co_yield_env 函数让出 CPU。
- 第四步要等到该协程被主协程重新“唤醒”后才能继续。
如果主协程 epoll_wait() 得知 read 操作的文件描述符可读,则会执行原 read 协程注册的会回调将它唤醒(超时后同理,不过还要设置超时标志)。工作协程被唤醒后,在调用原 Glibc 内被 hook 替换掉的、真正的 read() 系统调用。这时候如果是正常 epoll_wait 得知文件描述符 I/O 就绪就会读到数据,如果是超时就会返回 -1。
总之,在外部使用者看来,这个 read() 就跟阻塞式的系统调用表现出几乎完全一致的行为了。
参考:
https://blog.didiyun.com/index.php/2018/11/23/libco/#协程的“阻塞”与线程的“非阻塞”