PHP流协议的底层实现分析

PHP内部实现了一些伪协议,为日常的开发提供了便利,但是也存在着一些安全隐患,本文从底层源码分析常用的伪协议有哪些安全隐患,以便在渗透测试或者日常开发中可以注意到这些隐患点。
注: 由于大家的叫法比较多,下文中,流、流协议、伪协议,都是同一个东西。以及本文使用的php源码版本为8.2.4,是我分析时的最新版,流协议部分的代码相对比较成熟,不同版本一般来说也不会有太大变化。

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(15)
  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;
}
传入的path被传入到该函数,继续跟进 wrapper = php_stream_locate_url_wrapper(path, &path_to_open, options);
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+12) || (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;
}
其中这段代码影响到我们输入的路径是否能继续解析流协议
PHP流协议的底层实现分析
这里可以看出来,解析流协议需要以下条件:

必须条件

  1. 路径的开头必须是数字字母或者+ – .也就是[a-zA-Z0-9+-.]

  2. 在开头的[a-zA-Z0-9+-.]之后,必须紧接着一个冒号 “:”

以下条件满足其一即可

  1. 在冒号后面是两个斜杠 “//”

  2. 路径以data:开头

这样结构的路径就会被PHP当成流协议来处理,再看看处理流程:

PHP流协议的底层实现分析

首先会对整个路径作为流协议名在hash表 wrapper_hash 中进行查找,在PHP底层中,使用php_register_url_stream_wrapper函数来对wrapper_hash进行写入操作,因此搜索该函数的调用情况可以得到内置支持所有的流协议

PHP流协议的底层实现分析

这些流协议中,有些是需要安装相应的扩展组件才能使用。再回到函数php_stream_locate_url_wrapper的代码中:

PHP流协议的底层实现分析

可知如果不存在输入的流协议,会进行转化为小写的操作,因此我们可以使用大小写协议名绕过某些过滤,如:DatA:、FiLe:等,这个在下面的分析过程中会明白,继续往下看,到了这里如果存在对应的流协议在wrapper_hash里面,就完成对流协议的识别并返回wrapper就可以使用了。

上面在 php_register_url_stream_wrapper 函数里面将流协议名和用于处理流协议的方法集绑定在了一起,具体调用哪个函数来处理,取决去你用什么函数来使用流协议。类似于data流协议使用php_stream_rfc2397_wrapper 方法集来处理。接下来将分析具体一些常用流的具体实现。

小结

  1. php_register_url_stream_wrapper函数对流协议名称和处理方法集进行了绑定
  2. xxx:// 这种格式开头会被认为是流协议,至于能不能使用,还需要看有没有绑定相应的流协议名
  3. 流协议名在这个阶段不区分大小写。

常用流协议的具体实现


data流


根据函数名以及代码内容可以知道,这是rfc2397的PHP实现函数,使用这个标准的不只是PHP,还有类似于html用来表示base64编码的图片时也用到了这个标准。参考链接:http://www.faqs.org/rfcs/rfc2397.html 这是这个标准的说明。

根据上面的分析,data流在php_stream_rfc2397_wrapper方法集里面进行处理,跟进发现只有一个函数,具体代码分析:

PHP流协议的底层实现分析

首先是解析条件,满足以下条件才可以解析:

PHP流协议的底层实现分析

必须条件
  1. 路径以 data: 开头。

  2. data: 后面必须至少有一个英文逗号 “,”  兼容条件

  3. 如果data: 后面有 “//” 则自动忽略它们,因此可以不使用 “//”

经过上面的处理,comma获取到了”data:”之后,第一个英文逗号”,”出现的位置,如果comma和path不一致,也就是说只要不是data:或者”data://” 后面不是直接跟着英文逗号”,”,都会导致comma和path不一致,结合rfc2397的格式规范,可以知道, data流协议使用第一个英文逗号作为标识符,将后面的字符串作为内容,前面的内容是一些选项、内容类型等等。
PHP流协议的底层实现分析

我们先分析满足comma != path 条件后进行的操作: 

首先是条件部分

  1. 如果data:或者data:// 后面,到第一个英文逗号”,”之前都不存在分号”;”和反斜杠”/”的话,就会报错。

  2. 不存在”;”但是存在”/”的话,直接将data:或者data:// 后面,到第一个英文逗号”,”之前的字符串作为内容的类型,类似于 text/plain 这种。

  3. 当”;”和”/”都存在并且”;”在后面时,将”;”前面,”data:”后面的字符串作为内容类型.

  4. 当data:后面直接跟”;” 且分号后面不是base64的时候,将报错。

如果前面不报错,就会进入到以下流程:  

PHP流协议的底层实现分析

以分号分割字符串,每个子字符串作为一个参数,参数的格式必须是 key=value 这种格式,base64参数除外。当使用了base64这个参数时,base64变量被赋值为1。然后就到了最后的一个处理部分:

PHP流协议的底层实现分析

当base64选项存在时,对英文逗号后面的部分进行base64解码然后写到流里面。如果base64不存在则进行一次url解码再放到流里面,因此data流协议的数据部分不使用base64编码时则会进行一次url编码。到此完成对data流协议的处理。

小结

  1. 在这个流协议里面,不能使用大小写绕过流协议名,因为里面还判断了开头是不是data:且没有转化为小写。

  2. 可以使用base64参数对数据部分进行base64解码。

  3. 当没有指定base64参数时,会对数据部分进行一次url解码。

  4. 内容类型并不会对数据部分造成任何影响,例如以下例子,将内容类型设置为666/666依然可以正常获取内容:

PHP流协议的底层实现分析


file:// 流协议的具体实现



按照上述方法,找到file 流协议绑定的处理方法集为php_plain_files_wrapper ,跟进发现里面又调用方法集php_plain_files_wrapper_ops来处理:

PHP流协议的底层实现分析

这里以file_get_contents为例,所以调用的是php_plain_files_stream_opener函数。从这里也可以从命名上看出来,file:// 协议也支持mkdir、rename、unlink等等函数

php_plain_files_stream_opener 具体代码如下:

PHP流协议的底层实现分析
这里检查open_basedir选项是否设置,设置了会调用php_check_open_basedir函数来进行检查,如果我们输入的路径不在open_basedir选项设置的路径中,将会返回NULL,因此想使用file:// 来绕过open_basedir的限制是不可以的。如果检查通过,将会调用 php_stream_fopen_rel 来打开文件并返回,跟进看看,一直跟没发现什么内容,一直到了_php_stream_fopen函数:
首先是对mode的判断:
PHP流协议的底层实现分析
限制了在mode中只能出现下面的这些字符,出现其他的就报错
PHP流协议的底层实现分析
如果mode符合条件就继续下面的流程:
PHP流协议的底层实现分析
判断存在options且里面存在STREAM_ASSUME_REALPATH标志(是否真实路径),如果不存在,则使用expand_filepath来获取文件的真实路径也叫绝对路径,例如:我们输入 file://./test.txt 且php脚本位于/var/www/html/index.php,经过上面的处理就会获取到file:///var/www/html/test.txt 。获取到绝对路径后就打开文件并返回资源对象了,从以下代码中可以看出来他有个特殊机制:
PHP流协议的底层实现分析
当fopen函数使用了 STREAM_OPEN_PERSISTENT 标志时,将直接返回已经打开的资源对象。但是该标志无法通过我们的输入进行控制,没有什么分析价值。继续往下看看:
PHP流协议的底层实现分析
但这里就直接调用php_stream_fopen_from_fd_int_rel来创建流了,if语句判断的是是否从文件包含的函数打开的流,也就是inculde和require,调用不同的方法处理。继续判断是否指定了O_APPEND标志,类似于 file_put_contents 函数的第3个参数。
打开流后Windows下会直接返回这个流,但是在非windows系统下会进行以下操作后才会返回:
PHP流协议的底层实现分析

(r == 0 && !S_ISREG(self->sb.st_mode)  获取文件信息,获取成功,且S_ISREG检查出来不是普通文件,则会关闭流并返回null。这里不是普通文件的情况类似于Linux下的 目录文件、字符设备文件、块设备文件、命名管道文件、符号链接文件、套接字文件等等。到这里则完成了对file:// 流的解析。

小结:
  1. file:// 流协议并没有什么特殊的处理,很多函数使用file:// 流来进行操作时都只是判断如果为file:// 开头直接把它去掉,然后传入原本的处理函数去。

  2. 无论是否使用file:// 流协议,如果使用了文件包含的函数,将只能打开普通文件。(这条只针对非windows系统)

  3. open_basedir选项对file:// 流协议也有影响。


php:// 流协议的具体实现



这个流协议功能比较强大,因此在信息安全中使用的也比较多,类似于使用 php://filter 来进行php文件base64编码读取,以及base64写入文件绕过waf,php://input 获取用户传入的raw数据等等。
这个流协议绑定了php_stream_php_wrapper方法集,里面使用了php_stdio_wops方法集,跟进发现只有一个处理函数php_stream_url_wrap_php,跟进:
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流协议的底层实现分析

php:// 后面跟着temp就会进入创建这个临时读写流的流程,如果php://temp 后面还跟着/maxmemory:的话,就可以设置这个默认为2m的内存限制。然后调用php_stream_mode_from_str来将传入的模式转换为临时流的模式,然后调用php_stream_temp_create 来创建这个临时读写流。跟进去可以来到_php_stream_temp_create_ex函数,具体内容如下:
PHP流协议的底层实现分析

创建了一个php_stream_temp_data结构体,把传入的内存限制、临时目录路径、打开模式等记录下来,然后传入php_stream_alloc_rel来创建流,这里指定了处理流的方法集,这里只对常用的读写和关闭进行分析,先是读:

PHP流协议的底层实现分析
也就是对流进行写入读取各种操作时,是使用这里面的函数来处理的,读取函数很简单,给它传入对谁读、读到哪里去、要读多少,就可以把临时读写区域里面的内容原样返回。下面是写函数:
PHP流协议的底层实现分析
跟文件写入的流程差不多,但是多了一个操作:
PHP流协议的底层实现分析
当写入的内容和原有的内容加起来超出了指定的内存限制(也就是刚刚说的默认为2m的东西),就会往临时文件里面写入内容而不是在内存中写入。这里就有了个问题,如果有文件包含漏洞,是否可以通过不断的写入临时文件(php_xxxxxx,六个随机字母或数字)然后不断的包含临时文件从而发生碰撞导致文件包含成功呢?这里看看close函数后可以知道利用难度很大:
PHP流协议的底层实现分析

在触发close后,会调用到php_stream_free_enclosed来关闭流,在这个流里面会删除掉非持久化文件(临时文件就是非持久化文件),因此想要临时文件能够保留住,必须能在调用到close之前中断脚本。

小结

如果想利用这个临时文件,并不是说在代码中没有显式的调用fclose来关闭资源就不会触发这个close函数,因为PHP在执行完全结束前会自动释放资源,还是会调用到close来,因此想要保留住这个临时文件,必须使得PHP在完成写入之后,触发close之前造成PHP进程崩溃,常见的就像段错误Segmentation fault,它一般是由于服务器内存不够、扩展组件里面发生错误(例如在扩展组件里面访问已经关闭的内存空间)。因此需要在利用时看看代码有没有其它问题会导致脚本中断而不会自动销毁资源。

memory选项

这个选项和temp选项基本一致,就是当产出内存限制后不会生成临时文件,那如果使用memory打开的流发生超出内存限制的话会发生什么呢?
直接报错内存超出限制。但是这类的错误并不会导致PHP不清理已经打开的文件资源,也就是说不会保留临时文件。

output选项

这个就很简单了,类似于使用echo 输出内容,当对这个选项打开的流进行写入操作的时候会调用

PHP流协议的底层实现分析

最终调用PHPWRITE来向缓冲区写入内容,类似echo print也会调用PHPWRITE来向缓冲区写内容。因此以下代码都可以输出m4x给用户:

echo "m4x";
print "m4x";
file_put_contents("php://output","m4x");


input选项

这个选项就是单纯的返回我们请求体的内容

PHP流协议的底层实现分析

这里可以知道文件包含是不能使用这个选项的,以及会调用 php_stream_temp_create_ex 来存放数据,这就是为什么在上传文件时会在/tmp下产生临时文件。

由于PHP默认情况下不会解析json格式的请求体,因此很多时候,我们会使用json_decode(file_get_contents(“php://input”));来解析json数据,它里面返回的是原始的请求体数据。
fd选项
该选项用于文件描述符的获取
PHP流协议的底层实现分析

这里作了几个限制

  • cli情况下不允许使用
  • 文件包含且未启用远程文件包含选项的情况下不允许使用
  • php://fd/ 后面没有正确指定文件描述符编号的,不允许使用
输入的描述符编号如果小于或者大于int能表示的最大值则报错,如果都没问题则调用dup函数来获得获得文件描述符。
使用类似于:
file_put_contents("php://fd/1","m4x"); 
可以向标准输出写入 m4x 。

filter选项

PHP流协议的底层实现分析

只要是php://filter/ 开头的路径就会进入到这个处理流程中,首先会对打开模式进行一个处理,可以看出来一些东西:

  1. 只要存在+号,则会同时有读写权限。
  2. 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";
此时需要一个有恶意内容的PHP文件来完成RCE,但是各个上传接口一般都会过滤php文件的上传,png等图片文件一般都会在白名单里面,此时可以上传一个png结尾的zip或者phar文件,然后使用类似以下的payload来完成文件包含漏洞的利用:
lang=zip://m4x.png%23m4x
最后拼接成php://filter/resource=zip://m4x.png#m4x.php以完成对压缩包内m4x.php文件的包含。
再继续往下看:
PHP流协议的底层实现分析
以filter/后面的”/”符号作为分割,中间的字符串传入php_stream_apply_filter_list函数,跟进过去:
PHP流协议的底层实现分析

可以看到这里面又使用 “|” 符号进行了一次分割,并且会进行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函数创建过滤器,其中关键代码如下:

PHP流协议的底层实现分析

它会去filter_hash里面找有没有相关的过滤器名字,这些过滤器的处理流程可以在PHP官方手册里面的可用过滤器列表有讲解其相关作用,需要研究某个过滤器的处理流程可以到底层源码去看,ext/standard/filters.c 里面可以找到。到此完成了对php://filter 流协议的分析。

小结

  1. /resource= 可以处理流协议,意味着可以无限循环处理流协议,以及调用其他流协议。
  2. 过滤器名字可以进行url编码后再传入,因为会对过滤器名字进行一次url解码。
  3. 过滤器处理流程在ext/standard/filters.c ,需要研究某个过滤器可以去这里找。

phar:// 流协议的具体实现


这个流协议在信息安全领域用过最多的就是phar触发反序列化后调用__destruct()了吧,趁着这个机会深入研究一下具体为什么能触发反序列化。  来到它的处理方法集php_stream_phar_wrapper,跟进到phar_stream_wops,然后是对应的这些处理函数

PHP流协议的底层实现分析

先看看open函数,按照我的理解来说反序列化应该就是在这个阶段进行的:

PHP流协议的底层实现分析

首先是对传入的path进行解析,在phar_parse_url函数里面,跟进去:

PHP流协议的底层实现分析

首先是满足以下条件直接报错

  1. 不是以phar:// 开头的报错。
  2. 使用打开模式为”a”也就是追加模式报错。
  3. phar_split_fname函数检查不通过的报错。
跟进 phar_split_fname函数:
PHP流协议的底层实现分析

到这里又有两个失败的条件

  1. 文件名为空。
  2. phar_detect_phar_fname_ext函数检测不通过,且ext_len不等于-1

因此需要跟进phar_detect_phar_fname_ext函数,找出返回FAILURE且ext_len 不等于-1的情况:

  1. 当phar文件名中不存在点号 “.” 的情况下直接返回 FAILURE 且ext_len 为0(路径中包含”.”但是文件名不包含”.”也不行)。因此如果想利用session来打phar的就不行了。

  2. 无法利用类似于 phar:///tmp/1.phar/../sess_m4x 的payload,因为会对传入路径进行取真是文件路径的操作。

  3. 当我们只进行读取操作时,不会有.phar 扩展名的限制,但是进行写入时会有这个限制。

  4. 在phar:// 后面不允许存在 :// 。

当不存在这些条件且为本地文件的,就可以通过对路径名的检查。然后看到了一个令人激动的东西:  

PHP流协议的底层实现分析

它在检查路径的时候会调用 php_stream_open_wrapper 函数来进行检测,且在打开流的使用也支持流协议,虽然说不能有://出现,但是通过前面的分析可以发现,data: 也能解析成功,那是不是就可以直接使用data流协议来进行phar的反序列化攻击而无需文件落地呢?

PHP流协议的底层实现分析

遗憾的是,类似:phar://data:m4x/m4x,123.phar 的payload虽然能成功解析,但是却由于data流协议没有检查状态的函数,导致 wrapper->wops->url_stat 为空,从而无法通过检查。这里记一下,万一哪天PHP版本升级后data流协议就有了自己的状态检查函数呢,哈哈。  

然后继续回到phar_parse_url 函数里面来:

PHP流协议的底层实现分析

前面经过检查后进行了初始化操作,然后来到这里,调用phar_open_from_filename函数检测是否能正常打开phar文件。其他检测都是文件存在且只读的情况都能通过,然后来到最关键的phar_open_from_fp函数里面,在这进行了文件结构的检查和打开:

PHP流协议的底层实现分析

这里定义了zip、gz、bz压缩文件的文件头,且能够识别和打开tar文件,该函数位于 ext/phar/phar.c 的1623行左右,篇幅很长,需要单独研究某种压缩或者打包文件的处理流程的时候可以来这里找,这里整理出大致处理流程:

  1. 先对存在gz、bz压缩的文件进行解压缩处理。

  2. 将解压缩后的文件依次判断是否zip、tar、phar打包文件,是的话调用相关函数进行解析

  3. 由于可以解析tar文件,根据tar文件的特性,文件名会放在文件的最开头,且它能自动识别文件的结束,因此如果有个文件头尾都不可控制,也是可以解析的。

  4. 可以使用上面提到的各种压缩来绕过流量层的检查或者文件内容检查。

接下来直接分析哪里进行了反序列化,这才是关键。然后有些懵逼了,一直以来用的攻击方法打不成功了,换了个7.3版本的打却成功了,原来是8.0版本以后,不再自动对元数据进行反序列化操作,这是官方对于这项更新的公告:https://www.php.net/manual/zh/migration80.incompatible.php
只好切换到7.3的源码来分析,如果没有反序列化攻击,phar也没啥好分析的。
经过前面的各种解压解包之后,来到了这里:
PHP流协议的底层实现分析
从包中提取了metadata然后传入到phar_parse_metadata
PHP流协议的底层实现分析
在这里最终进行了反序列化操作。且metadata可控,导致了反序列化漏洞。

总结


本文章对PHP底层的几个主要流协议实现进行了分析学习,希望能对也在学习这一部分知识的师傅有所帮助。

原文始发于微信公众号(山石网科安全技术研究院):PHP流协议的底层实现分析

版权声明:admin 发表于 2023年12月13日 下午2:56。
转载请注明:PHP流协议的底层实现分析 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...