0×00 前序
几个月前, 我正在编写一篇关于 PHP 反序列化漏洞的博客文章, 决定为这篇文章找一个真实目标, 能够让我将测试数据传输给 PHP unserialize ()函数来实现演示目的于是我下载了一批 WordPress 插件, 并开始通过 grepping 来寻找调用 unserialize ()的代码实例:
- $url = 'http://api.wordpress.org/plugins/info/1.0/';
- $response = wp_remote_post ($url, array ('body' => $request));
- $plugin_info = @unserialize ($response ['body']);
- if (isset ($plugin_info->ratings)) {
这个插件的问题在于发送明文 HTTP 请求, 并且将该请求响应传递给了 unserialize ()函数就真实攻击而言, 它并不是最佳入口点, 但是如果我能通过这种微不足道的方式向 unserialize ()函数提供输出来触发代码的话, 这就足够了!
0×01 PHP 反序列化攻击
简单来说, 当攻击者能够将他的数据提供给应用程序, 而该应用程序将数据转化为运行对象时没有作适当验证的时候就会出现反序列化漏洞如果攻击者数据被允许去控制运行对象的属性, 那么攻击者就可以操纵任何使用这些对象属性的代码执行流程, 就有可能使用它发起攻击这是一种称为面向属性编程 (POP) 的技术, 一个 POP 小工具是可以通过这种方式控制任何代码片段, 开发实现是通过向应用程序提供特制对象, 以便在这些对象进行反序列化的时候触发一些有用的行为如果想了解更多详情的话, 可以参阅我的博客文章 Attacking Java Deserialization(nickbloor.co.uk/2017/08/13/), 其中的一般概念适用于任何基础技术
在 PHP 应用程序的现状来看, POP 小工具最为人熟知和最可靠的原因在于类的__wakeup()方法 (PHP 魔术方法, unserialize() 函数会检查是否存在__wakeup(), 如果存在, 则会先调用__wakeup()方法, 预先准备对象需要的资源), 如果一个类定义了__wakeup()方法, 那么无论何时该类的某个对象使用了 unserialize ()函数进行反序列化都能保证__wakeup()方法被调用, 另外一个原因是__destruct ()方法 (当创建的对象被销毁或遇到 PHP 结束标记的时候, 比如程序已经执行完毕, 对象会自动调用__destruct() 执行一些相应的操作, 可以自行定义), 例如 PHP 脚本执行完成时 (未发生致命错误), 当反序列化对象超出范围时仍几乎可以保证__destruct () 方法被调用
除了__wakeup ()和__destruct ()方法之外, PHP 还有其他魔术方法, 可以在类中定义, 也可以在反序列化之后调用, 这取决于反序列化对象的使用方式在一个更大更复杂的应用程序中可能很难追踪到反序列化对象在哪里结束以及如何来使用它或调用那些方法, 于是确定那些类可以用于 PHP 反序列化漏洞利用也很困难, 因为相关文件可能未包含在入口点, 或者一个类的自动加载器 (例如 spl_autoload_register() 函数)可能以及被注册来进一步混淆
0×02 通用的 PHP POP 小工具
为了简化这个过程, 我编写了一个 PHP 类, 它定义了所有魔术方法并且在调用任何魔术方法时将详细信息写入日志文件特别有趣的是魔术方法__get()和__call(), 如果应用程序尝试获取不存在的属性或调用该类中不存在的方法时就会调用以上魔术方法, 前者可以用来识别在 payload object 上设置的属性, 以便操纵并使用这些属性的代码, 而后者可以用来识别 POP 小工具触发使用的非魔术方法(并且可以将它们自身用作 POP 小工具)
该类的__wakeup ()方法还使用了 get_declared_classes ()函数来检索和记录可以利用 exploit payload 的已声明类的列表(虽然这不会反映当前未声明但可以自动加载的类)
- <?php
- if(!class_exists("UniversalPOPGadget")) {
- class UniversalPOPGadget {
- private function logEvent($event) {
- file_put_contents('UniversalPOPGadget.txt', $event . "\r\n", FILE_APPEND);
- }
- public function __construct() { $this->logEvent('UniversalPOPGadget::__construct()'); }
- public function __destruct() { $this->logEvent('UniversalPOPGadget::__destruct()'); }
- public function __call($name, $args) {
- $this->logEvent('UniversalPOPGadget::__call(' . $name . ',' . implode(',', $args) . ')');
- }
- public static function __callStatic($name, $args) {
- $this->logEvent('UniversalPOPGadget::__callStatic(' . $name . ',' . implode(',', $args) . ')');
- }
- public function __get($name) { $this->logEvent('UniversalPOPGadget::__get(' . $name . ')'); }
- public function __set($name, $value) { $this->logEvent('UniversalPOPGadget::__set(' . $name . ',' . $value . ')'); }
- public function __isset($name) { $this->logEvent('UniversalPOPGadget::__isset(' . $name . ')'); }
- public function __unset($name) { $this->logEvent('UniversalPOPGadget::__unset(' . $name . ')'); }
- public function __sleep() { $this->logEvent('UniversalPOPGadget::__sleep()'); return array(); }
- public function __wakeup() {
- $this->logEvent('UniversalPOPGadget::__wakeup()');
- $this->logEvent("[!] Defined classes:");
- foreach(get_declared_classes() as $c) {
- $this->logEvent("[+]" . $c);
- }
- }
- public function __toString() { $this->logEvent('UniversalPOPGadget::__toString()'); }
- public function __invoke($param) { $this->logEvent('UniversalPOPGadget::__invoke(' . $param . ')'); }
- public function __set_state($properties) {
- $this->logEvent('UniversalPOPGadget::__set_state(' . implode(',', $properties) . ')');
- }
- public function __clone() { $this->logEvent('UniversalPOPGadget::__clone()'); }
- public function __debugInfo() { $this->logEvent('UniversalPOPGadget::__debugInfo()'); }
- }}
- ?>
0×03 PHP 检测
将上面的代码保存到一个 PHP 文件中, 我们可以通过这个在其他任何 PHP 脚本中插入一个 include/path/to/UniversalPOPGadget.php 语句, 并使这个类可用以下 Python 脚本将查找给定目录中所有 PHP 文件, 并将语句写入文件前端, 从而有效地检测应用程序, 以便我们可以向为其提供序列化的 UniversalPOPGadget 对象, 来用它们研究反序列化的入口点
- import os
- import sys
- #Set this to the absolute path to the file containing the UniversalPOPGadget class
- GADGET_PATH = "/path/to/UniversalPOPGadget.php"
- #File extensions to instrument
- FILE_EXTENSIONS = [".php", ".php3", ".php4", ".php5", ".phtml", ".inc"]
- #Check command line args
- if len(sys.argv) != 2:
- print "Usage: GadgetInjector.py <path>"
- print ""
- sys.exit()
- #Search the given path for PHP files and modify them to include the universal POP gadget
- for root, dirs, files in os.walk(sys.argv[1]):
- for filename in files:
- for ext in FILE_EXTENSIONS:
- if filename.lower().endswith(ext):
- #Instrument the file and stop checking file extensions
- fIn = open(os.path.join(root, filename), "rb")
- phpCode = fIn.read()
- fIn.close()
- fOut = open(os.path.join(root, filename), "wb")
- fOut.write("<?php include'" + GADGET_PATH + "'; ?>" + phpCode)
- fOut.close()
- break
0×04 分析反序列化入口点
回到刚刚那个调用 unserialize()函数的 WordPress 插件代码片段, 我不知道该如何去实际触发 unserialize()函数的调用, 我所知道的是这个插件应该向 api.wordpress.org/plugins/inf 发送 HTTP 请求, 于是我使用上面的 Python 脚本来测试 WordPress 和插件代码, 然后修改了服务器上的 hosts 文件, 将 api.wordpress.org 指向同一台服务器以下代码放在 web 根目录中的 / plugins/info/1.0/index.php 文件中, 以便提供 UniversalPOPGadget payload:
- <?php
- include('UniversalPOPGadget.php');
- print serialize(new UniversalPOPGadget());
在使用这种手段后, 我开始像往常一样使用 WordPress 实例, 特别注意了与目标 WordPress 插件相关的所有功能, 同时查看 UniversalPOPGadget 日志文件很快地, 生成了一些日志文件, 其中包括以下内容(为简洁起见, 已将大量可用类删除):
- UniversalPOPGadget::__wakeup()
- [!] Defined classes:
- [...Snipped...]
- UniversalPOPGadget::__get(sections)
- UniversalPOPGadget::__isset(version)
- UniversalPOPGadget::__isset(author)
- UniversalPOPGadget::__isset(requires)
- UniversalPOPGadget::__isset(tested)
- UniversalPOPGadget::__isset(homepage)
- UniversalPOPGadget::__isset(downloaded)
- UniversalPOPGadget::__isset(slug)
- UniversalPOPGadget::__get(sections)
- UniversalPOPGadget::__get(sections)
- UniversalPOPGadget::__isset(banners)
- UniversalPOPGadget::__get(name)
- UniversalPOPGadget::__get(sections)
- UniversalPOPGadget::__isset(version)
- UniversalPOPGadget::__isset(author)
- UniversalPOPGadget::__isset(last_updated)
- UniversalPOPGadget::__isset(requires)
- UniversalPOPGadget::__isset(tested)
- UniversalPOPGadget::__isset(active_installs)
- UniversalPOPGadget::__isset(slug)
- UniversalPOPGadget::__isset(homepage)
- UniversalPOPGadget::__isset(donate_link)
- UniversalPOPGadget::__isset(rating)
- UniversalPOPGadget::__isset(ratings)
- UniversalPOPGadget::__isset(contributors)
- UniversalPOPGadget::__isset(tested)
- UniversalPOPGadget::__isset(requires)
- UniversalPOPGadget::__get(sections)
- UniversalPOPGadget::__isset(download_link)
日志文件中显示, 在 UniversalPOPGadget 对象被反序列化之后, 用程序试图获取或检查是否存在多个属性 (段版本作者等等) 首先这就告诉我们, 通过这个特定的入口点我们可以使用任何可用类中的任何定义在__get ()或__isset ()方法中的代码来作为 POP 小工具, 其次它揭示了目标应用程序试图获得的几个属性, 这些属性几乎保证影响执行流程, 因此可能对开发有用处
0×05 Sections 属性?
上面的日志文件显示, 与反序列化对象的首次交互是尝试获取名为 sections 的属性
- $url = 'http://api.wordpress.org/plugins/info/1.0/';
- $response = wp_remote_post ($url, array ('body' => $request));
- $plugin_info = @unserialize ($response ['body']);
- if (isset ($plugin_info->ratings)) {
现在来看最初的目标插件, 它在调用 unserialize ()之后做的第一件事是检查名为 rating 的属性是否存在, 那么这个日志并不是我当初注意的第三方插件产生的!
0×06 POPping WordPress 出现的意外
对 WordPress 代码进行一次快速 grep, 对于上面提到的 HTTP URL, 显示该请求是由 wp-admin/includes/plugin-install.php 文件中的 WordPress 插件 API 发送的浏览代码时并不清楚反序列化的 payload object 是如何使用的, 或者切确地说这个 HTTP 请求以及随后对 unserialize ()函数的调用是从哪里触发的我继续点击 WordPress 管理界面, 发现日志是从主控制面板更新页面和插件页面生成的重新加载这些页面使我能够触发目标 HTTP 请求, 并向 unserialize ()函数提供任意数据
我记录了一些 WordPress 发出的 HTTP 请求并把它们发送到真正的 api.wordpress.org 以获取实例响应, 结果响应的是 stdClass 类型的序列化对象, 更重要的是示例响应给了我一个预期中 WordPress 会收到的属性的确切列表, 其中每个属性都有可能用于操控某些核心 WordPress 代码的执行流程我根据捕获到的真实响应修改了伪造的 api.wordpress.org 用来返回序列化对象以下是这个的一个简单例子:
- <?php
- $payloadObject = new stdClass();
- $payloadObject->name = "PluginName";
- $payloadObject->slug = "PluginSlug";
- $payloadObject->version = "PluginVersion";
- print serialize($payloadObject);
我开始修改这些对象的属性并刷新相关的 WordPress 页面, 来测试修改内容对结果页面有何影响 (如果有的话) 在有些情况下 WordPress 使用了 HTML 编码来防止 HTML/JavaScript 注入, 但是最终我发现了几个可以插入任意 HTML 和 JavaScript 的字段请记住这个情况是发生在管理界面内, 如果管理员登录并浏览更新或插件页面, 攻击者就能够对 WordPress 站点执行 MitM 攻击或 DNS 欺骗, 也可能会利用此漏洞实现远程代码执行
在快速尝试一些 JavaScript 和 Python 脚本之后我有了假设漏洞的运用证明这个 PoC 会导致 WordPress 管理界面中的更新和插件菜单旁显示一个徽章, 表示有更新可用(当然即使没有也会显示), 这可能会诱导管理员点击这些链接来检查并可能安装这些更新如果有管理员点击任一链接, 那么一个 JavaScript payload 被注入到该页面中, 然后就添加了一个新的管理员账户并将一个基本 PHP 命令 shell 注入到现行的 WordPress 主题的 index.php 中
在大多数情况下这种 PoC 攻击足以实现代码执行, 但是我也发现了我可以使用类似方式向 WordPress 发送一个错误的插件更新来攻击 WordPress 管理界面的点击更新功能, 如果有管理员点击了更新按钮, 就会导致下载一个假插件更新的 ZIP 文件并将其提取至服务器上
0×07 解答
深入挖掘这一点, 我注意到即使没有登录, WordPress 也会发送了类似对 api.wordpress.org 的 HTTP 请求, 我开始对 WordPress 进行代码审计来了解其中发生了什么, 以及它是否可能遭受了类似攻击我在 wp-includes/update.php 文件中发现了 wp_schedule_update_checks()函数
- function wp_schedule_update_checks() {
- if ( ! wp_next_scheduled( 'wp_version_check' ) && ! wp_installing() )
- wp_schedule_event(time(), 'twicedaily', 'wp_version_check');
- if ( ! wp_next_scheduled( 'wp_update_plugins' ) && ! wp_installing() )
- wp_schedule_event(time(), 'twicedaily', 'wp_update_plugins');
- if ( ! wp_next_scheduled( 'wp_update_themes' ) && ! wp_installing() )
- wp_schedule_event(time(), 'twicedaily', 'wp_update_themes');
- }
WordPress 会每天两次调用 wp_version_check ()函数 wp_update_plugins ()函数和 wp_update_themes ()函数默认情况下, 这些更新检查也可以通过 wp-cron.php 发送 HTTP 请求来触发于是我开始手动审计这些函数, 并修改代码来记录各种数据以及分支和函数调用的结果, 查看发生了什么, 函数是否根据来自 api.wordpress.org 的响应而做出了任何危险的操作
最终我设法伪造了来自 api.wordpress.org 的几个响应, 来触发对 $upgrader->upgrade()的调用, 然而以前的伪造插件更新攻击在这里似乎不起作用, 之后我在 should_update()方法中发现了以下注释:
- /**
- * [...Snipped...]
- *
- * Generally speaking, plugins, themes, and major core versions are not updated
- * by default, while translations and minor and development versions for core
- * are updated by default.
- *
- * [...Snipped...]
- */
事实证明这是 WordPress 试图升级内置 Hello Dolly 插件的翻译, 我一直试图从 downloads.wordpress.org 下载 hello-dolly-1.6-en_GB.zip, 而不是请求我伪造的插件 zip 文件我下载了原始文件, 添加了一个 shell.php 文件, 并将其托管在我的虚假 downloads.wordpress.org 网站上于是下一次我访问了 wp-cron.php,WordPress 下载了伪造的更新并解压到 wp-content/languages/plugins/, 其中包括 shell 等等
攻击者既然可以对 WordPress 网站执行 MitM 攻击或 DNS 欺骗, 那么就可以针对自动更新功能执行零交互攻击, 并将恶意脚本写入服务器当然这不一定是一次简单的攻击, 但这仍然不可能!
WordPress 团队意识到这些问题, 但是他们的立场似乎是, 如果 HTTPS 启用失败, 为了允许在具有旧或损坏的 SSL 堆栈系统上运行的 WordPress 网站进行更新, WordPress 将会故意降级为 HTTP 连接(或者安装恶意代码)
0×08 注意事项 / 陷阱
当请求更新详细信息和更新存档时, WordPress 会尝试首先通过 HTTPS 连接到 api.wordpress.org 和 downloads.wordpress.org, 但是如果由于任何原因导致 HTTPS 启用失败, 则使用明文 HTTP 连接
如果 WordPress 的 PHP 脚本属于不同的用户, 那么 WordPress 将默认无法自动更新(因此不容易受到上述攻击), 例如 index.php 为用户 foo 拥有, 但 WordPress 是在用户 www-data 权限下运行的
来源: https://juejin.im/entry/5ab32138518825555d472980