WMCTF2020 PHP source analysis

WMCTF 2020中赵师傅出了一道PHP源码审计 Make PHP Great Again
比赛中没有做出来,非常遗憾。

作为一个赛后诸葛亮,趁着赵师傅还没发官方分析,在此水一篇博客分析分析题目

什么事require_once

as always,先看文档
require_once在功能上与require一致,只是对于任意文件都只会包含一次,而require在正常情况下又与include的功能一致。
又到了日常骂文档的时间:require_once的文档告诉我们要到include_once的文档中查看_once的行为(See the include_once documentation for information about the _once behaviour),而include_once又说了几句废话带过去了(As the name suggests, the file will be included just once.)。8愧事PHP

源码分析

Entry

很多人会误以为require/include系列是函数,然而文档都写得很清楚了它们实际上是statement,语句,所以它们并没有通过PHP_FUNCTION宏注册于PHP的函数注册表中。这样的statement总共只有五个,分别是include[_once]require[_once]eval

Zend/zend_vm_opcodes.h中我们可以找到,require/include的opcode是73

而在Zend/zend_vm_def中我们可以看到

可以看到,这个handler的核心在于zend_include_or_eval,接下来我们就从这个函数开始进一步分析

zend_include_or_eval

zend_resolve_path是php API的一部分,也就是说是动态赋值的。
很容易就会发现在zend_startup步骤中出现了
zend_resolve_path = utility_functions->resolve_path_function;
这样的语句,交叉引用看到main.c中:

1
2
3
4
5
6
7
int php_module_startup(sapi_module_struct *sf, zend_module_entry *additional_modules, uint32_t num_additional_modules){
zend_utility_functions zuf;
...
zuf.resolve_path_function = php_resolve_path_for_zend;
zend_startup(&zuf);
...
}

最终找到"真正"的zend_resolve_path函数,然后再琢磨一会才能找到tsrm_realpath_r,但是实际上这里正常的做法是动态调试。
zend_include_or_eval中下断点,下在zend_resolve_path处,单步进入,会发现实际上走到了phar_find_in_include_path,原因是phar拓展拦截了zend_resolve_path函数(装饰器设计模式)。不过没关系,我们还是fallback到了php_resolve_path_for_zend

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PHP_MINIT_FUNCTION(phar)
{
REGISTER_INI_ENTRIES();

phar_orig_compile_file = zend_compile_file;
zend_compile_file = phar_compile_file;

phar_save_resolve_path = zend_resolve_path;
zend_resolve_path = phar_resolve_path;

phar_object_init();

phar_intercept_functions_init();
phar_save_orig_functions();

return php_register_url_stream_wrapper("phar", &php_stream_phar_wrapper);
}

跟啊跟,最终跟到tsrm_realpath_r。但是tsrm_realpath_r这么长不太想看怎么办?
别忘了我们是在动态调试。让我们先看看执行的效果如何

tsrm_realpath返回了NULL。看上去没问题,但是让我们回到zend_include_or_eval。按照开发者的逻辑,tsrm_realpath返回NULL意味着出现了问题,理应抛出一个异常(在PHP中为execute_globals.exception,即EG(exception)),然而纵观源码,此处并没有调用zend_throw_exception抛出异常。

所以我们直接走到了zend_stream_open。这时我们遇到了另一个PHP_API,参考zend_resolve_path,我们能够找到"真正的"zend_stream_openphp_stream_open_for_zend。可以看到它对php_stream_open_wrapper进行了包装,而wrapper又是一个指向_php_stream_open_wrapper_ex(main/streams/streams.c:2057)的宏

跟进来,仍然有对zend_resolve_path的调用

梅开二度,仍然返回NULL,没抛Exception。我们跟到main/streams/plain_wrapper.c中看文件是如何打开的:

也就是说需要经过一次expand_filepath

1
2
3
4
5
6
7
8
9
10
11
12
13
14
PHPAPI char *expand_filepath(const char *filepath, char *real_path) {
return expand_filepath_ex(filepath, real_path, NULL, 0);
}
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);
}
PHPAPI char *expand_filepath_with_mode(const char *filepath, char *real_path, const char *relative_to, size_t relative_to_len, int realpath_mode) {
...
if (virtual_file_ex(&new_state, filepath, NULL, realpath_mode)) {
efree(new_state.cwd);
return NULL;
}
...
}

这里怎么也有个virtual_file_ex?我们继续走

竟然顺利通过了。所以我们可以得出一个小结论:是virtual_file_ex的不一致的表现导致了这个bug。
我们进一步来探讨一下这个不一致性是怎么产生的。

virtual_file_ex

在上面的分析中,可以看到tsrm_realpathexpand_filepath在调用virtual_file_ex分别是这么传参的:

1
2
3
4
// tsrm_realpath
if (virtual_file_ex(&new_state, path, NULL, CWD_REALPATH)) {...}
// expand_filepath
if (virtual_file_ex(&new_state, path, NULL, CWD_FILEPATH)) {...}

这两个宏在源码里是这么解释的:

1
2
#define CWD_FILEPATH 1 /* resolve symlinks if file is exist otherwise expand */
#define CWD_REALPATH 2 /* call realpath(), resolve symlinks. File must exist */

二者的区别在于REALPATH调用时必须保证文件存在,不然就会直接返回

1
2
3
4
5
6
7
8
if (save && php_sys_lstat(path, &st) < 0) {
if (use_realpath == CWD_REALPATH) {
/* file not found */
return (size_t)-1;
}
/* continue resolution anyway but don't save result in the cache */
save = 0;
}

lstat

等等,它是怎么判断文件是否存在的?php_sys_lstat是什么?

1
2
#include <sys/stat.h>
#define php_sys_lstat lstat

也就是说只要lstat(path)小于0,PHP就会认为文件不存在,从而virtual_file_ex(..., CWD_REALPATH),即tsrm_realpath会出问题,而virtual_file_ex(..., CWD_FILEPATH)虽然"找不到"这个文件,但仍然会返回一个合法的路径。
我们再仔细看看lstat在什么情况下会报错

其中有一条就很有趣:

1
2
3
4
5
The lstat() function may fail if:

ELOOP
More than {SYMLOOP_MAX} symbolic links were encountered
during resolution of the path argument.

在网上查阅了大半个世纪,所有人都说这是通过sysconf动态赋值的,只要满足不小于POSIX规定的8即可。可是我找到了一件很搞笑的事情:

https://github.com/torvalds/linux/search?q=MAXSYMLINKS&unscoped_q=MAXSYMLINKS

无敌的Linux竟然是把这个值写死成40的,nb,属实nb

至此,我们有了一个payload,即"/proc/self/root"*21+/flag

payload中:/proc/self/root提供了两层symlink(/proc/self指向/proc/[pid]),也就是说重复21次我们将得到42层symlink,比lstat能够处理的层数多出两层。

总结

  • 在软件开发的过程中,要有一个统一的异常处理机制,不要一会返回0,一会抛异常的
  • 要和一起写代码的沟通好,写好文档(其实virtual_file_ex上面注释里写了,返回0是正常,1是有错,我估计调用的人就没好好看(逃))
  • 要保证一个操作的一致性,比如这个require_once就因为内部前后不一致导致了绕过

备注

  • 源码分析基于PHP 7.4.5,截止8.0.0-beta1 php 仍然使用lstat的返回值作为文件是否存在的依据
  • 在源码分析的过程中还有一个地方可能导致类似的问题,有兴趣的自己看,此处不点明(