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 | int php_module_startup(sapi_module_struct *sf, zend_module_entry *additional_modules, uint32_t num_additional_modules){ |
最终找到"真正"的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 | PHP_MINIT_FUNCTION(phar) |
跟啊跟,最终跟到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_open
为php_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 | PHPAPI char *expand_filepath(const char *filepath, char *real_path) { |
这里怎么也有个virtual_file_ex
?我们继续走
竟然顺利通过了。所以我们可以得出一个小结论:是virtual_file_ex
的不一致的表现导致了这个bug。
我们进一步来探讨一下这个不一致性是怎么产生的。
virtual_file_ex
在上面的分析中,可以看到tsrm_realpath
与expand_filepath
在调用virtual_file_ex
分别是这么传参的:
1 | // tsrm_realpath |
这两个宏在源码里是这么解释的:
1 |
二者的区别在于REALPATH调用时必须保证文件存在,不然就会直接返回
1 | if (save && php_sys_lstat(path, &st) < 0) { |
lstat
等等,它是怎么判断文件是否存在的?php_sys_lstat
是什么?
1 |
也就是说只要lstat(path)
小于0,PHP就会认为文件不存在,从而virtual_file_ex(..., CWD_REALPATH)
,即tsrm_realpath
会出问题,而virtual_file_ex(..., CWD_FILEPATH)
虽然"找不到"这个文件,但仍然会返回一个合法的路径。
我们再仔细看看lstat在什么情况下会报错
其中有一条就很有趣:
1 | The lstat() function may fail if: |
在网上查阅了大半个世纪,所有人都说这是通过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的返回值作为文件是否存在的依据
- 在源码分析的过程中还有一个地方可能导致类似的问题,有兴趣的自己看,此处不点明(