一套使用注入和Hook技术托管入口函数的方案

栏目: 软件资讯 · 发布时间: 7年前

内容简介:一般场景下,我们都是把开源项目代码编译到我们自己的项目中。这样的“融合”,就相当于让两个项目进行了“基因重组”,最终产出一个“基因战士”。在进行“基因重组”中,需要“专业人员”对开源项目中每个“基因序列”有足够的理解,这样才能正确挑选出适合的“序列”;还需要知晓这些“序列”的组合关系,才能将其正确“剪切”且“组装”到我们项目的“基因”中。这对“专业人员”有着比较高的要求,因为研究透一套开源项目并不容易。然而对于急需解决问题的人们来说,“拿来主义”应该足够简单且稳定。如果需要花很多时间去熟悉研究一个开源项目

一般场景下,我们都是把开源项目代码编译到我们自己的项目中。这样的“融合”,就相当于让两个项目进行了“基因重组”,最终产出一个“基因战士”。在进行“基因重组”中,需要“专业人员”对开源项目中每个“基因序列”有足够的理解,这样才能正确挑选出适合的“序列”;还需要知晓这些“序列”的组合关系,才能将其正确“剪切”且“组装”到我们项目的“基因”中。

这对“专业人员”有着比较高的要求,因为研究透一套开源项目并不容易。

然而对于急需解决问题的人们来说,“拿来主义”应该足够简单且稳定。如果需要花很多时间去熟悉研究一个开源项目,并且不能保证使用的正确性,也不能保证开源项目内部实现的稳定性,就可能比较不值得。可以想象下,假如我们“基因重组”出来的“物种”总是夭折(频繁崩溃),是对项目多么大的打击。

这样的例子并不少,比较常见的是开源项目curl。它帮我们实现很多下载、上传等网络业务。几年前,我曾经花费了不少时间研究过它的使用。可以参见 《实现HTTP协议Get、Post和文件上传功能——使用libcurl接口实现》 ,大家只管直接往后翻阅,就会发现这套“基因”并不是那么容易组装的。

再稍微复杂点,像视频领域的开源项目ffmpeg。之前为了熟悉它的“基因序列”,我也花费了不少时间,才摸清了套路。大家同样可以参见 《ffmpeg api的应用——提取视频图片》 ,就会发现这套系统也有其独特的设计思路。而且比较悲剧的是,我们可能并不能将去各个方面都摸透。比如如何防止超时?如果防止启动过多线程?如果要解决这些关键而又“专业”的问题,可能就需要花费更多的时间。对于“拿来主义”来说,这可能就变味了。

而且,在实践中,我们发现一些开源项目自身的稳定性不是十分可靠(比如ffmpeg)。如果的在线服务是“重组”了这些项目的“基因”,就会面临着很大的稳定性风险。

这么看来,“基因重组”是需要“非常专业”的人员花费大量的时间来“组装”出一个不是十分理想产品的方案。

但是有些时候,我们出于“定制”、“高效”等原因不得不使用“基因重组”,那么就需要花很多金钱和时间去把上述问题都攻破。

可是,在实际场景中,并不是每个项目都“苛刻”到必须使用“基因重组”。

有些开源项目,是以 工具 的形式发布的,其公布的源码或者开放接口只是副产品。比如curl或者ffmpeg,我们可以使用这些可执行文件完成大部分业务。这就像人体需要糖分,我们可以直接食用饱含糖的香蕉。而不需要去把香蕉的基因和人类的基因“融合”,产生出一个只要晒晒太阳喝喝水就能产生糖的“转基因人”。

说了这么多,可能有人会说,不就是直接启动工具并接管输出么?不错,是这样的。但是我们追求可以更高点。

在最近工作中,我们就遇到这样场景。我提出一种“进程池”模型,即:这些工具是以独立进程运行的;这些进程组成一个动态可管理的池子。

这相对于“线程池”来说,就是新瓶装旧酒,没什么新意。但是从实现的角度来说,还是存在一些可以挖掘的技术点。比如一般工具都是在运行一次后就退出了,那就意味着工具进程频繁的生死。怎么解决这个问题?这就是本文要探讨的一个技术方案。

目前我想到的一个方案就是托管工具的主函数,然后替换成我们的函数。我们的函数负责和父进程通信传递请求(之前是通过命令行的方式)和结果,并且调用原来的主函数。

一套使用注入和Hook技术托管入口函数的方案

这个方案一个基础的技术点便是:如何托管工具的主函数?

首先我们需要明确一些基础知识。

  1. Main函数是主函数么?不一定。Main函数只是一种约定,我们的程序并不一定需要一个叫做main的函数才能运行。这块可以参见编译链接等知识。
  2. 主函数是进程运行的第一个函数么?不是。在调用主函数之前,系统还要做很多预分配等工作。这块可以参见进程启动的原理等知识。
  3. 哪些我们可以定制的行为可以在主函数之前执行?这个问题如果换做一道经典的面试题就是“全局变量是在什么时候被构建的”?我想大家已经知道了答案了。

因为只是一个方案的调研,我们先把问题简化。不求这个方案可以满足所有场景,但求大部分场景可以覆盖。于是本文的方案将基于一个假设:工具的主函数就是main。对于例外的场景,只要替换方案中寻找主函数的逻辑即可。

linux 系统中,我们启动另外一个可执行文件是通过fork和exec系列函数实现的。fork完之后,进程的代码空间还和主进程一样。exec系列函数被执行后,进程的代码空间就变成目标文件的了。这段割裂让我们无法常规的使用主进程中的代码去干预子进程。然而干预必须存在,否则怎么替换子进程的主函数?

这就需要使用注入技术了。注入分为提前注入和普通注入,提前注入要求在主函数执行之前注入。很明显我们需要提前注入,因为子进程主函数执行起来后,我们如何找到时机将流程切换到我们的“替换的主函数”中就是个比较困难的问题。关于这块的技术方案,我曾经写过一个windows下的系列。感兴趣的同学可以参见 《VC下提前注入进程的一些方法1——远线程不带参数 》, 《VC下提前注入进程的一些方法2——远线程带参数》《VC下提前注入进程的一些方法3——修改程序入口点》《VC提前注入.net软件的方法》

在linux下,一种常见的方案是使用ptrace。这块方案已经比较成熟,我就不再展开。除了这个之外,还有种比较简单的方案,就是使用LD_PRELOAD。使用过gperftools的同学应该对这个方案有点熟悉,它可以让我们检测没有“基因重组”gperftools的库的程序。但是它有个限制,要求进程动态加载libc.so。

在方案确定可行的情况下,我选用LD_PRELOAD。因为这只是调研,先把整体流程走通再说。最终我们会替换到一些终极方案,比如ptrace。

我们直接看主进程的代码

#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

int main(int argc, char * argv[]) {
    std::cout << "parent main start" << std::endl;

    pid_t child = 0;
    int status = 0;
    child = fork();
    if (child == -1) {
        std::cerr << "fork error" << std::endl;
        return -1;
    }
    if (child == 0) {
        //in child process
        char* newargv[] = { "./child", "hello", "world", nullptr };
        char* newenviron[] = { "LD_PRELOAD=./inject.so", nullptr };
        execve("./child", newargv, newenviron);
    }
    else {
        //in parent process
        wait(&status);
        std::cout << "child status=" << WEXITSTATUS(status) << std::endl;;
    }
    return 0;
}

主进程逻辑很简单,就是启动同录下一个叫做child的程序,并传递hello world这两个参数。同时使用LD_PRELOAD让子进程提前加载同目录下的inject.so文件。其编译指令是

g++ parent.cpp -ldl -o parent

子进程更简单,只是输出输入的参数,然后退出。

#include <iostream>
#include <dlfcn.h>

int main(int argc, char** argv) {
    std::cout << "client main start" << std::endl;
    for (int i = 0; i < argc; i++) {
        std::cout << "argv[" << i << "]\t" << argv[i] << std::endl;
    }
    std::cout << "client main end" << std::endl;
    return 0;
}

但是其编译指令有点特别,需要额外加入-rdynamic。这是为了方便在注入模块中比较简单的获取主函数——main的地址。此时需要指出的是,这只是一个便捷的方案,而不是必要条件。因为如果我们限制了工具的编译方式,将极大限制这套方案的适用性。当然不可否认的是,寻找一个普遍适用的主函数地址并不是一件容易的事。目前我可能想到的替代方案是,通过hook libc库中的__libc_start_main,从其第一个参数中获取主函数地址。

g++ child.cpp -ldl -rdynamic -o child

现在我们看下注入的模块的代码

#include <iostream>
#include <dlfcn.h>
#include <limits.h>
#include <unistd.h>
#include <stdio.h>

#include <iostream>
#include <dlfcn.h>
#include <limits.h>
#include <unistd.h>
#include <stdio.h>

extern "C" {
#include "funchook.h"
}

typedef int (*main_func)(int, char**);
main_func g_main_ori = nullptr;

int main_stub(int argc, char** argv) {
    std::cout << "main_stub start" << std::endl;
    for (int i = 0; i < argc; i++) {
        std::cout << "main_stub argv[" << i << "]\t" << argv[i] << std::endl;
    }

    while (true) {
        sleep(1);
        g_main_ori(argc, argv);
    }
    return 0;
}

class Inject {
public:
    Inject() {
        std::cout << "inject OK" << std::endl;

        void *handle = dlopen(NULL, RTLD_NOW|RTLD_GLOBAL);
        if (!handle) {
            std::cerr << "dlopen error" << std::endl;
        }
        else {
            std::cout << "cur module address:" << handle << std::endl;
        }

        void *ptr = dlsym(handle, "main");
        if (ptr) {
            std::cout << "inject main ptr:" << ptr << std::endl;
        }
        else {
            std::cerr << "inject get main ptr error" << std::endl;
        }

        funchook_t *funchook = funchook_create();
        g_main_ori = (main_func)ptr;
        int rv = funchook_prepare(funchook, (void**)&g_main_ori, (void*)main_stub);
        if (rv) {
            std::cerr << "funchook_prepare error " << rv << std::endl;
        }

        rv = funchook_install(funchook, 0);
        if (rv) {
            std::cerr << "funchook_install error " << rv << std::endl;
        }
    }
};

class InjectHolder {
public:
    InjectHolder() {
    };
private:
    Inject _inject;
};

static InjectHolder g_inject_holder;

第76行,我们定义了一个InjectHolder的全局变量。它在被注入进程的main函数之前被初始化。由于其包含Inject类的对象也将被初始化,这将触发其构造函数的执行。在Inject的构造函数中,我们将完成Hook主函数的功能。

第38到52行,我们试图从当前进程空间中获取main函数地址。使用dlsym只是一个简便方案,它需要子进程编译时使用-rdynamic。当然我们可以找到比较终极的寻找方案以去掉该限制。

第54到64行,我们试图使用自定义的main_stub函数替换原来的main函数。

第20到31行,我们定义的main_stub函数输出主进程传递过来的参数后,在一个死循环中调用原来的main函数。让main函数成为我们的一个子函数,并且可以保证进程不退出。

hook方案的选择我折腾了一段时间。首先我选用的是subhook库( https://github.com/Zeex/subhook.git )。很不幸。我发现这个库有着严重的问题,特别是处理64位程序时,基本可以认为是不可用。经过调试和对比内存变化,我发现其本质的缺陷是64位下,部分地址偏移算的有问题。我并不打算去研究这块,所以放弃寻找修复的方案。

最终,我找到funchook( https://github.com/kubo/funchook.git )。经验证,它在64位系统下是可用的。由于它编译产出是一个so文件,而我并不希望我们项目最终发布时需要发布多个so,于是就通过修改其Makefile文件,让其编译出一个静态库。然后inject.so“基因重组”这个libfunchook。

g++ inject.cpp -lfunchook -Wl,-rpath,../funchook/lib -L../funchook/lib -I../funchook/include -ldl -fPIC -rdynamic -shared -o inject.so

最后我们看下执行的效果

一套使用注入和Hook技术托管入口函数的方案

子进程main函数被我们托管了,从而子进程不再退出。这样我们就实现了进程池的基础关键技术。

作为对比,我们尝试在child编译时去掉-rdynamic参数,以使hook失败。这样的执行结果是:子进程执行一次主函数后便退出了。

一套使用注入和Hook技术托管入口函数的方案

最后说一下,这个托管主函数的方案不是十全十美。因为有些程序通过注册信号回调干了很多事,而这套方案只适用于那些相对正常的程序。

大家可以从 https://github.com/f304646673/hookmain.git 获取测试的代码。


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Concepts, Techniques, and Models of Computer Programming

Concepts, Techniques, and Models of Computer Programming

Peter Van Roy、Seif Haridi / The MIT Press / 2004-2-20 / USD 78.00

This innovative text presents computer programming as a unified discipline in a way that is both practical and scientifically sound. The book focuses on techniques of lasting value and explains them p......一起来看看 《Concepts, Techniques, and Models of Computer Programming》 这本书的介绍吧!

MD5 加密
MD5 加密

MD5 加密工具

html转js在线工具
html转js在线工具

html转js在线工具

HEX HSV 转换工具
HEX HSV 转换工具

HEX HSV 互换工具