php_apache2_操作系统之间的一些黑魔法

0x00 前言

做了一个CTF题目,遇到了一些有趣的东西,所以写了这篇文章记录了一下。 但是我却不明白造成这个问题的原因在哪里,所以不知道给文章起什么标题,就姑且叫这个非常宽泛的名字吧。

0x01 CTF题目原型

在遇到的题目中,最后一步是getshell,大概可以简化为以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php 

// 测试环境 linux + apache2 + php
// 没有开rewrite ,所以写 .htaccess 没用
// 没有用cgi ,所以写 .user.ini 也没有
// 要求 getshell
// 修改配置文件,crontab之类的都是没权限的。

$content = $_POST['content'];
$filename = $_POST['filename'];
$filename = "backup/".$filename;

if(preg_match('/.+\.ph(p[3457]?|t|tml)$/i', $filename)){
die("Bad file extension");
}else{
$f = fopen($filename, 'w');
fwrite($f, $content);
fclose($f);
}
?>

我把这个问题发到phithon的代码审计圈里征求答案,师傅们的答案都在理,但是却不是我想要的那个答案,下面就来一一的分析以下。

0x02 一些不完美的做法

我最开始的想法跟大多数师傅的想法一样

1
2
因为正则表达式中的点(.)不会匹配换行符(0x0a),所以可以在扩展名前面插入一个换行符,构造的文件名为233%0a.php,
这样就可以绕过正则,而且还是合法的文件名(linux允许这样的文件名存在,而windows是不允许的)。

在我本地测试一下,一切都是那么的美好,轻松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
2
3
4
5
6
7
8
➜  ~ cat /private/etc/apache2/other/php5.conf
<IfModule php5_module>
AddType application/x-httpd-php .php
AddType application/x-httpd-php-source .php
<IfModule dir_module>
DirectoryIndex index.html index.php
</IfModule>
</IfModule>

而ubuntu上的php5.conf是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
<FilesMatch ".+\.ph(p[345]?|t|tml)$">
SetHandler application/x-httpd-php
</FilesMatch>
<FilesMatch ".+\.phps$">
SetHandler application/x-httpd-php-source
Order Deny,Allow
Deny from all
</FilesMatch>
# Deny access to files without filename (e.g. '.php')
<FilesMatch "^\.ph(p[345]?|t|tml|ps)$">
Order Deny,Allow
Deny from all
</FilesMatch>

看ubuntu的配置

1
<FilesMatch ".+\.ph(p[345]?|t|tml)$">

这个正则和题目中的正则是一样的,很容易明白,当文件名和这个正则匹配上之后,就交给mod_php处理。

这就解释了为什么233%0a.php不会被解析的。

还有下面这段,也禁止解析以.开头的php文件执行的:

1
2
3
4
<FilesMatch "^\.ph(p[345]?|t|tml|ps)$">
Order Deny,Allow
Deny from all
</FilesMatch>

所以我觉得产生文件解析漏洞的根源是这句话:

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
2
3
PHP_FUNCTION(file_put_contents)  
{
.....

这就是file_put_contents函数的入口,在这里下一个断点,然后执行。

1
2
3
4
5
6
➜  cli git:(php-5.6.8) ✗ gdb -q ./php
Reading symbols from ./php...done.
gdb-peda$ b ext/standard/file.c:579
Breakpoint 1 at 0x10012b3ce: file ext/standard/file.c, line 579.
gdb-peda$ r ~/Desktop/2.php
Starting program: /Users/wonderkun/script/php-src/sapi/cli/php ~/Desktop/2.php
1
2
3
#2.php 的内容如下
<?php
file_put_contents("./test.php/.","<?=phpinfo()=>");

跟踪到file.c:616行

1
2
stream = php_stream_open_wrapper_ex(filename, mode, ((flags & PHP_FILE_USE_INCLUDE_PATH) ? USE_PATH : 0) | REPORT_ERRORS, NULL, context);
//调用php_stream_open_wrapper_ex进行了写文件处理

单步跟进次函数:

1
2
3
gdb-peda$ s
_php_stream_open_wrapper_ex (path=0x1012181d8 "./test.php/.", mode=0x7fff5fbfdf2d "wb", options=0x8, opened_path=0x0, context=0x1006d0520) at main/streams/streams.c:2022
2022 php_stream *stream = NULL;

/main/stream/stream.c:2022行找到了次函数的实现。

跟踪到/main/stream/stream.c:2064行:

1
2
3
stream = wrapper->wops->stream_opener(wrapper,
path_to_open, mode, options ^ REPORT_ERRORS,
opened_path, context STREAMS_REL_CC TSRMLS_CC);

跟进此函数:

1
2
3
4
gdb-peda$ s
php_plain_files_stream_opener (wrapper=0x1004ae498, path=0x1012181d8 "./test.php/.", mode=0x7fff5fbfdf2d "wb", options=0x0, opened_path=0x0, context=0x1006d0520)
at main/streams/plain_wrapper.c:1020
1020 if (((options & STREAM_DISABLE_OPEN_BASEDIR) == 0) && php_check_open_basedir(path TSRMLS_CC)) {

main/streams/plain_wrapper.c:1020,找到此函数实现:

1
2
3
4
5
6
7
8
9
static php_stream *php_plain_files_stream_opener(php_stream_wrapper *wrapper, const char *path, const char *mode,
int options, char **opened_path, php_stream_context *context STREAMS_DC TSRMLS_DC)
{
if (((options & STREAM_DISABLE_OPEN_BASEDIR) == 0) && php_check_open_basedir(path TSRMLS_CC)) {
return NULL;
}

return php_stream_fopen_rel(path, mode, opened_path, options);
}

继续跟进 php_stream_fopen_rel函数,在main/streams/plain_wrapper.c的1024行:

1
2
3
gdb-peda$ s
_php_stream_fopen (filename=0x1012181d8 "./test.php/.", mode=0x7fff5fbfdf2d "wb", opened_path=0x0, options=0x0) at main/streams/plain_wrapper.c:920
920 char *realpath = NULL;

跟进到main/streams/plain_wrapper.c:937,进入函数expand_filepath(filename, NULL TSRMLS_CC)

1
2
3
gdb-peda$ s
expand_filepath (filepath=0x1012181d8 "./test.php/.", real_path=0x0) at main/fopen_wrappers.c:732
732 return expand_filepath_ex(filepath, real_path, NULL, 0 TSRMLS_CC);

main/fopen_wrappers.c:732行,看到函数实现:

1
2
3
4
PHPAPI char *expand_filepath(const char *filepath, char *real_path TSRMLS_DC)
{
return expand_filepath_ex(filepath, real_path, NULL, 0 TSRMLS_CC);
}

继续跟踪函数expand_filepath_ex,在main/fopen_wrappers.c:738

1
2
3
4
PHPAPI char *expand_filepath_ex(const char *filepath, char *real_path, const char *relative_to, size_t relative_to_len TSRMLS_DC)
{
return expand_filepath_with_mode(filepath, real_path, relative_to, relative_to_len, CWD_FILEPATH TSRMLS_CC);
}

继续跟踪函数expand_filepath_with_mode,在main/fopen_wrappers.c:746行:

1
2
3
gdb-peda$ s
expand_filepath_with_mode (filepath=0x1012181d8 "./test.php/.", real_path=0x0, relative_to=0x0, relative_to_len=0x0, realpath_mode=0x1) at main/fopen_wrappers.c:752
752 if (!filepath[0]) {

执行到main/fopen_wrappers.c:797行:

1
2
3
4
if (virtual_file_ex(&new_state, filepath, NULL, realpath_mode TSRMLS_CC)) {
efree(new_state.cwd);
return NULL;
}

跟进virtual_file_ex函数:

1
2
3
gdb-peda$ s
virtual_file_ex (state=0x7fff5fbfd800, path=0x1012181d8 "./test.php/.", verify_path=0x0, use_realpath=0x1) at Zend/zend_virtual_cwd.c:1181
1181 int path_length = strlen(path);

继续向下执行,读代码可以发现结构体state中存储着要被写入的文件的路径,

1
2
3
4
5
gdb-peda$ p *state
$6 = {
cwd = 0x1006d0958 "/Users/wonderkun/script/php-src/sapi/cli",
cwd_length = 0x28
}

发现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
2
3
gdb-peda$ s
tsrm_realpath_r (path=0x7fff5fbfd370 "/Users/wonderkun/script/php-src/sapi/cli/./test.php/.", start=0x1, len=0x35, ll=0x7fff5fbfd340, t=0x7fff5fbfd338, use_realpath=0x1,
is_dir=0x0, link_is_dir=0x0) at Zend/zend_virtual_cwd.c:781

从最开始的函数入口,到找到问题存在的函数,整个调用栈是这样的,方便大家看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
gdb-peda$ bt
#0 tsrm_realpath_r (path=0x7fff5fbfd370 "/Users/wonderkun/script/php-src/sapi/cli/./test.php/.", start=0x1, len=0x35, ll=0x7fff5fbfd340, t=0x7fff5fbfd338, use_realpath=0x1,
is_dir=0x0, link_is_dir=0x0) at Zend/zend_virtual_cwd.c:794
#1 0x000000010028c615 in virtual_file_ex (state=0x7fff5fbfd800, path=0x1012181d8 "./test.php/.", verify_path=0x0, use_realpath=0x1) at Zend/zend_virtual_cwd.c:1320
#2 0x00000001001c1e9e in expand_filepath_with_mode (filepath=0x1012181d8 "./test.php/.", real_path=0x0, relative_to=0x0, relative_to_len=0x0, realpath_mode=0x1)
at main/fopen_wrappers.c:797
#3 0x00000001001c1bc3 in expand_filepath_ex (filepath=0x1012181d8 "./test.php/.", real_path=0x0, relative_to=0x0, relative_to_len=0x0) at main/fopen_wrappers.c:740
#4 0x00000001001c0364 in expand_filepath (filepath=0x1012181d8 "./test.php/.", real_path=0x0) at main/fopen_wrappers.c:732
#5 0x00000001001e1c8a in _php_stream_fopen (filename=0x1012181d8 "./test.php/.", mode=0x7fff5fbfdf2d "wb", opened_path=0x0, options=0x0) at main/streams/plain_wrapper.c:937
#6 0x00000001001e25f4 in php_plain_files_stream_opener (wrapper=0x1004ae498, path=0x1012181d8 "./test.php/.", mode=0x7fff5fbfdf2d "wb", options=0x0, opened_path=0x0,
context=0x1006d0520) at main/streams/plain_wrapper.c:1024
#7 0x00000001001dbb12 in _php_stream_open_wrapper_ex (path=0x1012181d8 "./test.php/.", mode=0x7fff5fbfdf2d "wb", options=0x8, opened_path=0x0, context=0x1006d0520)
at main/streams/streams.c:2064
#8 0x000000010012b6e5 in zif_file_put_contents (ht=0x2, return_value=0x1006d0848, return_value_ptr=0x10069b160, this_ptr=0x0, return_value_used=0x0) at ext/standard/file.c:616
#9 0x000000010034a8bc in zend_do_fcall_common_helper_SPEC (execute_data=0x10069b178) at Zend/zend_vm_execute.h:558
#10 0x00000001002d32f7 in ZEND_DO_FCALL_SPEC_CONST_HANDLER (execute_data=0x10069b178) at Zend/zend_vm_execute.h:2599
#11 0x000000010029eadf in execute_ex (execute_data=0x10069b178) at Zend/zend_vm_execute.h:363
#12 0x000000010029f53e in zend_execute (op_array=0x1006cf438) at Zend/zend_vm_execute.h:388
#13 0x0000000100257838 in zend_execute_scripts (type=0x8, retval=0x0, file_count=0x3) at Zend/zend.c:1341
#14 0x00000001001b73af in php_execute_script (primary_file=0x7fff5fbff230) at main/main.c:2597
#15 0x0000000100396835 in do_cli (argc=0x2, argv=0x7fff5fbff948) at sapi/cli/php_cli.c:994
#16 0x0000000100395737 in main (argc=0x2, argv=0x7fff5fbff948) at sapi/cli/php_cli.c:1378

tsrm_realpath_r函数中存在递归,所以完全理解起来还是比较复杂的,但是只需要看懂其中的一层,就可以理解其他的部分了,看如下关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
i = len;  
// i的初始值为字符串的长度
while (i > start && !IS_SLASH(path[i-1])) {
i--;
// 把i定位到第一个/的后面
}
if (i == len ||
(i == len - 1 && path[i] == '.')) {
len = i - 1;
// 删除路径中最后的 /. , 也就是 /path/test.php/. 会变为 /path/test.php
is_dir = 1;
continue;
} else if (i == len - 2 && path[i] == '.' && path[i+1] == '.') {
//删除路径结尾的 /..
is_dir = 1;
if (link_is_dir) {
*link_is_dir = 1;
}
if (i - 1 <= start) {
return start ? start : len;
}
j = tsrm_realpath_r(path, start, i-1, ll, t, use_realpath, 1, NULL TSRMLS_CC);
// 进行递归调用的时候,这里把strlen设置为了i-1,

可以看出在做路径处理的时候,会递归的删除掉路径中存在的/.,所以导致写入文件成功。但是为什么不能覆盖老文件呢?

还要多谢@yihchin大牛帮我分析,看Zend/zend_virtual_cwd.c文件的两段代码:

1
2
3
4
5
6
7
8
1077 if (save && php_sys_lstat(path, &st) < 0) {
1078 if (use_realpath == CWD_REALPATH) {
1079 /* file not found */
1080 return -1;
1081 }
1082 /* continue resolution anyway but don't save result in the cache */
1083 save = 0;
1084 }
1
2
3
4
5
6
7
8
9
10
11
1120 if (save) {
1121 directory = S_ISDIR(st.st_mode);
1122 if (link_is_dir) {
1123 *link_is_dir = directory;
1124 }
1125 if (is_dir && !directory) {
1125 /* not a directory */
1127 free_alloca(tmp, use_heap);
1128 return -1;
1129 }
1130 }

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,得到的路径长度出错,所以无法覆盖老文件了。