Anatomy of a Magento Webshell Upload Campaign — and a Three-Layer Fix

Published 18 July 2026

TL;DR

An attacker was quietly uploading PHP webshells to our Magento 2 stores through the guest-cart REST API — abusing a file-type custom option to drop 554 malicious files into pub/media/custom_options/quote/. The good news: not one of them ever executed, because our web server was never configured to run PHP from the media directory. The bad news: the upload hole was wide open and the attacker kept coming back every few days.

Here is how we found it in the logs, how the attack worked, and the three independent layers we put in place so that even a future misconfiguration can't turn it into remote code execution.


1. How we found it

Routine log review. Nothing was "down" — response times were healthy, CPU was normal. But the web-server error log was full of a repeating pattern from a single IP:

access forbidden by rule ... request: "GET /media/custom_options/quote/i/n/index.php/.php?k=...&c=printf ..."
open() ".../custom_options/quote/t/d/tdclze.php..gif" failed
"...546a44static.php/index.php" is not found (20: Not a directory)

Over a thousand of these in a day. The filenames — index.php, x.phar, shell.php..gif, cron.php8 — are the signature of someone who has already uploaded a payload and is now trying every trick in the book to get the server to execute it.

Two questions matter in that moment:

  1. Are the files actually on disk? (Is the upload vector real?)
  2. Did any request actually execute one? (Are we breached?)

A quick search of the media tree answered #1 immediately:

find pub/media/custom_options -type f \( -name "*.php*" -o -name "*.phar*" -o -name "*.phtml*" \) | wc -l
# 554

554 files, arriving in neat batches of 42 every few days. Definitely a live vector.

For #2 we checked the response sizes of every successful (200) hit on those paths. An executed shell returns a tiny response (a few bytes of command output). What we saw instead were constant sizes — the raw file source, or a 23,951-byte PNG from Magento's router. We confirmed it directly against the loopback interface:

HTTP 200 size=23951 → first bytes: \x89PNG...

No command output. No shell markers. The PHP never ran. The web server simply does not map PHP execution under /media, so the "shells" were inert text files.


2. How the attack worked

The payload is a classic key-gated command shell, disguised with a GIF89a header so it looks like an image to naive checks:

GIF89a;<?php
$k="<secret>"; if($_REQUEST["k"]!==$k){exit;}
$c=$_REQUEST["c"]; // or base64 in "b"
// tries system() → passthru() → exec() → shell_exec() → popen() → backticks
echo "[S]".$o."[E]";
?>

The delivery mechanism is the interesting part. Magento products can have a custom option of type "file" (think "upload your logo for this engraved sign"). When a file option is submitted — including through POST /rest/V1/guest-carts/{cartId}/items — Magento's ValidatorFile moves the uploaded file into pub/media/custom_options/quote/ as part of validation, before it has confirmed the option is legitimate.

That is the whole trick:

  • The attacker never needs a real product with a file option. The file lands during validation regardless.
  • The per-option "allowed extensions" list is the only extension check — and if it's blank (as it is for every option in a typical catalog), anything is accepted.
  • The uploads ride in over the REST API, which in many setups is exempted from rate limiting and bot protection to accommodate ERP / integration traffic.

To try to get execution, the attacker sprayed evasion variants:

TrickExampleWhat it targets
PATH_INFO.../index.php/?c=...servers that pass *.php/... to PHP
Double extensionshell.php..gifextension checks that only read the last suffix
Alternate suffixesx.phar, x.php8, x.phtmlincomplete PHP handler lists
Path traversal/media/catalog/../custom_options/...location-based rules

All of them failed here — but only because of one config decision (no PHP under /media). That is far too thin a margin. One day someone "fixes" a media location block and suddenly it's remote code execution. So we hardened it properly.


3. The three-layer fix

The principle: stop the execution, stop the upload, and stop the traffic — three independent controls, any one of which is sufficient on its own.

Layer 1 — Web server: never execute (or serve) scripts from /media

The authoritative control. No user-uploaded directory should ever run code. An nginx snippet, included in every storefront server block, returns 403 for any script extension anywhere under /media — and because nginx normalizes the URI before matching, traversal tricks like /media/catalog/../custom_options/... collapse and get caught too:

# Deny web access to script files anywhere under /media (and /pub/media).
# Magento never serves executable PHP from /media; uploaded files are fetched
# only through the download controller, never by direct URL.
location ~* ^/(?:pub/)?media/.*\.(?:php\d?|phar|phtml|pht|phps|cgi|pl|py|sh|asp|aspx|jsp)\b {
    return 403;
}

After reload, every shell path — including .php..gif and the traversal variants — returns 403, while real images and CSS/JS serve normally.

Layer 2 — Application: reject the upload at the source

Defense in depth: don't let the file land on disk at all. A small plugin on Magento's ValidatorFile inspects the incoming filename before the move and rejects it if any dot-separated segment is an executable type. Checking every segment (not just the last) is what defeats shell.php..gif:

public function beforeValidate(ValidatorFile $subject, $processingParams, $option)
{
    foreach ($_FILES as $key => $info) {
        if (!preg_match('/options_\d+_file/', (string)$key)) {
            continue; // only custom-option file fields
        }
        foreach ((array)($info['name'] ?? []) as $name) {
            foreach (explode('.', strtolower((string)$name)) as $segment) {
                if (in_array(trim($segment), self::BLOCKED_EXTENSIONS, true)) {
                    throw new LocalizedException(__('The file you uploaded has a disallowed file type.'));
                }
            }
        }
    }
}

BLOCKED_EXTENSIONS covers php, php2php8, phtml, pht, phps, phar, phpt, cgi, pl, py, sh, asp, aspx, jsp, shtml, htaccess, and more. Legitimate images and PDFs pass untouched. (Worth checking your catalog first — many stores have zero legitimate file-type options, in which case you can be even stricter.)

Layer 3 — Edge / WAF: block the actor and rate-limit the path

At the CDN/WAF we blocked the source IP and closed a subtler gap: a broad /rest/* "skip" rule — added long ago so an ERP integration wouldn't be challenged — was disabling rate limiting for all REST traffic, including the cart-upload endpoint. We already had a rule that limits cart API calls to 10/min per IP; it simply never fired. Narrowing the skip so it no longer covers the guest-cart path re-armed that limit on exactly the abused endpoint, with no impact on the integration (which never uses cart endpoints).

The lesson generalizes: a "skip everything for /api" rule is a security blind spot. Scope your integration bypasses to the integration's source IPs, not to a whole URL prefix.


4. Verify, don't assume

Every layer was tested after deployment:

  • Layer 1: live requests to the shell paths (and traversal variants) now return 403; control requests for real images return 200.
  • Layer 2: a behavioral test through Magento's object manager — index.php, shell.php..gif, x.phar, a.php8, c.pHtMl all blocked; photo.jpg, doc.pdf allowed. 8/8.
  • Layer 3: confirmed across all zones that the IP is blocked, the skip excludes the cart path, and the rate-limit rule is present.

And of course: the 554 existing files were archived and deleted, and the rest of the media tree was scanned for anything else.


5. A checklist for your Magento store

If you run Magento 2 (or honestly any app with user file uploads), spend ten minutes on this today:

  • Confirm your web server cannot execute PHP under /media (or /pub/media). This is the single most important control.
  • Add an explicit 403 for script extensions under /media — belt and suspenders, and it catches source-code disclosure too.
  • Grep your media dirs for scripts: find pub/media -type f \( -name '*.php*' -o -name '*.phar' -o -name '*.phtml' \)
  • Audit file-type custom options. If you don't use them, restrict or disable them; if you do, set an explicit allowed-extensions whitelist per option.
  • Check your WAF for broad /api or /rest bypass rules. Scope them to known integration IPs, not to a URL prefix.
  • Read your logs even when nothing looks wrong. This campaign produced zero customer-facing symptoms. We found it because someone looked.

Layered defenses aren't paranoia — they're what turns "a single config decision saved us" into "it would have taken four separate failures to breach us."


Questions or want the full nginx snippet and Magento module? Reach out — happy to share.