linux的so注入与热更新原理

简介

之前写了个hookso的工具,用来操作linux进程的动态链接库行为,本文从so注入与热更新入手,简单讲解一下其中的原理,配合源码阅读效果更佳。

原理

不管是热更新so还是其他方式操作so,都要先注入才行。所以先考虑如何注入so。
其实往一个进程注入so的方法,很简单,让进程自己调用一下dlopen即可。这个就是基本原理,剩下的事情,就是如何让他调用。
那么如何操作?这里要介绍一下linux的ptrace函数。

ptrace

ptrace很多人也用过,大致意思就是拿来控制其他进程的,读写内存,读写寄存器,下断点,追踪系统调用,相当于可编程版gdb,实际上gdb就是基于ptrace实现。
ptrace的定义如下:

#include <sys/ptrace.h>
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);

通过设置request的值,来实现具体的操作,本文用到的大部分如下:

PTRACE_ATTACH:关联上目标进程
PTRACE_GETREGS:读目标进程寄存器
PTRACE_SETREGS:写目标进程寄存器
PTRACE_PEEKTEXT:读目标进程内存数据
PTRACE_POKETEXT:写目标进程内存数据
PTRACE_CONT:目标进程继续
PTRACE_DETACH:断联目标进程

比如设置PTRACE_PEEKTEXT,就能把目标进程的某个地址的内存读到本进程。

用户函数调用

前面说到,我们希望让目标进程调用dlopen(target.so),来实现target.so的注入。抽象出来,就是如何让目标进程调用一个用户函数(即,非系统调用的函数)。
那么如何调用?可以拆分为两步,第一步找到目标函数的地址,第二步调用它。

函数查找

我们知道,linux的可执行文件是elf文件格式,动态链接库其实也是elf格式。关于elf,有很多资料,这里简单讲一下elf结构。

elf

elf全名Executable and Linkable Format,存在的主要目的,就是为了程序的执行。而程序的执行,需要哪些信息呢?

  • 具体做事情的代码,也即代码段,当我们调用到了int add()函数,进去的就是这个地方
  • 为了方便调试或者查找,会把add名字记录下来,与代码段对应上,这样就知道是哪个函数了
  • 对于动态链接库,有的函数是在执行的时候,才能知道地址在哪里,比如main使用了一个头文件定义的函数int add(),最后编译成了main.outadd.so两个elf文件。两个elf之前是相互独立的,那么就需要在main.out记录引用了外部的add函数,add.so里记录导出了add函数

最后这些信息,加上一些乱七八糟的,以一块一块(section)的形式组合而成,就是elf文件了。

查找过程

查找函数地址的过程也分为两步,查找so起始内存地址,查找函数所在so偏移,两者相加就是函数的地址

查找so起始地址

首先,我们要查找某个so的函数,就得先找到so所在的内存位置才行。
例如要调用dlopen,而dlopen是在libc.so中,那么我们第一步就是要找到libc.so所在内存的地址。
好在linux很方便的提供了方法,通过cat /proc/进程id/maps,可以看到内存布局

7f3a9a270000-7f3a9a42a000 r-xp 00000000 fc:01 25054                      /usr/lib64/libc-2.17.so
7f3a9a42a000-7f3a9a629000 ---p 001ba000 fc:01 25054                      /usr/lib64/libc-2.17.so
7f3a9a629000-7f3a9a62d000 r--p 001b9000 fc:01 25054                      /usr/lib64/libc-2.17.so
7f3a9a62d000-7f3a9a62f000 rw-p 001bd000 fc:01 25054                      /usr/lib64/libc-2.17.so

这里第一排的7f3a9a270000,就是libc-2.17.so在内存的起始地址。
这里我们先假定elf是完整映射到了内存中,那么只需要分析内存中的elf结构就可以了。实际上某些比较大的如libstdc++.so并不是,对于这种情况,就需要指定具体so的文件路径,解析好函数在文件中的偏移,再加上so内存地址就是函数地址了。

查找函数所在so偏移

前面我们找到了so的起始地址,也分析了elf格式,剩下的就是照着elf关系图,通过名字查找函数了。具体的查找关系图如下:

简单讲解一下上图

  • 首先elf头里,记录了存着section name table(节头字符表)在哪里。
  • 找到节头字符表,就能知道这些section具体的类型。
  • 接着找到dynsym(动态链接符号表),即导出给外部用的函数信息,跟着用dynstr定位这些符号的名字,这一步就能定位有没有想找的函数了,比如在libc里找到dlopen(实际上是__libc_dlopen_mode)。
  • 如果找的是foo1,那么就能通过dynsym里的st_shndx字段,找到代码所在的section,那么可以算出,这个函数的地址=elf加载的地址+section所在elf的偏移。
  • 如果找的是foo2,foo2是在另一个elf中定义的,例如之前提到的,调用add.so函数的add函数。那么就需要左边的rela.plt(重定向信息)以及got.plt(位置偏移信息)。
  • 当发现foo2在dynsym里的st_shndx字段是undef时,通过index定位到rela.plt中的位置,进一步取到偏移表的位置,这个位置的值,指向了foo2的函数地址。

这里派生出几个问题

  1. 为什么要动态链接?
    为了解决重复代码、更新难的问题,把代码按模块分开。(实际上linux各种运行时库的版本也很难受)
  2. 为什么不做成机器码直接jmp就好了?
    机器码里直接jmp,但是事先不知道目标地址,所以只能填空,这样又不好与正常代码区分。所以搞一个plt的地方,来做这个事情。
  3. plt怎么运行的?
    写一个so,这个so只是调用了下puts函数,然后objdump观察机器码。
    可以看到调用puts的地方,实际上是调用了puts@plt,即plt的某个位置

往上找一找,找到puts@plt的定义,即0x580的位置,可以看到机器码如下:

第一行jmpq通过got的值跳转,在初始时got的值直接为下一行,即0x586,于是开始执行第二行。第二行和第三行传参调用libc完成了绑定puts的过程,并且更新got。
后续再调用第一行,就直接跳转到了目标函数了。

  1. 为什么plt里不直接存放地址,要搞个got?
    理论上是可以got里的每个地址拆分放到plt中,可能是出于逻辑与数据分离考虑,并且分开后内存页的读写权限更好管理,毕竟一个是可执行,一个是可写。

到这里,函数的地址就拿到了,如果是外部函数,还知道了存放函数地址的指针在哪,即got的位置,这个后面做替换的时候会用到。

函数调用

在前面我们已经拿到了函数的地址了,剩下的就是修改目标进程的寄存器与内存。
通过查阅资料可知,linux amd64调用函数,用到的寄存器及含义如下:

  • rdi:参数1
  • rsi:参数2
  • rdx:参数3
  • rcx:参数4
  • r8:参数5
  • r9:参数6
  • rax:函数地址
  • rbp:栈底地址
  • rsp:栈顶地址
  • rip:执行代码地址

比如我们要调用dlopen("target.so"),dlopen的地址前面我们已经拿到,但是参数"target.so"是一个字符串,寄存器里存放的是字符串的地址,而目标进程中并没有这个内存,怎么办?同时函数运行需要的栈空间,也需要内存,怎么获取?
解决方法是调用一下系统调用mmap来申请内存,抽象一下,就是如何让目标进程调用系统调用

系统调用

系统调用比较简单,查阅相关资料,系统调用的寄存器及含义如下:

  • rdi:参数1
  • rsi:参数2
  • rdx:参数3
  • r10:参数4(注意这里和用户函数不一样)
  • r8:参数5
  • r9:参数6
  • rax:系统调用号(如write是1)
  • rip:执行代码地址

mmap的定义为

void *mmap(void *addr, size_t length, int prot, int flags,
                  int fd, off_t offset);

第一个参数填0表示系统分配,其他都是int,参数很好解决。系统调用号填9。都准备好,让目标进程执行一个syscall指令就开始调用了。
剩下的问题就是rip怎么处理?以及如何拿到返回值?

函数执行

我们期望函数能够跑某段机器码,即设置一个rip。如前所述,申请内存的方式要调用函数,陷入了鸡生蛋的轮回。
有个方法是直接修改elf的某段可执行内存,改完再复原。
这里可以取巧,使用elf头部的8字节无用内存,定义为

Elf64_Ehdr e_ident[8-16]

所以我们就用这8个字节,来作为函数调用需要的机器码存放地址。在数组里写入一个syscall指令

[0x0f, 0x05]

函数返回值

当目标进程执行完syscall后,如何断住,能让本进程拿到返回值,比较简单,直接在前面的code空间里,写入int3断点指令,再填满无用指令nop

[0x0f, 0x05, 0xcc, 0x90, 0x90, 0x90, 0x90, 0x90]

这样当目标进程执行到0xcc时,会发出SIGTRAP信号,ptrace它的本进程就会收到信号断住,类似于gdb的断点。
这时候就可以获取寄存器的rax值,拿到返回值。对于mmap,就是实际的内存地址。

函数调用尾声

在前面,我们找到了函数地址,一系列系统调用,准备好了执行环境,剩下的事情就是调用我们想要的函数了。
这里调用方式与返回值获得,其实和系统调用没啥区别,就不再赘述。
总结系统调用与用户函数调用,如下图所示:

  • 系统调用

  • 用户函数调用

到了这里,我们已经完成了用户函数调用,也即完成了so的注入。下一步就开始具体的热更新操作了。

用户函数热更新

如前所述,我们可以随意注入so到某个进程,也能找到某个so的某个函数的地址。那么热更新其实比较简单。这里分为了两种,分别是内部函数、外部函数。

内部函数

还是回到前面的例子,例如main加载了add.so,执行add.soadd函数,我们期望以后调用add都变成addnew.soaddnew函数。这种addadd.so内部定义,这种替换方式就叫内部函数替换。
那么如何替换呢?很简单,注入addnew.so,找到addnew.soaddnew函数地址。然后修改add函数的机器码,写一个jmp到addnew函数。
替换的代码如下:

int offset = (int) ((uint64_t) new_funcaddr - ((uint64_t) old_funcaddr + 5));

char code[8] = {0};
code[0] = 0xe9;
memcpy(&code[1], &offset, sizeof(offset));

外部函数

刚才的例子,假设add.soadd函数,调用了c标准库libc.soputs函数打印结果,我们期望不要调用puts,改为自定义的putsnew。这种putsadd.so外部定义,这种替换方式就叫外部函数替换。
那么如何替换呢?很简单,注入查找新的函数地址,直接把新的函数地址写入got即可。
注意这里的修改只对add.so生效,其他so调用puts还是不变。
代码如下:

// func out .so
ret = remote_process_write(pid, old_funcaddr_plt, &new_funcaddr, sizeof(new_funcaddr));
if (ret != 0) {
    close_so(pid, handle);
    return -1;
}

图示

两种替换的示意图如下:

Lua绑定热更新

前面我们已经完成了常见的函数热更新,对于某些项目,比如Lua,会将函数地址与字符串做一个映射,然后存到一个map中。
这种情况,修改got已经不能满足了,因为map存放的是最终地址,只能修改函数机器码jmp。
假如有100个函数,那么就要修改100次,对于导出lua函数比较多的so来说,会很麻烦,特别是类成员函数的名字还很复杂。
所以最好能直接注入一个新的so,重新绑定一下,将map中的地址替换为新的函数地址。

隐含问题

这里有几个问题:

  1. 如何拿到lua_State * L
    众所周知,Lua的数据都是保存在L中,除非搞一个全局变量,不然我们调用绑定函数的时候,需要指定L,如rebind(lua_State * L)
  2. rebind函数调用时机?
    因为我们ptrace的时候,目标进程会处于任意状态,如果直接调用rebind,会导致Lua的重入,要么死锁要么core掉。选择一个调用时机很重要。

解决方案

  1. 如何拿到lua_State * L
    关于拿到L的问题,我们只需要让目标进程在执行某个Lua函数的时候断住,然后获取它的参数,就能拿到L。至于如何断住,和前面的函数调用类似,直接在目标函数的入口写入一个int3即可。
  2. rebind函数调用时机?
    调用时机也可以顺便在这里一起解决,我们做一个触发的机制,当目标进程执行某个函数的时候,让他停住,先执行一个新的函数。

具体示意图如下:

最终效果,我们在lua_settop的地方断住,此时可以认为Lua的栈是稳定的,我们只要保证执行后,Lua栈一致即可。当断住后,拿到第一个参数L,执行rebind(lua_State * L)完成重新绑定。

本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
linux的so注入与热更新原理
之前写了个hookso的工具,用来操作linux进程的动态链接库行为,本文从so注入与热更新入手,简单讲解一下其中的原理,配合源码阅读效果更佳。
<<上一篇
下一篇>>