top of page

PolyShell Magento Attack: Hunt the CMS Block Loader

Writer: Inception Security
Inception Security
13 minutes ago
10 min read

What this means for your clients: a Magento store can look patched and still send shoppers' browsers to an attacker domain, because the loader often sits in the database rather than in a scanned file.


A client's Magento storefront passes a routine file scan. Then a shopper's browser loads a script from deepveindrilling[.]com, and the next stage decides whether that visitor looks like a real buyer or a scanner.


That is the PolyShell Magento problem Sansec has been tracking. PolyShell is CVE-2026-48356, an unrestricted file upload in the Magento REST API that Sansec named and found. CISA added it to the CISA KEV catalog on Oct 27, 2026, due Nov 10. Sansec's October research shows the group writes a CMS block loader into cms_block, so file scans miss it until you query the database.


How the loader outlives a patch


Sansec says PolyShell lets an unauthenticated caller upload an executable file through guest cart REST API routes, often as a polyglot image. Web server config around pub/media decides remote code execution versus stored script injection. Sansec says the upload needs no login, and NVD's score agrees that no privileges are required. NVD's user interaction flag describes a victim loading the planted file, not the upload itself, so do not use it to rule a store out.


Once the attacker can write to the store, Sansec says they inject a small loader into cms_block content. The October form first tries fpCode from the visitor's localStorage, then calls deepveindrilling[.]com. A visitor who already received a payload can keep running it on later page views even if you block the domain. Sansec has not captured the final October payload.


What the numbers do and do not mean


Sansec counts 953 stores that ran this group's loader since July 2025, mostly Magento, across five domains. That is Sansec's campaign total for the loader, not a count of PolyShell exploits. Sansec found deepveindrilling[.]com in CMS blocks of 83 stores, and 60 of those 83 were earlier victims. On March 30, 2026, Sansec says a PolyShell mass wave put a loader on 471 stores in one hour after webshells were uploaded.


Sansec also saw a Magento 2.4.7-p10 store with no missing core patches receive the deepveindrilling loader on Oct 23, an hour after a clean scan, after a rogue admin and a GSocket remote access tool appeared weeks earlier. Sansec does not say that store was hit through PolyShell, so we do not either.


Which versions Adobe lists and which builds fix it


Adobe's bulletin APSB26-73, published July 14, 2026, lists these lines as affected. Each line is the last affected build for that branch:


  • Adobe Commerce: 2.4.9, 2.4.8-p5, 2.4.7-p10, 2.4.6-p15, 2.4.5-p17, 2.4.4-p18, and earlier

  • Magento Open Source: 2.4.9, 2.4.8-p5, 2.4.7-p10, 2.4.6-p15, and earlier

  • Adobe Commerce B2B: 1.5.3, 1.5.2-p5, 1.4.2-p10, 1.3.4-p17, 1.3.3-p18, and earlier

  • Adobe Commerce Events: 1.6.0 to 1.20.0


The fixed builds carry the "-2026-jul" suffix per branch: 2.4.9, 2.4.8, 2.4.7, and 2.4.6 for both Adobe Commerce and Magento Open Source, plus 2.4.5 and 2.4.4 for Adobe Commerce only. B2B is fixed at 1.5.3, 1.5.2, 1.4.2, 1.3.4, and 1.3.3, each with the same suffix, and Events at 1.21.0.


Adobe rates CVE-2026-48356 at CVSS 9.6, critical, with no authentication required and the impact listed as privilege escalation. NVD lists 9.3 under CWE-434; treat each score as its source's rating. Adobe's page was last updated Aug 11, 2026 and still says Adobe is not aware of exploits in the wild, which predates CISA adding the flaw to KEV on Oct 27. The page does not mention PolyShell or cms_block, so those names stay with Sansec. Sansec also tells stores to install APSB25-88 for SessionReaper.


If a store loads the loader


If a store loads any of Sansec's loader domains, Sansec says the attacker can write to the database, so deleting the script alone is not enough. Run Sansec's SQL on cms_block and cms_page for fpCode, se_referrer, and localStorage.getItem, and use update_time as the last write.


Check pub/media/custom_options/ and pub/media/customer_address/ for files other than .htaccess, search for accesson.php, and review admin_user for unknown accounts.


Rotate database, admin, and API credentials, then schedule APSB26-73 and APSB25-88. Harden by blocking script execution under pub/media, putting a WAF on upload endpoints, and enabling CSP with reporting.


Hunts to run (test first)


These hunts find evidence of a CMS block loader or prior upload abuse, not proof of payment theft. The table under the hunts, "Interpret before you escalate", says what is benign and what is not.


Test query. Confirm Magento DB rows, web or CDN logs, and any CSP or DNS stream you expect are reaching the index. Empty usually means the source is missing, the range is wrong, or a field name does not match.


Splunk


index=* (sourcetype=magento:db OR sourcetype=nginx:access OR sourcetype=apache:access OR sourcetype=csp:report OR sourcetype=linux:process) earliest=-24h
| stats count by index, sourcetype
| sort - count

Elastic


FROM logs-*, metrics-*
| WHERE @timestamp > NOW() - 24 hours
| STATS count = COUNT(*) BY data_stream.dataset
| SORT count DESC

CrowdStrike


// Time picker: last 24 hours
(#event_simpleName=ProcessRollup2) OR (#Vendor=/(magento|nginx|apache|csp)/i) OR (#repo=/magento/i)
| groupBy([#event_simpleName, #Vendor], function=count(as=count))
| sort(count, order=desc)

Microsoft KQL


union isfuzzy=true withsource=SourceTable DeviceProcessEvents, DeviceFileEvents, CommonSecurityLog, Syslog
| where TimeGenerated > ago(24h) or Timestamp > ago(24h)
| summarize count() by SourceTable
| order by count_ desc

Swap in your Magento DB and CSP tables if you ship scheduled SQL or browser reports into Sentinel.


Hunt 1: cms_block and cms_page loader content. Needs Magento DB read access or a scheduled SQL export in the SIEM. Flag content matching fpCode, se_referrer, localStorage.getItem, or Sansec loader domains, plus update_time and unexpected admin_user rows. Map table, content, identifier, username and update_time to whatever your export calls them, and keep the table name and the row identifier, so you can open the block that matched.


Splunk


index=magento sourcetype=magento:db (table=cms_block OR table=cms_page OR table=admin_user) earliest=-30d
| eval matched=if(match(content, "(?i)(fpCode|se_referrer|localStorage\.getItem|deepveindrilling|additionalver|lanhd6549tdhse|neswnsssn|manulfie|hdfdsi84j3h|c85grt98sd|leqdger|crypto-insider)"), 1, 0)
| where matched=1 OR (table="admin_user" AND action IN ("insert","create"))
| stats values(identifier) as ids, values(username) as users, max(update_time) as last_write by host, table

Elastic


// ES|QL; field names assumed; TO_LOWER + .*(...).* because RLIKE has no (?i)
FROM logs-magento.db*
| WHERE @timestamp > NOW() - 30 days
  AND (
    (table IN ("cms_block", "cms_page") AND TO_LOWER(content) RLIKE ".*(fpcode|se_referrer|localstorage\\.getitem|deepveindrilling|additionalver|lanhd6549tdhse|neswnsssn|manulfie|hdfdsi84j3h|c85grt98sd|leqdger|crypto-insider).*")
    OR (table == "admin_user" AND action IN ("insert", "create"))
  )
| STATS ids = VALUES(identifier), users = VALUES(username), last_write = MAX(update_time) BY host.name, table

CrowdStrike


// Assumes Magento DB audit or export ingested into Falcon Next-Gen SIEM; field names assumed
#Vendor=magento
| (table=/(cms_block|cms_page)/i content=/(?i)(fpCode|se_referrer|localStorage\.getItem|deepveindrilling|additionalver|lanhd6549tdhse|neswnsssn|manulfie|hdfdsi84j3h|c85grt98sd|leqdger|crypto-insider)/)
  or (table=/admin_user/i action=/(insert|create)/i)
| groupBy([ComputerName, table], function=[collect([identifier, username]), max(update_time, as=last_write)])

Microsoft KQL


// Assumed custom table from a scheduled Magento DB export; swap names for yours
MagentoDb_CL
| where TimeGenerated > ago(30d)
| where (table_s in ("cms_block", "cms_page") and content_s matches regex @"(?i)(fpCode|se_referrer|localStorage\.getItem|deepveindrilling|additionalver|lanhd6549tdhse|neswnsssn|manulfie|hdfdsi84j3h|c85grt98sd|leqdger|crypto-insider)")
    or (table_s == "admin_user" and action_s in ("insert", "create"))
| summarize ids = make_set(identifier_s, 20), users = make_set(username_s, 20), last_write = max(update_time_t) by HostName, table_s

Theme edits can match loosely, so open the block before you declare a hit.


Hunt 2: upload-path and REST pairing. Needs nginx, Apache, or CDN logs. Flag requests under pub/media/custom_options/ or pub/media/customer_address/ for .php or odd .png, accesson.php, and REST cart item POSTs. Each query keeps a 30 minute window only when it holds a shell hit, or when it holds both a REST cart item POST and a request for a file under the upload directories. A lone cart POST is ordinary shopper traffic and is dropped. Splunk, Elastic and KQL group the window by host. CrowdStrike groups it by log repository, because the host field is assumed, and applies the same test to the collected signals, so check that filter first when you run it. The REST-to-file pairing is unordered co-occurrence inside one window, not a sequence, and it is our correlation, not a Sansec query. Map uri_path, url.path or RequestURL, and the request method, to what your log source calls them.


Splunk


index=web (sourcetype=nginx:access OR sourcetype=apache:access OR sourcetype=cdn:access) earliest=-7d
| eval path=coalesce(uri_path, uri, url)
| eval is_upload_path=if(match(path, "(?i)/pub/media/(custom_options|customer_address)/"), 1, 0)
| eval is_shell=if(match(path, "(?i)accesson\.php(\?|$)") OR (is_upload_path=1 AND match(path, "(?i)\.(php|phtml|phar)(\?|$)")), 1, 0)
| eval is_rest=if(match(path, "(?i)/V1/guest-carts/.*/items") AND method="POST", 1, 0)
| where is_shell=1 OR is_rest=1 OR (is_upload_path=1 AND match(path, "(?i)\.(png|gif|jpg)(\?|$)"))
| bin _time span=30m
| stats values(path) as paths, values(method) as methods, values(clientip) as ips, max(is_shell) as shell, max(is_rest) as rest, max(is_upload_path) as upload by host, _time
| where shell=1 OR (rest=1 AND upload=1)

Elastic


// ES|QL; url.path assumed; TO_LOWER for RLIKE
FROM logs-nginx.access-*, logs-apache.access-*, logs-cdn.*
| WHERE @timestamp > NOW() - 7 days
| EVAL path = COALESCE(url.path, url.original)
| EVAL path_l = TO_LOWER(path)
| EVAL is_upload = LOCATE(path_l, "/pub/media/custom_options/") > 0 OR LOCATE(path_l, "/pub/media/customer_address/") > 0
| EVAL is_shell = path_l RLIKE ".*accesson\\.php(\\?.*)?" OR (is_upload AND path_l RLIKE ".*\\.(php|phtml|phar)(\\?.*)?")
| EVAL is_rest = path_l RLIKE ".*/v1/guest-carts/.*/items.*" AND http.request.method == "POST"
| WHERE is_shell OR is_rest OR (is_upload AND path_l RLIKE ".*\\.(png|gif|jpg)(\\?.*)?")
| EVAL bucket = DATE_TRUNC(30 minutes, @timestamp)
| STATS paths = VALUES(path), methods = VALUES(http.request.method), ips = VALUES(source.ip),
        shell = MAX(CASE(is_shell, 1, 0)), rest = MAX(CASE(is_rest, 1, 0)), upload = MAX(CASE(is_upload, 1, 0))
    BY host.name, bucket
| WHERE shell == 1 OR (rest == 1 AND upload == 1)

CrowdStrike


// Assumes Apache or nginx style raw request lines in Falcon Next-Gen SIEM; JSON CDN logs need mapped fields
(#Vendor=/(nginx|apache|cdn|cloudflare)/i)
| (@rawstring=/pub\/media\/(custom_options|customer_address)\//i or @rawstring=/accesson\.php/i or @rawstring=/V1\/guest-carts\//i)
| case {
    @rawstring=/accesson\.php/i or (@rawstring=/pub\/media\/(custom_options|customer_address)\//i and @rawstring=/\.(php|phtml|phar)(\?|\s|"|$)/i) | signal := "shell_or_php";
    @rawstring=/"POST\s+[^"]*V1\/guest-carts\/[^"]*\/items/i | signal := "rest_post";
    @rawstring=/pub\/media\/(custom_options|customer_address)\//i | signal := "upload_path";
    * | signal := "none"
  }
| signal != "none"
| bucket(span=30m, field=[#repo], function=[collect([signal, @rawstring]), count(as=count)])
| signal=/shell_or_php/ or (signal=/rest_post/ signal=/upload_path/)

Microsoft KQL


CommonSecurityLog
| where TimeGenerated > ago(7d)
| extend path = coalesce(RequestURL, AdditionalExtensions)
| extend is_upload = path matches regex @"(?i)/pub/media/(custom_options|customer_address)/"
| extend is_shell = path matches regex @"(?i)accesson\.php(\?|$)" or (is_upload and path matches regex @"(?i)\.(php|phtml|phar)(\?|$)")
| extend is_rest = path matches regex @"(?i)/V1/guest-carts/.*/items" and RequestMethod == "POST"
| where is_shell or is_rest or (is_upload and path matches regex @"(?i)\.(png|gif|jpg)(\?|$)")
| summarize paths = make_set(path, 30), methods = make_set(RequestMethod, 5), ips = make_set(SourceIP, 20),
    shell = max(toint(is_shell)), rest = max(toint(is_rest)), upload = max(toint(is_upload))
    by DeviceName, bin(TimeGenerated, 30m)
| where shell == 1 or (rest == 1 and upload == 1)

CDN path rewriting can hide /pub/media, so match custom_options alone if your edge strips the prefix. The 30 minute REST-to-file window is ours, and a pair that straddles a window edge will be missed.


Hunt 3: browser calls to Sansec IOC domains. Needs CSP report-uri or report-to collection, or proxy or DNS logs. Any client request to the Sansec loader, Keitaro, or payload hosts is in scope. Map blocked_uri for CSP, url or query for proxy, and query_name for DNS to your own fields. If you only collect one of the three, delete the other branches instead of leaving them to match nothing.


Splunk


index=* (sourcetype=csp:report OR sourcetype=proxy:http OR sourcetype=dns) earliest=-14d
| eval dest=coalesce(blocked_uri, url, query, query_name)
| where match(dest, "(?i)deepveindrilling\.com|additionalver\.com|lanhd6549tdhse\.top|neswnsssn\.click|manulfie\.click|hdfdsi84j3h\.top|c85grt98sd\.com|leqdger\.click|crypto-insider\.top")
| stats count, values(dest) as dests, values(src) as srcs, min(_time) as first_seen by host, sourcetype

Elastic


// ES|QL; field names assumed; TO_LOWER for RLIKE
FROM logs-csp.*, logs-proxy.*, logs-dns.*
| WHERE @timestamp > NOW() - 14 days
| EVAL dest = TO_LOWER(COALESCE(csp.blocked_uri, url.full, dns.question.name))
| WHERE dest RLIKE ".*(deepveindrilling\\.com|additionalver\\.com|lanhd6549tdhse\\.top|neswnsssn\\.click|manulfie\\.click|hdfdsi84j3h\\.top|c85grt98sd\\.com|leqdger\\.click|crypto-insider\\.top).*"
| STATS count = COUNT(*), dests = VALUES(dest), first_seen = MIN(@timestamp) BY host.name, data_stream.dataset

CrowdStrike


// Time picker: last 14 days. Assumes CSP, proxy, or DNS in Next-Gen SIEM
@rawstring=/(?i)(deepveindrilling\.com|additionalver\.com|lanhd6549tdhse\.top|neswnsssn\.click|manulfie\.click|hdfdsi84j3h\.top|c85grt98sd\.com|leqdger\.click|crypto-insider\.top)/
| groupBy([#Vendor, #repo], function=[count(as=count), collect(@rawstring), min(@timestamp, as=first_seen)])

Microsoft KQL


union isfuzzy=true
  (CommonSecurityLog | where TimeGenerated > ago(14d) | extend dest = coalesce(RequestURL, DestinationHostName)),
  (DnsEvents | where TimeGenerated > ago(14d) | extend dest = Name),
  (Syslog | where TimeGenerated > ago(14d) | extend dest = SyslogMessage)
| where dest matches regex @"(?i)(deepveindrilling\.com|additionalver\.com|lanhd6549tdhse\.top|neswnsssn\.click|manulfie\.click|hdfdsi84j3h\.top|c85grt98sd\.com|leqdger\.click|crypto-insider\.top)"
| summarize count(), dests = make_set(dest, 20), first_seen = min(TimeGenerated) by Computer

Expired domains that later parked on abovedomains[.]com can still receive traffic, so a DNS hit alone is not a fresh breach.


Hunt 4: host files and odd children of the web server process. Needs EDR or auditd on the web host. Flag file activity under the upload directories, php-fpm children spawning shells or network tools, and GSocket-like processes. The GSocket check is our addition from Sansec's mention of a GSocket implant on one patched store. Map parent_process_name, process_name, file_path and CommandLine to your EDR or auditd fields. Many Linux sources record the file path and the process in separate events, so join them on host and time.


Splunk


index=* (sourcetype=linux:sysmon OR sourcetype=linux:audit OR sourcetype=sysmon:linux) earliest=-7d
| where match(file_path, "(?i)pub/media/(custom_options|customer_address)/") OR match(file_name, "(?i)^accesson\.php$")
    OR (match(parent_process_name, "(?i)(php-fpm|apache2|httpd|nginx)") AND match(process_name, "(?i)(bash|sh|curl|wget|nc|ncat|gsocket|gs-netcat)"))
| fillnull value="-" user parent_process_name process_name
| stats count, values(file_path) as paths, values(CommandLine) as cmds, min(_time) as first_seen by host, user, parent_process_name, process_name

Elastic


// EQL; process and file fields assumed
any where (
    (event.category == "file" and (
       file.path like "*pub/media/custom_options*" or file.path like "*pub/media/customer_address*" or file.name == "accesson.php"
    ))
    or (event.category == "process"
        and process.parent.name in ("php-fpm", "apache2", "httpd", "nginx")
        and process.name in ("bash", "sh", "curl", "wget", "nc", "ncat", "gsocket", "gs-netcat"))
  )

CrowdStrike


// Time picker: last 7 days
(
  (#event_simpleName=/^File/ TargetFileName=/pub\/media\/(custom_options|customer_address)|accesson\.php/i)
  or (
    #event_simpleName=ProcessRollup2
    ParentBaseFileName=/^(php-fpm|apache2|httpd|nginx)$/i
    FileName=/^(bash|sh|curl|wget|nc|ncat|gsocket|gs-netcat)$/i
  )
)
| groupBy([ComputerName, FileName, ParentBaseFileName], function=[count(as=count), collect(CommandLine), min(@timestamp, as=first_seen)])

Microsoft KQL


union isfuzzy=true
  (DeviceFileEvents
    | where Timestamp > ago(7d)
    | where FolderPath contains "pub/media/custom_options" or FolderPath contains "pub/media/customer_address" or FileName =~ "accesson.php"
    | extend signal = "file", parent = InitiatingProcessFileName, proc = FileName, cmd = InitiatingProcessCommandLine, t = Timestamp, host = DeviceName),
  (DeviceProcessEvents
    | where Timestamp > ago(7d)
    | where InitiatingProcessFileName in~ ("php-fpm", "apache2", "httpd", "nginx")
    | where FileName in~ ("bash", "sh", "curl", "wget", "nc", "ncat", "gsocket", "gs-netcat")
    | extend signal = "process", parent = InitiatingProcessFileName, proc = FileName, cmd = ProcessCommandLine, t = Timestamp, host = DeviceName)
| summarize count(), cmds = make_set(cmd, 20), first_seen = min(t) by host, signal, parent, proc

Deploy hooks can create media files during releases, so pair file creates with Hunt 1.


Only the Elastic EQL in Hunt 4 was parse-tested. The other queries were reviewed by hand and have not been run on live data, so run each one against a small time range first and fix field names before you trust an empty result.


Interpret before you escalate


Finding

What it likely means

Next step

cms_block content with fpCode or a Sansec domain

Loader present in the database

Export the block, clear it, rotate DB and admin creds

accesson.php or .php under custom_options

Likely webshell remains

Quarantine the file, review REST logs around its create time

REST cart POST and a media file request in the same 30 minutes

Our pairing; possible upload abuse

Confirm the file type and whether execution is allowed

CSP or DNS hit to a Sansec IOC domain

Browser still calling the loader path

Find the page or block that referenced it

php-fpm spawning curl or a shell

Possible post-exploit activity

Isolate the host, preserve process tree

New admin_user alone

Often a staff account

Verify the creator and MFA before you revoke


What to alert on


Hunt 1 on cms_block matches for fpCode or deepveindrilling deserves high priority review. Hunt 2 should page on accesson.php or executable uploads under custom_options. Sweep Hunts 3 and 4 on a schedule.


What This Means for Your Team


For an MSP, Magento patch status is a weak answer to whether customers are safe. A PolyShell Magento cleanup is not done when the version string looks current, because a store can still serve a CMS block loader, which can be missed when scanning files alone. Ask which stores still miss APSB26-73 and APSB25-88.


Sources



Inception Protection


Inception Protection is the Inception Security managed detection and response service, and it works with the Microsoft licenses you already have. We watch Defender endpoint and Entra sign-in data, where a compromised staff account or managed admin workstation often shows first, and work through containment with your team.


Inception Foresight


A Magento skimmer can start with a staff account or workstation foothold that shows up in Microsoft sign-in or endpoint data. To see whether your Microsoft environment would notice that kind of foothold, try the free Inception Foresight M365 Assessment: https://www.inceptionsecurity.com/m365assessment.

bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page