0x00 前言
做了一个CTF题目,遇到了一些有趣的东西,所以写了这篇文章记录了一下。 但是我却不明白造成这个问题的原因在哪里,所以不知道给文章起什么标题,就姑且叫这个非常宽泛的名字吧。
0x01 CTF题目原型
在遇到的题目中,最后一步是getshell,大概可以简化为以下代码:
1 |
|
我把这个问题发到phithon的代码审计圈里征求答案,师傅们的答案都在理,但是却不是我想要的那个答案,下面就来一一的分析以下。
0x02 一些不完美的做法
我最开始的想法跟大多数师傅的想法一样
1 | 因为正则表达式中的点(.)不会匹配换行符(0x0a),所以可以在扩展名前面插入一个换行符,构造的文件名为233%0a.php, |
在我本地测试一下,一切都是那么的美好,轻松getshell:
我本地的环境是 mac默认安装的apache+php。
当我在ubuntu上测试的时候傻眼了,
ubuntu上的apache并没有把这个文件当做php文件来解析。这到底是为什么呢?这里先留个坑,一会仔细讲。
这种方法不行之后,很快就有人想到了,会不是是apache2的文件解析漏洞呢?然后我就在ubuntu上测试了一下
apache的版本是2.4.7,并不存在解析漏洞。
然后我又在本地测试了一下。
What fuck!!! 我的本地竟然存在php文件解析漏洞,这笔记本是假的吧,装的apache也是假的吧。版本都是2.4.25了怎么还会存在文件解析漏洞呢?
在我的印象中apache的文件解析漏洞是在2.3.x以下版本才会存在的啊?这到底是为什么?
之后朋友又给我发来了一张图片,我当时就炸毛了,他的版本是2.4.18,并不存在漏洞。我觉得我这apache可能是假的。
经过冷静的思考,我得出结论:Apache文件解析漏洞,可能与apache的版本并没有关系,而是与apache解析php的配置相关。
0x03 再提apache的文件解析漏洞
apache的文件解析漏洞正火的时候,我还没上大学呢,所以也没有真正的去分析产生这种漏洞的原因,一直模糊的认为这是apache本身的问题:Apache 解析文件的规则是从右到左开始判断解析,如果后缀名为不可识别文件解析,就再往左判断。
所以我以为apache后来已经修复了这个bug了,不会再出现文件解析漏洞了。
经过比对,我发现我本地mac上的php5.conf是这样写的:
1 | ➜ ~ cat /private/etc/apache2/other/php5.conf |
而ubuntu上的php5.conf是这样的:
1 | <FilesMatch ".+\.ph(p[345]?|t|tml)$"> |
看ubuntu的配置
1 | <FilesMatch ".+\.ph(p[345]?|t|tml)$"> |
这个正则和题目中的正则是一样的,很容易明白,当文件名和这个正则匹配上之后,就交给mod_php处理。
这就解释了为什么233%0a.php
不会被解析的。
还有下面这段,也禁止解析以.
开头的php文件执行的:
1 | <FilesMatch "^\.ph(p[345]?|t|tml|ps)$"> |
所以我觉得产生文件解析漏洞的根源是这句话:
1 | AddType application/x-httpd-php .php |
为了验证我的想法,我把这句修改为下面这样,然后重启apache
1 | AddType application/x-httpd-php .phtml |
在这种情况下.php后缀已经不再被解析了,而被解析的是.phtml和.phtml.xxxxxxx
所以这样的错误配置才是引起apache 解析漏洞的关键。
最后感悟:无论是apache文件解析漏洞还是nginx文件解析漏洞,本来都不应该是apache,nginx 或者php的锅,它们有的只是功能,而且开发这些功能也是为了方便使用者,而恰好这些功能恰好被一个管理员用在了不恰当的时候,所以才造成了漏洞。
0x04 回到题目中
经过测试发现一个可以再windows和linux上都行得通的方法:
1 | filename=1.php/.&content=<?php phpinfo();?> |
在操作系统中,都是禁止使用/
作为文件名的,但是不知道为什么后面加一个.
就可以成功的写入1.php了。
而且奇怪的是无论是在windows上还是linux上,每次都只可以创建新文件,不能覆盖老文件。要想知道php里面是怎么处理这个路径的,就需要看php源代码了,但是我目前并没有看明白里面的处理逻辑,等我抽个时间分析完了,再做补充吧。
0x5 问题成因分析
经过了一段时间的分析,我终于找到了php在文件路径处理上的问题所在。
由于我对php源码不太熟悉,分析过程踩了一些坑,下面记录一下分析过程。
我用的是php5.6.8
版本记进行分析的,源码可以直接从https://github.com/php/php-src
下载,然后checkout出php5.6.8
版本即可。对于编译过程不再详说,为了方便分析,建议修改MakeFile:58
行为:
1 | CFLAGS_CLEAN = -g -O0 -fvisibility=hidden //去掉优化编译选项 |
现在php源码全局搜索找出file_put_contents
函数的实现入口,在ext/standard/file.c
的579行,发现了下面代码:
1 | PHP_FUNCTION(file_put_contents) |
这就是file_put_contents
函数的入口,在这里下一个断点,然后执行。
1 | ➜ cli git:(php-5.6.8) ✗ gdb -q ./php |
1 | #2.php 的内容如下 |
跟踪到file.c:616行
1 | stream = php_stream_open_wrapper_ex(filename, mode, ((flags & PHP_FILE_USE_INCLUDE_PATH) ? USE_PATH : 0) | REPORT_ERRORS, NULL, context); |
单步跟进次函数:
1 | gdb-peda$ s |
在/main/stream/stream.c:2022
行找到了次函数的实现。
跟踪到/main/stream/stream.c:2064
行:
1 | stream = wrapper->wops->stream_opener(wrapper, |
跟进此函数:
1 | gdb-peda$ s |
在main/streams/plain_wrapper.c:1020
,找到此函数实现:
1 | static php_stream *php_plain_files_stream_opener(php_stream_wrapper *wrapper, const char *path, const char *mode, |
继续跟进 php_stream_fopen_rel
函数,在main/streams/plain_wrapper.c的1024行:
1 | gdb-peda$ s |
跟进到main/streams/plain_wrapper.c:937
,进入函数expand_filepath(filename, NULL TSRMLS_CC)
1 | gdb-peda$ s |
在main/fopen_wrappers.c:732
行,看到函数实现:
1 | PHPAPI char *expand_filepath(const char *filepath, char *real_path TSRMLS_DC) |
继续跟踪函数expand_filepath_ex
,在main/fopen_wrappers.c:738
行
1 | PHPAPI char *expand_filepath_ex(const char *filepath, char *real_path, const char *relative_to, size_t relative_to_len TSRMLS_DC) |
继续跟踪函数expand_filepath_with_mode
,在main/fopen_wrappers.c:746
行:
1 | gdb-peda$ s |
执行到main/fopen_wrappers.c:797
行:
1 | if (virtual_file_ex(&new_state, filepath, NULL, realpath_mode TSRMLS_CC)) { |
跟进virtual_file_ex
函数:
1 | gdb-peda$ s |
继续向下执行,读代码可以发现结构体state中存储着要被写入的文件的路径,
1 | gdb-peda$ p *state |
发现Zend/zend_virtual_cwd.c:1320
行,代码如下,修改了path_length,之后把path_length赋值给了state.cwd_length,所以tsrm_realpath_r
一定是对路径做处理的函数,是这个问题的关键。
1 | path_length = tsrm_realpath_r(resolved_path, start, path_length, &ll, &t, use_realpath, 0, NULL TSRMLS_CC); |
跟进此函数:
1 | gdb-peda$ s |
从最开始的函数入口,到找到问题存在的函数,整个调用栈是这样的,方便大家看:
1 | gdb-peda$ bt |
tsrm_realpath_r
函数中存在递归,所以完全理解起来还是比较复杂的,但是只需要看懂其中的一层,就可以理解其他的部分了,看如下关键代码:
1 | i = len; |
可以看出在做路径处理的时候,会递归的删除掉路径中存在的/.
,所以导致写入文件成功。但是为什么不能覆盖老文件呢?
还要多谢@yihchin大牛帮我分析,看Zend/zend_virtual_cwd.c文件的两段代码:
1 | 1077 if (save && php_sys_lstat(path, &st) < 0) { |
1 | 1120 if (save) { |
php_sys_lstat
是一个宏定义,其实是系统函数lstat
,主要功能是获取文件的描述信息存入st
结构体中,由于上面分析会删除掉路径中的/.
,所以调用时传入的path=/Users/wonderkun/script/php-src/sapi/cli/./test.php
。 当第一次执行时不存在test.php
文件,函数php_sys_lstat
返回 -1
,所以第1083行会被执行,重置save为0,所以1120-1130行都没有被执行。
当第二次执行,覆盖老文件的时候,/Users/wonderkun/script/php-src/sapi/cli/./test.php
已经是一个存在的文件了,所以php_sys_lstat
返回0,st中存储的是一个文件的信息,save还是1,导致1120-1130行被执行。由于之前php认为/Users/wonderkun/script/php-src/sapi/cli/./test.php/.
是一个目录(is_dir是1),现在有获取到/Users/wonderkun/script/php-src/sapi/cli/./test.php
是一个文件,所以is_dir && !directory
为true,函数返回了-1,得到的路径长度出错,所以无法覆盖老文件了。