PHP中,流协议的底层实现
我们在使用类似 file_get_contents、fopen、file_exists 等等函数中,可以使用PHP内置的一些流协议,我将以file_get_contents为例,分析其使用流协议的原理:
首先查看file_get_contents的底层实现(本文只关注流协议相关部分),位于file.c 394行附近
PHP_FUNCTION(file_get_contents)
{
char *filename;
size_t filename_len;
bool use_include_path = 0;
php_stream *stream;
zend_long offset = 0;
zend_long maxlen;
bool maxlen_is_null = 1;
zval *zcontext = NULL;
php_stream_context *context = NULL;
zend_string *contents;
/* Parse arguments */
ZEND_PARSE_PARAMETERS_START(1, 5)
Z_PARAM_PATH(filename, filename_len)
Z_PARAM_OPTIONAL
Z_PARAM_BOOL(use_include_path)
Z_PARAM_RESOURCE_OR_NULL(zcontext)
Z_PARAM_LONG(offset)
Z_PARAM_LONG_OR_NULL(maxlen, maxlen_is_null)
ZEND_PARSE_PARAMETERS_END();
if (maxlen_is_null) {
maxlen = (ssize_t) PHP_STREAM_COPY_ALL;
} else if (maxlen < 0) {
zend_argument_value_error(5, "must be greater than or equal to 0");
RETURN_THROWS();
}
context = php_stream_context_from_zval(zcontext, 0);
stream = php_stream_open_wrapper_ex(filename, "rb",
(use_include_path ? USE_PATH : 0) | REPORT_ERRORS,
NULL, context);
if (!stream) {
RETURN_FALSE;
}
/* disabling the read buffer allows doing the whole transfer
in just one read() system call */
if (php_stream_is(stream, PHP_STREAM_IS_STDIO)) {
php_stream_set_option(stream, PHP_STREAM_OPTION_READ_BUFFER, PHP_STREAM_BUFFER_NONE, NULL);
}
if (offset != 0 && php_stream_seek(stream, offset, ((offset > 0) ? SEEK_SET : SEEK_END)) < 0) {
php_error_docref(NULL, E_WARNING, "Failed to seek to position " ZEND_LONG_FMT " in the stream", offset);
php_stream_close(stream);
RETURN_FALSE;
}
if ((contents = php_stream_copy_to_mem(stream, maxlen, 0)) != NULL) {
RETVAL_STR(contents);
} else {
RETVAL_EMPTY_STRING();
}
php_stream_close(stream);
}
重点为文件名会传入 php_stream_open_wrapper_ex 中进行解析,我们继续跟进该函数:
PHPAPI php_stream *_php_stream_open_wrapper_ex(const char *path, const char *mode, int options,
zend_string **opened_path, php_stream_context *context STREAMS_DC)
{
php_stream *stream = NULL;
php_stream_wrapper *wrapper = NULL;
const char *path_to_open;
int persistent = options & STREAM_OPEN_PERSISTENT;
zend_string *path_str = NULL;
zend_string *resolved_path = NULL;
char *copy_of_path = NULL;
if (opened_path) {
if (options & STREAM_OPEN_FOR_ZEND_STREAM) {
path_str = *opened_path;
}
*opened_path = NULL;
}
if (!path || !*path) {
zend_value_error("Path cannot be empty");
return NULL;
}
if (options & USE_PATH) {
if (path_str) {
resolved_path = zend_resolve_path(path_str);
} else {
resolved_path = php_resolve_path(path, strlen(path), PG(include_path));
}
if (resolved_path) {
path = ZSTR_VAL(resolved_path);
/* we've found this file, don't re-check include_path or run realpath */
options |= STREAM_ASSUME_REALPATH;
options &= ~USE_PATH;
}
if (EG(exception)) {
return NULL;
}
}
path_to_open = path;
wrapper = php_stream_locate_url_wrapper(path, &path_to_open, options);
if ((options & STREAM_USE_URL) && (!wrapper || !wrapper->is_url)) {
php_error_docref(NULL, E_WARNING, "This function may only be used against URLs");
if (resolved_path) {
zend_string_release_ex(resolved_path, 0);
}
return NULL;
}
if (wrapper) {
if (!wrapper->wops->stream_opener) {
php_stream_wrapper_log_error(wrapper, options & ~REPORT_ERRORS,
"wrapper does not support stream open");
} else {
stream = wrapper->wops->stream_opener(wrapper,
path_to_open, mode, options & ~REPORT_ERRORS,
opened_path, context STREAMS_REL_CC);
}
/* if the caller asked for a persistent stream but the wrapper did not
* return one, force an error here */
if (stream && (options & STREAM_OPEN_PERSISTENT) && !stream->is_persistent) {
php_stream_wrapper_log_error(wrapper, options & ~REPORT_ERRORS,
"wrapper does not support persistent streams");
php_stream_close(stream);
stream = NULL;
}
if (stream) {
stream->wrapper = wrapper;
}
}
if (stream) {
if (opened_path && !*opened_path && resolved_path) {
*opened_path = resolved_path;
resolved_path = NULL;
}
if (stream->orig_path) {
pefree(stream->orig_path, persistent);
}
copy_of_path = pestrdup(path, persistent);
stream->orig_path = copy_of_path;
#if ZEND_DEBUG
stream->open_filename = __zend_orig_filename ? __zend_orig_filename : __zend_filename;
stream->open_lineno = __zend_orig_lineno ? __zend_orig_lineno : __zend_lineno;
#endif
}
if (stream != NULL && (options & STREAM_MUST_SEEK)) {
php_stream *newstream;
switch(php_stream_make_seekable_rel(stream, &newstream,
(options & STREAM_WILL_CAST)
? PHP_STREAM_PREFER_STDIO : PHP_STREAM_NO_PREFERENCE)) {
case PHP_STREAM_UNCHANGED:
if (resolved_path) {
zend_string_release_ex(resolved_path, 0);
}
return stream;
case PHP_STREAM_RELEASED:
if (newstream->orig_path) {
pefree(newstream->orig_path, persistent);
}
newstream->orig_path = pestrdup(path, persistent);
if (resolved_path) {
zend_string_release_ex(resolved_path, 0);
}
return newstream;
default:
php_stream_close(stream);
stream = NULL;
if (options & REPORT_ERRORS) {
char *tmp = estrdup(path);
php_strip_url_passwd(tmp);
php_error_docref1(NULL, tmp, E_WARNING, "could not make seekable - %s",
tmp);
efree(tmp);
options &= ~REPORT_ERRORS;
}
}
}
if (stream && stream->ops->seek && (stream->flags & PHP_STREAM_FLAG_NO_SEEK) == 0 && strchr(mode, 'a') && stream->position == 0) {
zend_off_t newpos = 0;
/* if opened for append, we need to revise our idea of the initial file position */
if (0 == stream->ops->seek(stream, 0, SEEK_CUR, &newpos)) {
stream->position = newpos;
}
}
if (stream == NULL && (options & REPORT_ERRORS)) {
php_stream_display_wrapper_errors(wrapper, path, "Failed to open stream");
if (opened_path && *opened_path) {
zend_string_release_ex(*opened_path, 0);
*opened_path = NULL;
}
}
php_stream_tidy_wrapper_error_log(wrapper);
#if ZEND_DEBUG
if (stream == NULL && copy_of_path != NULL) {
pefree(copy_of_path, persistent);
}
#endif
if (resolved_path) {
zend_string_release_ex(resolved_path, 0);
}
return stream;
}
PHPAPI php_stream_wrapper *php_stream_locate_url_wrapper(const char *path, const char **path_for_open, int options)
{
HashTable *wrapper_hash = (FG(stream_wrappers) ? FG(stream_wrappers) : &url_stream_wrappers_hash);
php_stream_wrapper *wrapper = NULL;
const char *p, *protocol = NULL;
size_t n = 0;
if (path_for_open) {
*path_for_open = (char*)path;
}
if (options & IGNORE_URL) {
return (php_stream_wrapper*)((options & STREAM_LOCATE_WRAPPERS_ONLY) ? NULL : &php_plain_files_wrapper);
}
for (p = path; isalnum((int)*p) || *p == '+' || *p == '-' || *p == '.'; p++) {
n++;
}
if ((*p == ':') && (n > 1) && (!strncmp("//", p+1, 2) || (n == 4 && !memcmp("data:", path, 5)))) {
protocol = path;
}
if (protocol) {
if (NULL == (wrapper = zend_hash_str_find_ptr(wrapper_hash, protocol, n))) {
char *tmp = estrndup(protocol, n);
zend_str_tolower(tmp, n);
if (NULL == (wrapper = zend_hash_str_find_ptr(wrapper_hash, tmp, n))) {
char wrapper_name[32];
if (n >= sizeof(wrapper_name)) {
n = sizeof(wrapper_name) - 1;
}
PHP_STRLCPY(wrapper_name, protocol, sizeof(wrapper_name), n);
php_error_docref(NULL, E_WARNING, "Unable to find the wrapper "%s" - did you forget to enable it when you configured PHP?", wrapper_name);
wrapper = NULL;
protocol = NULL;
}
efree(tmp);
}
}
/* TODO: curl based streams probably support file:// properly */
if (!protocol || !strncasecmp(protocol, "file", n)) {
/* fall back on regular file access */
php_stream_wrapper *plain_files_wrapper = (php_stream_wrapper*)&php_plain_files_wrapper;
if (protocol) {
int localhost = 0;
if (!strncasecmp(path, "file://localhost/", 17)) {
localhost = 1;
}
#ifdef PHP_WIN32
if (localhost == 0 && path[n+3] != ' ' && path[n+3] != '/' && path[n+4] != ':') {
#else
if (localhost == 0 && path[n+3] != ' ' && path[n+3] != '/') {
#endif
if (options & REPORT_ERRORS) {
php_error_docref(NULL, E_WARNING, "Remote host file access not supported, %s", path);
}
return NULL;
}
if (path_for_open) {
/* skip past protocol and :/, but handle windows correctly */
*path_for_open = (char*)path + n + 1;
if (localhost == 1) {
(*path_for_open) += 11;
}
while (*(++*path_for_open)=='/') {
/* intentionally empty */
}
#ifdef PHP_WIN32
if (*(*path_for_open + 1) != ':')
#endif
(*path_for_open)--;
}
}
if (options & STREAM_LOCATE_WRAPPERS_ONLY) {
return NULL;
}
if (FG(stream_wrappers)) {
/* The file:// wrapper may have been disabled/overridden */
if (wrapper) {
/* It was found so go ahead and provide it */
return wrapper;
}
/* Check again, the original check might have not known the protocol name */
if ((wrapper = zend_hash_find_ex_ptr(wrapper_hash, ZSTR_KNOWN(ZEND_STR_FILE), 1)) != NULL) {
return wrapper;
}
if (options & REPORT_ERRORS) {
php_error_docref(NULL, E_WARNING, "file:// wrapper is disabled in the server configuration");
}
return NULL;
}
return plain_files_wrapper;
}
if (wrapper && wrapper->is_url &&
(options & STREAM_DISABLE_URL_PROTECTION) == 0 &&
(!PG(allow_url_fopen) ||
(((options & STREAM_OPEN_FOR_INCLUDE) ||
PG(in_user_include)) && !PG(allow_url_include)))) {
if (options & REPORT_ERRORS) {
/* protocol[n] probably isn't ' ' */
if (!PG(allow_url_fopen)) {
php_error_docref(NULL, E_WARNING, "%.*s:// wrapper is disabled in the server configuration by allow_url_fopen=0", (int)n, protocol);
} else {
php_error_docref(NULL, E_WARNING, "%.*s:// wrapper is disabled in the server configuration by allow_url_include=0", (int)n, protocol);
}
}
return NULL;
}
return wrapper;
}
必须条件:
-
路径的开头必须是数字字母或者+ – .也就是[a-zA-Z0-9+-.]
-
在开头的[a-zA-Z0-9+-.]之后,必须紧接着一个冒号 “:”
以下条件满足其一即可:
-
在冒号后面是两个斜杠 “//”
-
路径以data:开头
这样结构的路径就会被PHP当成流协议来处理,再看看处理流程:
首先会对整个路径作为流协议名在hash表 wrapper_hash 中进行查找,在PHP底层中,使用php_register_url_stream_wrapper函数来对wrapper_hash进行写入操作,因此搜索该函数的调用情况可以得到内置支持所有的流协议
这些流协议中,有些是需要安装相应的扩展组件才能使用。再回到函数php_stream_locate_url_wrapper的代码中:
可知如果不存在输入的流协议,会进行转化为小写的操作,因此我们可以使用大小写协议名绕过某些过滤,如:DatA:、FiLe:等,这个在下面的分析过程中会明白,继续往下看,到了这里如果存在对应的流协议在wrapper_hash里面,就完成对流协议的识别并返回wrapper就可以使用了。
上面在 php_register_url_stream_wrapper 函数里面将流协议名和用于处理流协议的方法集绑定在了一起,具体调用哪个函数来处理,取决去你用什么函数来使用流协议。类似于data流协议使用php_stream_rfc2397_wrapper 方法集来处理。接下来将分析具体一些常用流的具体实现。
小结:
-
php_register_url_stream_wrapper函数对流协议名称和处理方法集进行了绑定 -
xxx:// 这种格式开头会被认为是流协议,至于能不能使用,还需要看有没有绑定相应的流协议名 -
流协议名在这个阶段不区分大小写。
常用流协议的具体实现
data流
根据函数名以及代码内容可以知道,这是rfc2397的PHP实现函数,使用这个标准的不只是PHP,还有类似于html用来表示base64编码的图片时也用到了这个标准。参考链接:http://www.faqs.org/rfcs/rfc2397.html 这是这个标准的说明。
根据上面的分析,data流在php_stream_rfc2397_wrapper方法集里面进行处理,跟进发现只有一个函数,具体代码分析:
-
路径以 data: 开头。
-
data: 后面必须至少有一个英文逗号 “,” 兼容条件
-
如果data: 后面有 “//” 则自动忽略它们,因此可以不使用 “//”
我们先分析满足comma != path 条件后进行的操作:
首先是条件部分:
-
如果data:或者data:// 后面,到第一个英文逗号”,”之前都不存在分号”;”和反斜杠”/”的话,就会报错。
-
不存在”;”但是存在”/”的话,直接将data:或者data:// 后面,到第一个英文逗号”,”之前的字符串作为内容的类型,类似于 text/plain 这种。
-
当”;”和”/”都存在并且”;”在后面时,将”;”前面,”data:”后面的字符串作为内容类型.
-
当data:后面直接跟”;” 且分号后面不是base64的时候,将报错。
如果前面不报错,就会进入到以下流程:
以分号分割字符串,每个子字符串作为一个参数,参数的格式必须是 key=value 这种格式,base64参数除外。当使用了base64这个参数时,base64变量被赋值为1。然后就到了最后的一个处理部分:
小结:
-
在这个流协议里面,不能使用大小写绕过流协议名,因为里面还判断了开头是不是data:且没有转化为小写。
-
可以使用base64参数对数据部分进行base64解码。
-
当没有指定base64参数时,会对数据部分进行一次url解码。
-
内容类型并不会对数据部分造成任何影响,例如以下例子,将内容类型设置为666/666依然可以正常获取内容:
file:// 流协议的具体实现
php_plain_files_stream_opener 具体代码如下:
(r == 0 && !S_ISREG(self->sb.st_mode) 获取文件信息,获取成功,且S_ISREG检查出来不是普通文件,则会关闭流并返回null。这里不是普通文件的情况类似于Linux下的 目录文件、字符设备文件、块设备文件、命名管道文件、符号链接文件、套接字文件等等。到这里则完成了对file:// 流的解析。
-
file:// 流协议并没有什么特殊的处理,很多函数使用file:// 流来进行操作时都只是判断如果为file:// 开头直接把它去掉,然后传入原本的处理函数去。
-
无论是否使用file:// 流协议,如果使用了文件包含的函数,将只能打开普通文件。(这条只针对非windows系统)
-
open_basedir选项对file:// 流协议也有影响。
php:// 流协议的具体实现
一眼发现它可以支持很多种选项(我叫做选项,具体叫什么我也不知道),所有选项以及它的功能如下:
-
temp 一个类似文件 包装器的数据流,允许读写临时数据,但是达到限制后会写入到临时文件中,默认限制是2m。
-
memory 一个类似文件 包装器的数据流,允许读写临时数据
-
output 是一个只写的数据流, 允许你以 print 和 echo 一样的方式 写入到输出缓冲区。
-
input 是个可以访问请求的原始数据的只读流。 enctype=”multipart/form-data” 的时候 input 是无效的。
-
stdin 标准输入
-
stdout 标准输出
-
stderr 标准错误
-
fd 允许直接访问指定的文件描述符。 例如 fd/3 引用了文件描述符 3。
-
filter 过滤器
其中标准输入输出和错误很好理解,因此不进行分析。
temp选项
首先从temp开始,从简介可以知道,这个流可以用来读写一些临时数据,类似于以下:
<?php
$f = fopen("php://temp","r+");
fwrite($f,"123");
rewind($f);
echo fread($f,99);
fclose($f);
创建了一个php_stream_temp_data结构体,把传入的内存限制、临时目录路径、打开模式等记录下来,然后传入php_stream_alloc_rel来创建流,这里指定了处理流的方法集,这里只对常用的读写和关闭进行分析,先是读:
在触发close后,会调用到php_stream_free_enclosed来关闭流,在这个流里面会删除掉非持久化文件(临时文件就是非持久化文件),因此想要临时文件能够保留住,必须能在调用到close之前中断脚本。
小结:
如果想利用这个临时文件,并不是说在代码中没有显式的调用fclose来关闭资源就不会触发这个close函数,因为PHP在执行完全结束前会自动释放资源,还是会调用到close来,因此想要保留住这个临时文件,必须使得PHP在完成写入之后,触发close之前造成PHP进程崩溃,常见的就像段错误Segmentation fault,它一般是由于服务器内存不够、扩展组件里面发生错误(例如在扩展组件里面访问已经关闭的内存空间)。因此需要在利用时看看代码有没有其它问题会导致脚本中断而不会自动销毁资源。
memory选项
output选项
这个就很简单了,类似于使用echo 输出内容,当对这个选项打开的流进行写入操作的时候会调用
最终调用PHPWRITE来向缓冲区写入内容,类似echo print也会调用PHPWRITE来向缓冲区写内容。因此以下代码都可以输出m4x给用户:
echo "m4x";
print "m4x";
file_put_contents("php://output","m4x");
input选项
这个选项就是单纯的返回我们请求体的内容
这里可以知道文件包含是不能使用这个选项的,以及会调用 php_stream_temp_create_ex 来存放数据,这就是为什么在上传文件时会在/tmp下产生临时文件。
这里作了几个限制:
-
cli情况下不允许使用 -
文件包含且未启用远程文件包含选项的情况下不允许使用 -
php://fd/ 后面没有正确指定文件描述符编号的,不允许使用
file_put_contents("php://fd/1","m4x");
filter选项
只要是php://filter/ 开头的路径就会进入到这个处理流程中,首先会对打开模式进行一个处理,可以看出来一些东西:
-
只要存在+号,则会同时有读写权限。 -
w 和 a都会获得写权限,r则只读,w则只写。
然后pathdup会指向php://filter 后面的内容,如果不存在/resource=这10个字符的话,就直接报错,所以这是必须参数,之后获取/resource=后面的内容并交给php_stream_open_wrapper函数来进行解析,这就意味着可以无限嵌套流协议,会导致出现畸形路径的概率变大。
例如:
php://filter/convert.base64-encode/resource=php://filter/string.rot13/resource=data://m4x/m4x,m4x
仍能正常返回”m4x”经过rot13编码后再经过base64编码的结果。又类似于某段存在漏洞的代码如下:
<?php
include "php://filter/resource=".$_GET['lang'].".php";
lang=zip://m4x.png%23m4x
可以看到这里面又使用 “|” 符号进行了一次分割,并且会进行url解码,因此可以有类似于以下payload:
php://filter/string.rot13|convert.base64-decode/resource=php://filter/convert.base64-encode|string.rot13/resource=data://m4x/m4x,m4x
可以正常返回”m4x”,如果有一些过滤,可以使用url编码,因为php_stream_apply_filter_list会对过滤器名字进行一次url解码,例如:
过滤了base64,则如下:
<?php
function urlencode1($s){
return '%'.dechex(ord($s));
}
file_put_contents("1.log",base64_encode("<?php system('calc'); ?>"));
include "php://filter/convert.b". urlencode1("a") . "se64-decode/resource=1.log";
最终这些过滤器字符串都会传入php_stream_filter_create函数创建过滤器,其中关键代码如下:
它会去filter_hash里面找有没有相关的过滤器名字,这些过滤器的处理流程可以在PHP官方手册里面的可用过滤器列表有讲解其相关作用,需要研究某个过滤器的处理流程可以到底层源码去看,ext/standard/filters.c 里面可以找到。到此完成了对php://filter 流协议的分析。
小结:
-
/resource= 可以处理流协议,意味着可以无限循环处理流协议,以及调用其他流协议。 -
过滤器名字可以进行url编码后再传入,因为会对过滤器名字进行一次url解码。 -
过滤器处理流程在ext/standard/filters.c ,需要研究某个过滤器可以去这里找。
phar:// 流协议的具体实现
这个流协议在信息安全领域用过最多的就是phar触发反序列化后调用__destruct()了吧,趁着这个机会深入研究一下具体为什么能触发反序列化。 来到它的处理方法集php_stream_phar_wrapper,跟进到phar_stream_wops,然后是对应的这些处理函数
先看看open函数,按照我的理解来说反序列化应该就是在这个阶段进行的:
首先是对传入的path进行解析,在phar_parse_url函数里面,跟进去:
首先是满足以下条件直接报错:
-
不是以phar:// 开头的报错。 -
使用打开模式为”a”也就是追加模式报错。 -
phar_split_fname函数检查不通过的报错。
到这里又有两个失败的条件:
-
文件名为空。 -
phar_detect_phar_fname_ext函数检测不通过,且ext_len不等于-1
因此需要跟进phar_detect_phar_fname_ext函数,找出返回FAILURE且ext_len 不等于-1的情况:
-
当phar文件名中不存在点号 “.” 的情况下直接返回 FAILURE 且ext_len 为0(路径中包含”.”但是文件名不包含”.”也不行)。因此如果想利用session来打phar的就不行了。
-
无法利用类似于 phar:///tmp/1.phar/../sess_m4x 的payload,因为会对传入路径进行取真是文件路径的操作。
-
当我们只进行读取操作时,不会有.phar 扩展名的限制,但是进行写入时会有这个限制。
-
在phar:// 后面不允许存在 :// 。
当不存在这些条件且为本地文件的,就可以通过对路径名的检查。然后看到了一个令人激动的东西:
它在检查路径的时候会调用 php_stream_open_wrapper 函数来进行检测,且在打开流的使用也支持流协议,虽然说不能有://出现,但是通过前面的分析可以发现,data: 也能解析成功,那是不是就可以直接使用data流协议来进行phar的反序列化攻击而无需文件落地呢?
遗憾的是,类似:phar://data:m4x/m4x,123.phar 的payload虽然能成功解析,但是却由于data流协议没有检查状态的函数,导致 wrapper->wops->url_stat 为空,从而无法通过检查。这里记一下,万一哪天PHP版本升级后data流协议就有了自己的状态检查函数呢,哈哈。
然后继续回到phar_parse_url 函数里面来:
前面经过检查后进行了初始化操作,然后来到这里,调用phar_open_from_filename函数检测是否能正常打开phar文件。其他检测都是文件存在且只读的情况都能通过,然后来到最关键的phar_open_from_fp函数里面,在这进行了文件结构的检查和打开:
这里定义了zip、gz、bz压缩文件的文件头,且能够识别和打开tar文件,该函数位于 ext/phar/phar.c 的1623行左右,篇幅很长,需要单独研究某种压缩或者打包文件的处理流程的时候可以来这里找,这里整理出大致处理流程:
-
先对存在gz、bz压缩的文件进行解压缩处理。
-
将解压缩后的文件依次判断是否zip、tar、phar打包文件,是的话调用相关函数进行解析
-
由于可以解析tar文件,根据tar文件的特性,文件名会放在文件的最开头,且它能自动识别文件的结束,因此如果有个文件头尾都不可控制,也是可以解析的。
-
可以使用上面提到的各种压缩来绕过流量层的检查或者文件内容检查。
总结
原文始发于微信公众号(山石网科安全技术研究院):PHP流协议的底层实现分析