CISA added CVE-2026-48356 to the Known Exploited Vulnerabilities catalog on October 27 and attached a November 10 remediation date. Sansec watched the first attack against a production Magento store on March 19. In the seven months between those two dates, Adobe shipped the backport, rated it Priority 2, and wrote in the bulletin that it was not aware of any exploits in the wild. If you run Magento Open Source or Adobe Commerce, your patch level is the smaller half of this week's work. The larger half is a directory listing of pub/media/custom_options/, because the upgrade rewrites the code and leaves whatever was uploaded sitting on disk.
What the guest cart API accepts
Sansec found the bug, named it PolyShell, and published the advisory on March 17, 2026, four months before a CVE existed for it. The mechanism is three lines long. Magento's REST API accepts file uploads as part of a cart item's custom options: the request body carries a file_info object with base64 file data, a MIME type and a filename, and Magento writes the result into pub/media/custom_options/quote/.
Three checks are missing from that path:
The checks that are not there
- The submitted option ID is never verified against the product's actual options.
- The upload logic runs whether or not the product has a file-type option at all.
.php,.phtmland.pharare not blocked. The only validation is an image header check throughgetimagesizefromstring.
A file that opens with a GIF header and continues with PHP satisfies the one check that exists. The two routes that matter take no credentials at all, POST /V1/guest-carts/:cartId/items and PUT /V1/guest-carts/:cartId/items/:itemId, so there is no login to rate-limit and no session to invalidate. GraphQL mutations go through different code and are unaffected.
Whether the uploaded file runs depends on your web server, and this is the part most summaries drop. Sansec lists three executing configurations: Magento's own sample nginx config on 2.0.0 through 2.2.x, reached through a file named index.php; any custom nginx that hands every .php request to FastCGI; and Magento before 2.3.5 on Apache without php_flag engine 0. On a web server that refuses to execute anything in that directory, the upload still succeeds. The file waits for a configuration change, a hosting migration, or the day somebody swaps nginx for Apache.
The CVSS vector describes a different attack
Adobe scores this 9.6 and NVD scores it 9.3, both on CVSS 3.1, and both vectors carry UI:R. The NVD description says what that stands for: "Exploitation of this issue requires user interaction in that a victim must visit a maliciously crafted URL or interact with a compromised web page." Adobe files the impact as privilege escalation, with scope changed.
Read those fields together and you get a stored-cross-site-scripting path that ends in administrator account takeover and needs somebody to click something. That path is real. It is also not what put more than 50 scanning IP addresses on store front ends in March. The unauthenticated guest cart upload needs no victim, no click and no session: an attacker posts a polyglot and comes back for it over plain HTTP. CISA's catalog entry describes the outcome as arbitrary code execution, which matches what defenders actually saw.
The consequence lands on anyone who triages by vector. A UI:R flag tells a prioritization script that the attacker needs help from your users, and this attacker needs nothing from them. If your patch policy escalates on AV:N/AC:L/PR:N/UI:N and defers on UI:R, this CVE sorted itself into the slow queue for four months while the uploads continued. Vector fields describe a vendor's model of the bug, and the model can be a description of the second-best attack.
Who owns this, and who can stop reading
If you run Magento Open Source or Adobe Commerce and you have not installed the July 2026 security release, this is yours today. APSB26-73 lists 2.4.9, 2.4.8-p5 and earlier, 2.4.7-p10 and earlier, and 2.4.6-p15 and earlier as affected, plus 2.4.5-p17 and 2.4.4-p18 and earlier on Commerce. The fixed builds carry a date instead of a patch number: 2.4.9-2026-jul, 2.4.8-2026-jul, 2.4.7-2026-jul, 2.4.6-2026-jul, and on Commerce also 2.4.5-2026-jul and 2.4.4-2026-jul. Commerce B2B and Adobe Commerce Events have their own rows and fixed versions.
If your storefront is Shopify, BigCommerce, WooCommerce or anything else, this one is not yours. Close the tab.
One group sits in between and should keep reading: stores that took 2.4.9 when it shipped on May 12, or applied the July release on schedule. You were patched before the KEV entry existed, and you may still be hosting a file. The vulnerable code was present in the first Magento 2 release, so the exposure window predates every current release line. Sansec recorded malicious uploads against 56.7 percent of all stores by March 24 and 82 percent by April 14, and reported Shield blocking attempts against 79.5 percent of protected stores. Those are one vendor's numbers from one vendor's sensor network, and the only published figures anybody has. Treat them as a base rate.
Three steps, in this order
CISA's required action for this entry is the vendor patch. The KEV row sets forensic triage to No, so the directive does not oblige a federal agency to go looking for a pre-patch compromise on this one. Go looking anyway. Read the version off the install rather than out of the change ticket, list the upload directory before you touch anything, and only then decide whether you are running a patch or an incident.
This is the script I run against a document root. It is read-only, it never deletes or quarantines, and it exits non-zero when anything needs a human.
#!/usr/bin/env bash
# polyshell-triage.sh - CVE-2026-48356 exposure and compromise check for one
# Magento Open Source or Adobe Commerce document root. Read-only.
# Usage: ./polyshell-triage.sh /var/www/magento
set -uo pipefail
ROOT="${1:?usage: polyshell-triage.sh <magento-document-root>}"
UPLOADS="$ROOT/pub/media/custom_options"
findings=0
note() { printf '%-9s %s\n' "$1" "$2"; }
hit() { note "$1" "$2"; findings=1; }
# 1. Read the version from the install, never from the change ticket.
ver=""
[ -f "$ROOT/composer.lock" ] && ver=$(jq -r '
.packages[] | select(.name | test("^magento/product-(community|enterprise)-edition$")) | .version
' "$ROOT/composer.lock" 2>/dev/null | head -1)
case "$ver" in
*-2026-jul|*-2026-aug|*-2026-sep|*-2026-oct|*-2026-nov|*-2026-dec|*-2027-*)
note OK "magento $ver carries the July 2026 security release or later" ;;
"") hit REVIEW "no product edition in composer.lock - read bin/magento --version" ;;
*) hit UNFIXED "magento $ver predates APSB26-73 - upgrade to your line's -2026-jul build" ;;
esac
# 2. List the directory the vulnerable API writes to. Anything executable in
# here is an indicator of compromise, patched or not.
if [ -d "$UPLOADS" ]; then
while IFS= read -r f; do
case "${f,,}" in
*.php|*.phtml|*.phar|*.php[0-9]|*.pht|*.html|*.htm) hit SHELL "$f" ;;
*) note UPLOAD "$f" ;;
esac
done < <(find "$UPLOADS" -type f ! -name '.htaccess' 2>/dev/null)
else
note ABSENT "$UPLOADS"
fi
# 3. The second-stage backdoor is sprayed as assets/images/accesson.php inside
# every writable top-level directory, and it always prints 8194460.
while IFS= read -r f; do hit BACKDOOR "$f"; done \
< <(find "$ROOT" -type f -name 'accesson.php' 2>/dev/null)
while IFS= read -r d; do
while IFS= read -r f; do hit BEACON "$f"; done \
< <(grep -rlF '8194460' "$d" 2>/dev/null)
done < <(find "$ROOT" -type d -path '*/assets/images' 2>/dev/null)
# 4. Static check that the directory cannot execute PHP. This does not stop the
# upload; it decides whether the file already on disk runs.
if [ -f "$UPLOADS/.htaccess" ] && grep -qF 'php_flag engine 0' "$UPLOADS/.htaccess"; then
note OK "custom_options/.htaccess disables the PHP engine (Apache)"
elif [ -d "$UPLOADS" ]; then
hit REVIEW "no 'php_flag engine 0' under $UPLOADS - confirm the nginx deny block instead"
fi
exit $findings
Against a store fixture on 2.4.7-p10 with a planted 780index.php it returned one UNFIXED, one SHELL, two BACKDOOR lines and a REVIEW. One result is worth knowing before you rely on it: the 8194460 grep matched only the copy that stores the number as a literal, and missed the copy that computes 409723*20 at runtime. The filename search is what caught that one, which is why both run.
If the nginx deny block is missing, Sansec's configuration snippet has to go in before any \.php$ location block, or the PHP handler keeps precedence and the uploaded shells stay executable. Denying web access does not stop the upload itself. Only a rule that blocks the API call does that.
What the second stage looks like
Hand this part to whoever does your cleanup. The first-stage polyglot usually arrives as index.php, often prefixed with the option ID in the form 780index.php. Sansec's observed filename list also includes json-shell.php, bypass.phtml, rce.php, static.php, c.php, r.php, accesson.php and Unicode-escaped variants such as bypass.php. One shell checks a cookie named d against a hardcoded MD5 value and then runs eval(base64_decode()). A second uses hash_equals() and passes commands straight to system().
After execution, the attackers plant a second backdoor named accesson.php, and they spray it. Sansec observed copies under assets/images/ inside var/, lib/, app/, vendor/, pub/, bin/, setup/, generated/, phpserver/ and bamboo-specs/ on a single store, because scattering copies means at least one survives a cleanup or a redeploy. The backdoor always prints 8194460. The attackers use that as a fingerprint to find live shells across thousands of stores with one HTTP request, and it works just as well in your direction: that number in a response body from any path under your document root means you have an executing backdoor rather than a dormant file.
The third stage is client-side and it is the one that reaches your customers. An obfuscated loader goes into CMS pages or static blocks, caches its payload in localStorage so it survives a page reload, and pulls fresh payloads from lanhd6549tdhse[.]top. Sansec also lists jslibrary[.]net and canevaslab[.]com. Blocking those domains at your edge stops new delivery and does nothing about a payload already cached in a shopper's browser, so the injected block has to come out of the database:
-- Injected loader in CMS content and in the page head. Read-only.
-- Run against the store database with a least-privilege account.
SELECT 'cms_block' AS src, block_id AS id, identifier, update_time
FROM cms_block
WHERE content REGEXP 'lanhd6549tdhse|jslibrary\\.net|canevaslab\\.com|localStorage'
UNION ALL
SELECT 'cms_page', page_id, identifier, update_time
FROM cms_page
WHERE content REGEXP 'lanhd6549tdhse|jslibrary\\.net|canevaslab\\.com|localStorage'
ORDER BY update_time DESC;
-- The other classic injection point: arbitrary markup rendered into every page.
SELECT scope, scope_id, path, value
FROM core_config_data
WHERE path LIKE 'design/head/%' AND value <> '';
Check update_time against your own deployment history. A static block edited at 03:00 on a date with no release is the answer to the question your payment processor will ask first.
What a Priority 2 rating actually asserts
Adobe's severity rating definitions make Priority 2 a claim, not a guess at difficulty. It covers a vulnerability with a high likelihood of exploitation where "there are currently no known active exploits", and the recommendation is to act within 30 days. Priority 1 is the rating for a bug being targeted in the wild. This CVE shipped on July 14 as a Priority 2, in a bulletin stating that Adobe was not aware of exploitation, four months after a commercial research team published the advisory, named the bug and shipped detection for it.
I read that as a definition mismatch rather than negligence: an exploitation claim normally has to clear the vendor's own telemetry or a customer report, and one firm's sensor network is neither. The operational lesson stands on its own. A vendor priority field is an input to your patch order, and for anything internet-facing it is not the deciding one. A credible third-party report of in-the-wild exploitation moves the ticket on its own authority. CISA's catalog reached the same conclusion three months later and put November 10 on it.
List the custom_options directory before you schedule the upgrade
The upgrade is a change window, and change windows slip. The directory listing takes a minute and tells you which problem you have. An empty pub/media/custom_options/ means you are doing a patch. A .php file in there means you are doing an incident, and the order of operations inverts: preserve the file and its timestamps, establish whether that path executes, then go looking for accesson.php and the CMS injection. Deploying over a compromised store destroys the file timestamps that answer when it started, and that is the first thing your acquirer, your insurer and your customers will want.
If that is the morning you are having, we work incident response on live e-commerce infrastructure without flattening the forensics in the process. Book a session and bring the directory listing.
Need an incident response plan before the next attack?
We help organizations build and test incident response playbooks, and we work live e-commerce compromises without flattening the forensics. Book a session with our team.
