WMCTF有一道类似于online php的题make php great again2。赛后看这个题还挺有意思的,趁着空闲时间调一下。
先丢出来代码和结论
<?php
require_once('/Users/language/php74/bin/flag.php');
require_once('/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/language/php74/bin/flag.php');
使用>=32个长度的根目录symlinks可以绕到根目录,在linux下一般/proc/self/root为根目录的symlinks,require_once或者include_once都可以实现,只要对于标准文件名有操作的函数均有此特性,准确的说底层经过tsrm_realpath_r
函数处理都有此问题(下文分析)
首先php是如何判断是否已经require/include过呢?追一下主要的代码逻辑
resolved_path经过文件名处理函数得倒,对文件名处理的最终逻辑在tsrm_realpath_r
函数中实现,该函数递归调用自己,逐级遍历每个文件夹,并读取文件信息存放在st结构体中。在函数中设置了一个标志位叫save
,注意它是这个bug产生的核心点,对save值最重要的操作如下
其中path变量的值如下
"/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/fuck/Users/language/php74/bin/flag.php"
/Users/fuck
是我链接的根目录,即/Users/fuck->/
。此时它进入了判断且save=1,说明php_sys_lstat
返回值为-1,然而只有当文件不存在时lstat才可能为-1的。
之后我尝试cat下文件找到了原因:因为链接的长度超出了限制,被os捕获后将lstat置为-1,从下图也可以看出来
接着往下走,判断是否if save=1&if 此文件夹为软链接
,同时满足条件则返回真实的链接地址,再对该地址递归,否则直接向下递归(直接递归逻辑在#endif下)。
#else
if (save && S_ISLNK(st.st_mode)) {
if (++(*ll) > LINK_MAX || (j = (size_t)php_sys_readlink(tmp, path, MAXPATHLEN)) == (size_t)-1) {
/* too many links or broken symlinks */
free_alloca(tmp, use_heap);
return (size_t)-1;
}
path[j] = 0;
if (IS_ABSOLUTE_PATH(path, j)) {
j = tsrm_realpath_r(path, 1, j, ll, t, use_realpath, is_dir, &directory); //按照软链接的目录继续递归
if (j == (size_t)-1) {
free_alloca(tmp, use_heap);
return (size_t)-1;
}
} else {
if (i + j >= MAXPATHLEN-1) {
free_alloca(tmp, use_heap);
return (size_t)-1; /* buffer overflow */
}
memmove(path+i, path, j+1);
memcpy(path, tmp, i-1); //cpy last
path[i-1] = DEFAULT_SLASH;
j = tsrm_realpath_r(path, start, i + j, ll, t, use_realpath, is_dir, &directory);
if (j == (size_t)-1) {
free_alloca(tmp, use_heap);
return (size_t)-1;
}
}else {
if (save) {
directory = S_ISDIR(st.st_mode);
if (link_is_dir) {
*link_is_dir = directory;
}
if (is_dir && !directory) {
/* not a directory */
free_alloca(tmp, use_heap);
return (size_t)-1;
}
}
#endif
//判断是否遍历到最头的目录
if (i <= start + 1) {
j = start;
} else {
/* some leading directories may be unaccessable */
j = tsrm_realpath_r(path, start, i-1, ll, t, save ? CWD_FILEPATH : use_realpath, 1, NULL); //递归判断目录
if (j > start && j != (size_t)-1) {
path[j++] = DEFAULT_SLASH;
}
}
若save=1但当前目录不是软链,就通过st.mode是否为合法的”文件夹”来进行异常终止。举个例子,如果你在php中执行include(“/root/a.php/aaa”)。在判断a.php时发现不是directory类型,因此退出递归依次return -1,抛出一个fatal error,说明文件寻找失败。也就是说只要你的目录是合法的,这一步就不会报错。即使文件不存在,那也是zend后面去执行open会报的fatal erorr。
如果是合法的文件地址,则记录当前“目录名”的数组长度,如”flag”的长度为5,用以在退栈时以此按数组长度恢复目录名。核心代码如下。
memcpy(path+j, tmp+i, len-i+1);
j += (len-i);
这点我们也不用太在意它具体实现,只需要知道每次合法的目录名都会在退栈时依次memcpy到path中,最终返回。还记得刚才说过save
参数吗?这里就有两个问题:
1、save值何时为1: 经过测试只有当软链的个数<=32能正常读取,也即save=1
2、进入save=1的逻辑有什么用?: 进入判断后的核心代码如下
if (++(*ll) > LINK_MAX || (j = (size_t)php_sys_readlink(tmp, path, MAXPATHLEN)) == (size_t)-1) {
/* too many links or broken symlinks */
free_alloca(tmp, use_heap);
return (size_t)-1;
}
path[j] = 0;
if (IS_ABSOLUTE_PATH(path, j)) {
j = tsrm_realpath_r(path, 1, j, ll, t, use_realpath, is_dir, &directory);
if (j == (size_t)-1) {
free_alloca(tmp, use_heap);
return (size_t)-1;
}
当save=1,S_ISLNK判断当前目录/Users/fuck/Users/fuck/Users/fuck/xxx/Users/fuck
是symlinks。经过php_sys_readlink后将j置1,因为链接的目录是”/“,这一步就是为了找真正的目录。
之后将path[i]=0
,这是个不得了的操作,意味着之前的path/Users/fuck/Users/fuck/Users/fuck/xxx/Users/fuck
会从/
继续递归,同理如果你的软链是/yourname
就会从/yourname
继续递归,很清晰的代码逻辑。
—分割线—
既然都讲到这里,这个bug怎么产生的就不难理解了:
save只有在symlinks的个数为32时才开始从根目录递归,但是当links超过32,每一层的目录依然会被写到栈上,这就意味着我们可以写很多链接了根目录的symlinks,就能返回不一样的文件名了。假如我们写33个根目录的symlink,退栈时的path值就如下
多出来了一个/Users/fuck/
,链接为根目录,所以不影响后续open找到/Users/language/php74/bin/flag.php
文件
最后附上一个处理文件名的堆栈
php!tsrm_realpath_r (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_virtual_cwd.c:972)
php!virtual_file_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_virtual_cwd.c:1114)
php!expand_filepath_with_mode (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/fopen_wrappers.c:820)
php!expand_filepath_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/fopen_wrappers.c:758)
php!expand_filepath (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/fopen_wrappers.c:750)
php!_php_stream_fopen (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/streams/plain_wrapper.c:1042)
php!php_plain_files_stream_opener (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/streams/plain_wrapper.c:1132)
php!_php_stream_open_wrapper_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/streams/streams.c:2111)
php!php_stream_open_for_zend_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/main.c:1588)
php!php_stream_open_for_zend (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/main/main.c:1581)
php!zend_stream_open (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_stream.c:80)
php!zend_include_or_eval (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_execute.c:4203)
php!ZEND_INCLUDE_OR_EVAL_SPEC_CONST_HANDLER (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_vm_execute.h:4051)
php!execute_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_vm_execute.h:53618)
php!zend_execute (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_vm_execute.h:57920)
php!zend_eval_stringl (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_execute_API.c:1088)
php!zend_eval_stringl_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_execute_API.c:1129)
php!zend_eval_string_ex (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/Zend/zend_execute_API.c:1140)
php!do_cli (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/sapi/cli/php_cli.c:995)
php!main (/Users/Hpdata/CTF工具/PHP源码/php-7.4.8/sapi/cli/php_cli.c:1359)
小记一下当时跟的php include操作逻辑,其实eval/require/include是相似的操作
当去请求读取文件时(无论是include\require\file_get_contents),通过/main/fopen_wrappers.c
的php_resolve_path
函数实现解析,部分代码操作如下
for (p = filename; isalnum((int)*p) || *p == '+' || *p == '-' || *p == '.'; p++);
if ((*p == ':') && (p - filename > 1) && (p[1] == '/') && (p[2] == '/')) {
wrapper = php_stream_locate_url_wrapper(filename, &actual_path, STREAM_OPEN_FOR_INCLUDE);
if (wrapper == &php_plain_files_wrapper) {
if (tsrm_realpath(actual_path, resolved_path)) {
return zend_string_init(resolved_path, strlen(resolved_path), 0);
}
}
return NULL;
}
此时的filename即用户输入,通过判断传入的格式头调用php_stream_locate_url_wrapper
获取php流对象php_stream
最后回到对流的打开操作,如果顺利打开则返回SUCCESS(如果不是流则打开正常的本地文件)
最后一步步退栈,同时生成新的opcode到zend虚拟机执行.
Zend虚拟机引擎执行的操作,是通过一个大循环While不断地对handler进行处理。每处理完一个handler就会执行类似于next()的操作进行下一个handler函数处理,直到opcode被完全处理完。
while (1) {
#if !defined(ZEND_VM_FP_GLOBAL_REG) || !defined(ZEND_VM_IP_GLOBAL_REG)
int ret;
#endif
#if (ZEND_VM_KIND == ZEND_VM_KIND_HYBRID)
HYBRID_SWITCH() {
#else
#if defined(ZEND_VM_FP_GLOBAL_REG) && defined(ZEND_VM_IP_GLOBAL_REG)
((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU);
if (UNEXPECTED(!OPLINE)) {
#else
if (UNEXPECTED((ret = ((opcode_handler_t)OPLINE->handler)(ZEND_OPCODE_HANDLER_ARGS_PASSTHRU)) != 0)) {
....
}
opcodes在寄存器上一顿操作后的结果交由Zend虚拟机的函数 ZEND_ECHO_SPEC_TMPVAR_HANDLER 执行,输出到终端
static ZEND_OPCODE_HANDLER_RET ZEND_FASTCALL ZEND_ECHO_SPEC_CONST_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
USE_OPLINE
zval *z;
SAVE_OPLINE();
z = RT_CONSTANT(opline, opline->op1);
if (Z_TYPE_P(z) == IS_STRING) {
zend_string *str = Z_STR_P(z);
if (ZSTR_LEN(str) != 0) {
zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
}
} else {
zend_string *str = zval_get_string_func(z);
if (ZSTR_LEN(str) != 0) {
zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
} else if (IS_CONST == IS_CV && UNEXPECTED(Z_TYPE_P(z) == IS_UNDEF)) {
ZVAL_UNDEFINED_OP1();
}
zend_string_release_ex(str, 0);
}
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}
比如php语句如下,最后的栈顶调用为write
//test.php
<?php echo "123";?>
//debug.php
<?php require_once('test.php');?>