译者:[email protected]
原作者:Nicky Bloor@nickstadb
原文地址:https://nickbloor.co.uk/2018/02/28/popping-wordpress/

译者按:作者证明这是最新的WordPress 4.9.4版本存在的问题,默认安装,虽然bug有些不值一提,但会导致RCE,作者希望能够多多讨论这些事情的思路和努力,而不是过多地关注漏洞详细信息。
PoC视频URL:https://videopress.com/v/tYSsU4Ch

0x00 前序


几个月前,我正在编写一篇关于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()函数提供输出来触发代码的话,这就足够了!

0x01 PHP反序列化攻击


简单来说,当攻击者能够将他的数据提供给应用程序,而该应用程序将数据转化为运行对象时没有作适当验证的时候就会出现反序列化漏洞。如果攻击者数据被允许去控制运行对象的属性,那么攻击者就可以操纵任何使用这些对象属性的代码执行流程,就有可能使用它发起攻击。这是一种称为面向属性编程(POP)的技术,一个POP小工具是可以通过这种方式控制任何代码片段,开发实现是通过向应用程序提供特制对象,以便在这些对象进行反序列化的时候触发一些有用的行为。如果想了解更多详情的话,可以参阅我的博客文章Attacking Java Deserialization ,其中的一般概念适用于任何基础技术。

在PHP应用程序的现状来看,POP小工具最为人熟知和最可靠的原因在于类的__wakeup()方法(*PHP“魔术方法”,unserialize()函数会检查是否存在__wakeup(),如果存在,则会先调用__wakeup()方法,预先准备对象需要的资源),如果一个类定义了__wakeup()方法,那么无论何时该类的某个对象使用了unserialize()函数进行反序列化都能保证__wakeup()方法被调用,另外一个原因是__destruct()方法(当创建的对象被销毁或遇到PHP结束标记的时候,比如程序已经执行完毕,对象会自动调用__destruct()执行一些相应的操作,可以自行定义),例如PHP脚本执行完成时(未发生致命错误),当反序列化对象超出范围时仍几乎可以保证__destruct()方法被调用。

除了__wakeup()__destruct()方法之外, PHP还有其他“魔术方法”,可以在类中定义,也可以在反序列化之后调用,这取决于反序列化对象的使用方式。在一个更大更复杂的应用程序中可能很难追踪到反序列化对象在哪里结束以及如何来使用它或调用那些方法,于是确定那些类可以用于PHP反序列化漏洞利用也很困难,因为相关文件可能未包含在入口点,或者一个类的自动加载器(例如spl_autoload_register()函数)可能以及被注册来进一步混淆。

0x02 通用的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()'); }
}}
?>

0x03 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

0x04 分析反序列化入口点


回到刚刚那个调用unserialize()函数的WordPress插件代码片段,我不知道该如何去实际触发unserialize()函数的调用,我所知道的是这个插件应该向http://api.wordpress.org/plugins/info/1.0/发送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小工具,其次它揭示了目标应用程序试图获得的几个属性,这些属性几乎保证影响执行流程,因此可能对开发有用处。

0x05 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的属性是否存在,那么这个日志并不是我当初注意的第三方插件产生的

0x06 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文件并将其提取至服务器上。

0x07 解答

深入挖掘这一点,我注意到即使没有登录,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连接(或者安装恶意代码)……

0x08 注意事项/陷阱

当请求更新详细信息和更新存档时,WordPress会尝试首先通过HTTPS连接到api.wordpress.orgdownloads.wordpress.org,但是如果由于任何原因导致HTTPS启用失败,则使用明文HTTP连接。
  如果WordPress的PHP脚本属于不同的用户,那么WordPress将默认无法自动更新(因此不容易受到上述攻击),例如index.php为用户foo拥有,但WordPress是在用户www-data权限下运行的。

源链接

Hacking more

...