原理
LD_PRELOAD
是一个在 Linux 系统中非常强大的环境变量
LD_PRELOAD
是 Linux 动态链接器 ld.so
提供的一项功能。其工作原理是:当程序运行时,操作系统会加载它所需的共享库(.so 文件),而 LD_PRELOAD
可以在加载这些库之前指定先加载哪些库。这就意味着:
- 如果在
LD_PRELOAD
中列出的库与程序原本需要的库存在相同的函数定义,那么LD_PRELOAD
中的函数会覆盖原本库中的相应函数实现。 - 如果
LD_PRELOAD
中的库没有覆盖原本的函数,那么程序会正常地继续加载它需要的库。
我们通过环境变量 LD_PRELOAD 劫持系统函数,可以达到不调用 PHP 的各种命令执行函数(system()、exec() 等等)仍可执行系统命令的目的。
注意
能够上传自己的.so
文件
能够控制LD_PRELOAD环境变量的值,比如putenv()
函数
因为新进程启动将加载LD_PRELOAD中的.so文件,所以要存在可以控制PHP启动外部程序
的函数并能执行,比如mail()
、imap_mail()
、mb_send_mail()
和error_log()
函数等
动态链接机制的工作原理
当你在 Linux 系统中运行一个程序时,程序通常会依赖多个共享库(共享对象 .so
文件)。动态链接器(ld.so
)的任务就是在程序启动时找到并加载这些共享库,同时解析程序中需要的函数符号(函数的地址和实现)。这个过程大致包括以下几个步骤:
- 程序启动时的符号解析:程序代码中如果需要调用某个函数(例如
b()
),链接器会根据程序运行时的动态库依赖,去找到包含这个函数的共享对象(如c.so
)。 - 符号查找顺序:当动态链接器解析符号(例如函数
b()
)时,它遵循一定的顺序。这就决定了程序会首先找到哪个库中的b()
实现。
LD_PRELOAD
如何改变符号查找顺序
通常情况下,动态链接器按照以下步骤查找符号:
- 程序内部定义的符号:首先,动态链接器会在程序内部查找符号(函数、变量等)。
- 依赖的共享库:如果程序没有定义这个符号,链接器就会去查找程序依赖的共享库。例如,如果程序依赖
c.so
,就会在c.so
中寻找b()
的实现。 - 标准系统库:如果依赖库中也没有找到,动态链接器会继续在标准系统库(如 libc.so 等)中查找。
然而,当设置了 LD_PRELOAD
环境变量时,动态链接器的查找顺序发生了改变:
LD_PRELOAD
指定的库优先级最高:如果LD_PRELOAD
设置了一个或多个共享库(如c_evil.so
),那么在符号解析时,动态链接器会首先在这些预加载的库中寻找符号。- 如果在
LD_PRELOAD
指定的库中找到了目标符号(如b()
),那么这个符号会覆盖原本系统中默认共享库中的符号。
这意味着,在
LD_PRELOAD
设置了c_evil.so
的情况下,动态链接器会优先在c_evil.so
中查找函数b()
的定义,而不会去查找系统的c.so
中的b()
函数。
编写一个原型为 uid_t getuid(void); 的 C 函数,内部执行攻击者指定的代码,并编译成共享对象 getuid_shadow.so;
攻击流程
攻击流程
1. 编写 getuid()
函数并编译为共享对象
攻击者编写一个和标准库中 getuid()
函数具有相同签名的函数。getuid()
是一个标准的 POSIX 系统调用,它返回调用进程的用户 ID。因为许多程序(如 sendmail
)在运行过程中会调用这个函数,所以攻击者可以利用它作为一个劫持的目标。
攻击者编写的 getuid()
函数不仅返回用户 ID,还可以包含恶意代码,如执行系统命令:
1 |
|
编译成共享对象(动态链接库),以便之后通过 LD_PRELOAD
进行加载:
1 | gcc -fPIC -shared -o getuid_shadow.so getuid_shadow.c |
- **
-fPIC
**:表示生成位置无关代码(Position Independent Code),这是创建共享库时所需的。 - **
-shared
**:表示编译成一个共享对象.so
文件。
生成的 getuid_shadow.so
是一个包含攻击者自定义 getuid()
实现的共享对象。
2. 使用 putenv()
设置 LD_PRELOAD
环境变量
接下来,攻击者利用 PHP 的 putenv()
函数来设置环境变量 LD_PRELOAD
,从而劫持程序对 getuid()
函数的调用。
1 | putenv("LD_PRELOAD=/path/to/getuid_shadow.so"); |
LD_PRELOAD
是一个环境变量,它告诉 Linux 动态链接器在加载任何共享库之前,首先加载LD_PRELOAD
指定的共享对象。这样,当任何新进程启动时,系统首先会加载攻击者指定的共享对象getuid_shadow.so
,然后使用这个共享对象中实现的getuid()
函数,而不是系统库中的getuid()
。- 使用 PHP 的
putenv()
函数可以在 PHP 进程的环境中设置这个变量,影响后续的进程(如sendmail
)加载的共享库顺序。
3. 利用 mail()
函数触发新进程的创建
接下来,攻击者调用 PHP 的 mail()
函数。虽然 PHP 的常规命令执行函数(如 system()
、exec()
)可能已被禁用,但 mail()
函数通常仍然可以使用,并且 mail()
函数内部会启动 sendmail
进程来发送邮件。
1 | mail("example@example.com", "Test Subject", "Test Message"); |
mail()
函数是 PHP 内置的一个函数,用于发送电子邮件。在内部,mail()
函数会调用系统的邮件传送代理(如sendmail
),这会启动一个新的进程/usr/sbin/sendmail
。sendmail
在其执行过程中,通常会调用系统函数getuid()
来获取当前用户的 UID。
4. LD_PRELOAD
的作用:劫持 getuid()
函数
由于 putenv()
已经将 LD_PRELOAD
设置为攻击者的共享对象 getuid_shadow.so
,所以当 sendmail
进程启动并调用 getuid()
函数时,系统会首先加载并使用 getuid_shadow.so
中的 getuid()
函数,而不是标准库中的 getuid()
。
因此,sendmail
进程在执行 getuid()
时,实际上调用的是攻击者的恶意版本:
1 | system("/bin/sh"); // 启动一个 shell |
这会执行攻击者指定的恶意操作(如启动 shell,执行其他系统命令等)。通过这种方式,攻击者绕过了 PHP 中对系统命令执行函数的限制,而成功执行了操作系统级别的命令。
限制
一是,某些环境中,web 禁止启用 sendmail、甚至系统上根本未安装 sendmail,也就谈不上劫持 getuid(),通常的 www-data 权限又不可能去更改 php.ini 配置、去安装 sendmail 软件;
二是,即便目标可以启用 sendmail,由于未将主机名(hostname 输出)添加进 hosts 中,导致每次运行 sendmail 都要耗时半分钟等待域名解析超时返回,www-data 也无法将主机名加入 hosts(如,127.0.0.1 lamp、lamp.、lamp.com)。
大佬yangyangwithgnu的破局
在加载时就执行代码(拦劫启动进程),而不用考虑劫持某一系统函数
回到 LD_PRELOAD 本身,系统通过它预先加载共享对象,如果能找到一个方式,在加载时就执行代码,而不用考虑劫持某一系统函数,那我就完全可以不依赖 sendmail 了。这种场景与 C++ 的构造函数简直神似!
https://github.com/yangyangwithgnu/bypass_disablefunc_via_LD_PRELOAD
GCC 有个 C 语言扩展修饰符 __attribute__((constructor))
,可以让由它修饰的函数在 main() 之前执行,若它出现在共享对象中时,那么一旦共享对象被系统加载,立即将执行 __attribute__((constructor))
修饰的函数。
例子:
1 |
|
1 | gcc -shared -o libexample.so -fPIC libexample.c |
1 |
|
**RTLD_LAZY
**:表示惰性解析符号。也就是说,只有在符号(函数或变量)真正被使用时,才会解析该符号。这种方式提高了性能,特别是在库中有很多函数时,程序只会解析实际需要调用的函数。
另一个常见的标志是 **RTLD_NOW
**,它会立即解析库中的所有符号。如果符号解析失败,dlopen()
将立即返回错误。
dlsym()
用于获取共享库中指定函数的地址。它的第一个参数是 dlopen()
返回的句柄,第二个参数是要查找的函数名称(以字符串形式给出)。在这里,查找的是 libexample.so
中的 my_function
函数。
如果找到了指定的函数,dlsym()
返回该函数的地址,程序可以通过函数指针 func
调用它。如果没有找到指定的符号,dlsym()
返回 NULL
,并通过 dlerror()
获取错误信息。
dlclose()
用于关闭通过 dlopen()
打开的共享库,并释放其相关资源。关闭后,句柄 handle
将不再有效,程序不能再通过它访问共享库中的符号。
如果不调用 dlclose()
,共享库会一直保持在内存中,直到程序退出。
1 | gcc -o main main.c -ldl |
通过 LD_PRELOAD 劫持了启动进程的行为,劫持后又启动了另外的新进程,若不在新进程启动前取消 LD_PRELOAD,则将陷入无限循环,所以必须得删除环境变量 LD_PRELOAD。最直观的做法是调用 unsetenv("LD_PRELOAD")
,这在大部份 linux 发行套件上的确可行,但在 centos 上却无效,究其原因,centos 自己也 hook 了 unsetenv(),在其内部启动了其他进程,根本来不及删除 LD_PRELOAD 就又被劫持,导致无限循环。所以,我得找一种比 unsetenv() 更直接的删除环境变量的方式。是它,全局变量 extern char** environ
!实际上,unsetenv() 就是对 environ 的简单封装实现的环境变量删除功能。
项目中有三个关键文件,bypass_disablefunc.php、bypass_disablefunc_x64.so、bypass_disablefunc_x86.so。
bypass_disablefunc.php 为命令执行 webshell,提供三个 GET 参数:
1 | http://site.com/bypass_disablefunc.php?cmd=pwd&outpath=/tmp/xx&sopath=/var/www/bypass_disablefunc_x64.so |
一是 cmd 参数,待执行的系统命令(如 pwd);二是 outpath 参数,保存命令执行输出结果的文件路径(如 /tmp/xx),便于在页面上显示,另外该参数,你应注意 web 是否有读写权限、web 是否可跨目录访问、文件将被覆盖和删除等几点;三是 sopath 参数,指定劫持系统函数的共享对象的绝对路径(如 /var/www/bypass_disablefunc_x64.so),另外关于该参数,你应注意 web 是否可跨目录访问到它。此外,bypass_disablefunc.php 拼接命令和输出路径成为完整的命令行,所以你不用在 cmd 参数中重定向。
bypass_disablefunc_x64.so 为执行命令的共享对象,用命令 gcc -shared -fPIC bypass_disablefunc.c -o bypass_disablefunc_x64.so
将 bypass_disablefunc.c 编译而来。 若目标为 x86 架构,需要加上 -m32 选项重新编译,bypass_disablefunc_x86.so。
想办法将 bypass_disablefunc.php 和 bypass_disablefunc_x64.so 传到目标,指定好三个 GET 参数后,bypass_disablefunc.php 即可突破 disable_functions。执行 cat /proc/meminfo
:
注意
对于bypass_disablefunc.php,权限上传到web目录的直接访问,无权限的话可以传到tmp目录后用include等函数来包含,并且需要用 GET 方法提供三个参数:
Antsword有插件。
我们选择LD_PRELOAD
模式并点击开始按钮,成功后蚁剑会在/var/www/html
目录里上传一个.antproxy.php
文件。我们创建副本, 并将连接的 URL shell 脚本名字改为.antproxy.php
获得一个新的shell,在这个新shell里面就可以成功执行命令了。
About this Post
This post is written by void2eye, licensed under CC BY-NC 4.0.