VexTrio’s Browser Fingerprinting

Gi7w0rm
40 min readMar 19, 2024

Hey everyone, welcome back to the first blog post in 2024. Today, we are going to have a look at something I came across while looking at Javascript inject-based malware: Browser Fingerprinting.

To do so, we are going to have a look at the fingerprinting stage of VexTrio, a malicious TDS (Traffic Distribution System) currently injected into webpages across the globe, that redirects visitors to an array of different fraudpages. Its functionality is probably best described like this:

VexTrio actors inject malicious JavaScript code into vulnerable WordPress websites, which then redirects visitors to potentially harmful content. The visitors go through a redirect chain that involves fraudulent domains whose purpose is to track victims and conditionally send them to landing webpages that serve riskware, spyware, adware, scams, pornographic images, or other unwanted programs. (Source: Infoblox)

To decide where to lead a user that opens a page infected with VexTrio, the malicious TDS uses several different methods to fingerprint the potential victim's browser. Depending on the JavaScript chain used, this can start by only allowing Users to be redirected if they meet certain conditions when visiting an infected site.

1. The user must visit the WordPress website from a search engine. For example, the referrer URL can be https://www.google.com/.

2. Cookies are enabled in the user’s web browser.

3. The user has not visited a VexTrio compromised web page in the past 24 hours.

(Source: Infoblox)

Now, this initial step of the infection chain already assures several things. First, by checking the referrer header, it is less likely that the VexTrio malware accidentally redirects a Website administrator or regular user of a website as both of those would likely not visit their website via a Search Engine but by directly opening the URL.

Second, by assuring that the User's Browser Cookies are enabled, the malware assures its functionality, as it makes use of several cookies, depending on its redirection chain. One of which is used to identify that no victim is redirected more than once in 24 hours. This leads us to the 3rd point, which likely serves the purpose of flying under the radar, as malicious redirecting behavior is not persistent but only happens once every 24 hours, making it less likely for users to complain to an administrator. It could also assure that a potential victim of more intrusive attacks, like VexTrio leading to Malware, does not get infected several times in a short timespan, reducing the noise of reoccurring infections on the attacker side.

However, the purpose of this blog post is not to shed light on the initial VexTrio injection page, but on the several methods used to redirect victims to the different kinds of fraud that can be delivered by the VexTrio TDS.

For doing so, the malware makes use of several fingerprinting methods being delivered in a second-stage JavaScript file, once a potential victim passes the above-described checks. These fingerprinting methods are used mainly to make sure that a potential victim is legitimate and not a researcher or web crawler. After each check is run, the malware returns a string value, which is then used by the VexTrio server to decide which redirect to serve the victim. Luckily for us, the actors behind VexTrio try to keep their code pretty clean. This allows us to have a look at all the checks at once due to a huge function that is only used to start each individual check and work with the result. Originally the function names were all generic, from A1 to A29

function chk() {
try {
if (CHECK_COMPARE_AVAILABLE_WINDOW_SIZE_TO_CURRENT_WINDOW_SIZE().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_COMPARE_ISO_CODE_LANGUAGE_ARRAY_VS_ISO_CODE_PRIMARY_LANGUAGE().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_COMPARE_OSCPU_TO_DEVICE_USERAGENT_STRING().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_OSCPU_NOT_UNDEFINED_AND_BROWSER_NOT_FIREFOX().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_COMPARE_PLATFORM_USERAGENT_DEVICEINUSERAGENT().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_PLUGINSUNDEFINED_BUT_USERAGENTOS_NOT_WINDOWS().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_BROWSER_BUILDNUMBER_AGAINST_BROWSERTYPE().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_LENGTH_OF_EVAL_FUNCTION_AGAINST_BROWSER_TYPE().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_Mozilla_UNIQUE_TOSOURCE_FKT_AGAINST_BROWSER_TYPE().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_Compare_DEVICEUSERAGENTSTRING_to_BrowserType_to_WEBGL_DEBUG_TOKENS().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_Webdriver_in_Navigator_Interface().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_Permission_DENIED_in_State_Prompt().split(":")[1] === "1") {
return "a0:" + 1;
} else if (DETECT_NAVIGATORPERMISSIONS_PROPERTIES_ODD().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_SPOOKYOSCHECK_likely_Devtools().split(":")[1] === "1") {
return "a0:" + 1;
} else if (DETECT_Phantom_in_Window().split(":")[1] === "1") {
return "a0:" + 1;
} else if (DETECT_BROWSERAUTOMATION_via_domElements().split(":")[1] === "1") {
return "a0:" + 1;
} else if (DETECT_PHANTOMAS().split(":")[1] === "1") {
return "a0:" + 1;
} else if (DETECT_Selenium_DOM_based().split(":")[1] === "1") {
return "a0:" + 1;
} else if (DETECT_NodeJS_Buffer().split(":")[1] === "1") {
return "a0:" + 1;
} else if (DETECT_Chromium_based_automation_driver().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_setTimeout_Integrity().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_setInterval_Integrity().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_XMLHTTPRequest_1().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_XMLHTTPRequest_2().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_UserAgent_not_automation().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_NumberofLogicalProcessors_IOS().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_Browser_VoiceList().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK__STACKTRACE_Behavior().split(":")[1] === "1") {
return "a0:" + 1;
} else if (CHECK_VirtualBox().split(":")[1] === "1") {
return "a0:" + 1;
} else {
return "a0:" + 0;
}
} catch (c) {
return "a0:e";
}
}

I will now try to walk you through these checks to explain what they do and how they work. This likely means to look at a lot of JavaScript and Browser Documentation. So buckle up for some craziness ahead. First of all, let's look at the most used helper functions:

function CHECK_DEVICE_IN_USERAGENT_STRING() {
var c = navigator.userAgent.toLowerCase();
var d;
if (c.indexOf("windows phone") >= 0) {
d = "Windows Phone";
} else if (c.indexOf("win") >= 0) {
d = "Windows";
} else if (c.indexOf("kaios") >= 0) {
d = "Kaios";
} else if (c.indexOf("android") >= 0 || c.indexOf("spreadtrum") >= 0) {
d = "Android";
} else if (c.indexOf("linux") >= 0 || c.indexOf("cros") >= 0) {
d = "Linux";
} else if (c.indexOf("iphone") >= 0 || c.indexOf("ipad") >= 0) {
d = "iOS";
} else if (c.indexOf("mac") >= 0) {
d = "Mac";
} else {
d = "Other";
}
return d;
}

and

function DETECT_Browser_Type() {
var c = navigator.userAgent;
var d;
if (c.indexOf("OPR/") !== -1 || c.indexOf("Opera") !== -1) {
d = "Opera";
} else if ((c.indexOf("MSIE") !== -1 || c.indexOf("Trident") !== -1) && c.indexOf("MAXTHON") === -1) {
d = "Internet Explorer";
} else if (c.indexOf("Edge") !== -1 || c.indexOf("EdgA") !== -1) {
d = "Edge";
} else if (c.indexOf("SamsungBrowser") !== -1) {
d = "Samsung Browser";
} else if (c.indexOf("UCBrowser") !== -1) {
d = "UC Browser";
} else if (c.indexOf("Android") !== -1 && c.indexOf("Chrome") === -1 && c.indexOf("Firefox") === -1) {
d = "Android Browser";
} else if (c.indexOf("Chrome") !== -1 || c.indexOf("CriOS") !== -1) {
d = "Chrome";
} else if (c.indexOf("Safari") !== -1 && c.indexOf("Chrome") === -1) {
d = "Safari";
} else if (c.indexOf("Firefox") !== -1) {
d = "Firefox";
} else {
d = "Other";
}
return d;
}

Both of these are pretty straightforward and work based on the potential victims' User-agent Header.

The User-Agent request header is a characteristic string that lets servers and network peers identify the application, operating system, vendor, and/or version of the requesting user agent. (Src: Mozilla.org)

So let's see an example UserAgent string to better understand what we can identify based on this header:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36

Let's split this into parts and try to understand everything individually:

  1. Mozilla/5.0: A historical string used at the start of most UserAgent strings that can be ignored.
  2. Windows NT 10.0: Windows NT is the name of the Windows Kernel. 10.0 is the Kernel Version used by the person above, who made the web request. Using online resources, we can easily identify that whoever is making this web request is using a Windows Operating system. More specifically, either a late version of Windows 10 or Windows 11. (Technically it could also be a Windows Server 2016, 2019 or 2021).
  3. Win64; x64: This gives us the Windows processor architecture used. From an attacker's perspective, this could be important as x64 malware binaries would not be able to run on an x32 operating system.
  4. AppleWebKit/537.36: This is the browser's rendering engine and version. The rendering engine is used to translate the source code of a website into a visual representation. In this case, it appears to be AppleWebKit. Now, historically this part of the UserAgent string used to be accurate, as for a long time AppleWebKit was used by all Apple and Chromium-based browsers. However, in 2013 Google started to create and use their own fork of AppleWebKit (called Blink). For some reason this was never really announced in the UserAgent of a Webbrowser so nowadays the above could be either Safari, Chrome, or other Chromium or WebKit-based browsers. The only thing we can already clearly exclude is Mozilla’s Firefox which uses Gecko and properly announces that.
  5. (KHTML like Gecko): Another historical piece of information. Technically, AppleWebKit is itself a fork of another browser rendering engine called KHTML. When Apple released WebKit around 2005, they wanted to make sure that the new rendering engine was accepted by all major web servers around. So they simply put KHTML like Gecko into the string to announce that their new rendering engine would behave the same way as Mozilla’s Gecko Engine.
  6. Chrome/121.0.0.0: This is the actual Browser using the above UserAgent Header. Google Chrome Version 121
  7. Safari/537.36: And this is yet another historical piece of information. When Google released Chrome they wanted to ensure that all Websites accept their requests as well. So they copied the Safari UserAgent of that time and added their own Chrome Version (6.) into it.

As you see, there is a lot of information to unpack from a UserAgent. And this is only one example. The below UserAgents are also perfectly fine:

  • Dalvik/2.1.0 (Linux; U; Android 9.0; ZTE BA520 Build/MRA58K)
  • Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1 Mobile/15E148 Safari/604.1
  • Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0

And as you can easily see, they vastly differ from what we saw before. However, all of them share 2 characteristics:

  1. A bracket with precise details about the used operating system
  2. The potential to precisely identify the used Browser by trying to find the exact part of the string that is not represented in other UserAgents.

But what's important to note here is that technically, the software that makes the web request can decide if and which UserAgent to set. Common tools like wget, curl or the python-request library all set individual UserAgents, if not told to do otherwise, which gives web servers an easy way to identify uncommon web requests. If you want to learn more about the history of UserAgents, I highly suggest reading this post.

Coming back to VexTrio, we can now understand what the first two presented fingerprinting functions do. The function “CHECK_DEVICE_IN_USERAGENT_STRING” is used to parse the UserAgent received from the victim and identify the Operating System. To do so, it converts the user-agent into all lowercase letters and then iterates over it to identify characteristic strings that uniquely identify the OS. If the OS is not identified, it sets the return value to “Other”. The function “DETECT_Browser_Type” is used to identify the victim's browser (Chrome, Firefox, Safari, etc.). If you watch closely you can see that it makes use of some of the previously discussed historical features of UserAgent strings. For example, a browser is only identified as being “Safari”, if there is no “Chrome” string in the header. As we just learned, thats the precise difference between a Safari and a Chrome header as Google just added the Chrome Version in a Safari UserAgent string. Again, if the Browser is not identified, it is set to “Other”.

As we will see during this article, VexTrio makes continuous use of both the above-described functions. But to keep things in order I will now walk through every check in order A1 to A29.

Check 1: CHECK_COMPARE_ISO_CODE_LANGUAGE_ARRAY_VS_ISO_CODE_PRIMARY_LANGUAGE

function CHECK_COMPARE_ISO_CODE_LANGUAGE_ARRAY_VS_ISO_CODE_PRIMARY_LANGUAGE() {
if (typeof navigator.languages !== "undefined") {
try {
var c = navigator.languages[0].substr(0, 2);
if (c !== navigator.language.substr(0, 2)) {
return "a1:" + 1;
}
} catch (d) {
return "a1:e";
}
}
return "a1:" + 0;
}

The first real check VexTrio does is to compare the value of the user's preferred language (navigator.language) with the first array of the browsers.languages array. More precisely it compares the first 2 letters of the ISO code representation of said elements. In Firefox, both values should always be the same. However, for other browsers, this is not necessarily true. In the case of the VexTrio code, if the ISO codes are not the same, the return value is “a1:1” (Check a1 = bad), if the values are the same or if the navigator.languages array is not defined it returns “a1:0” (Check a1 = good). I believe the VexTrio authors might not have thought this check through. While I get the initial intention of saying that a browser behaves weirdly if the UI/preferred language does not match the first entry in the languages array, this is not necessarily an indicator of something being odd. For example, Brave browser's anti-tracking feature intentionally sets these values to be different and browsers different from Mozilla Firefox are not obligated to have these values equal. At this time I am unaware of what VexTrio tries to accomplish with this check, but I am very open to suggestions.

Check 2: CHECK_COMPARE_AVAILABLE_WINDOW_SIZE_TO_CURRENT_WINDOW_SIZE

function CHECK_COMPARE_AVAILABLE_WINDOW_SIZE_TO_CURRENT_WINDOW_SIZE() {
try {
if (window.screen.width < window.screen.availWidth || window.screen.height < window.screen.availHeight) {
if (window.screen.width === window.screen.availHeight && window.screen.height === window.screen.availWidth || window.screen.width === window.screen.availHeight + 20 && window.screen.height === window.screen.availWidth) {
return "a2:" + 0;
} else {
return "a2:" + 1;
}
} else {
return "a2:" + 0;
}
} catch (c) {
return "a2:e";
}
}

In this second check, VexTrio compares the size of the current window representation against the size of the available screen by checking both the total height to the available height and the total width to the available width.

To understand this check, let's first understand the values that are compared. According to my research, the values window.screen.availWidth and window.screen.availHeight return the available width and height of the browser window that is available for rendering a webpage. Usually, this is the number of pixels of height/width of the screen minus the value of pixels used for static features of a web browser, like the taskbar, search bar, or other browser UI features.

In contrast, window.screen.height and window.screen.width show the maximum screen size as configured in the OS screen resolution settings. These values do not take into account any form of statically rendered browser UI features.

Logically, for a normal web browser, it should always be expected that the available Height and available width values are smaller than the height and width values, as every “normal” web browser renders UI features reducing the available width and height. The first part of the above check uses exactly this assumption to check for the browser's legitimacy. If the height and width ratio is bigger than the available height and width value, the check is passed and the function returns a2:0 (check 2 passed).

However, if the check is not passed, it can not be immediately said that the browser is not legitimate. A secondary check is done to check if the width value equals the availHeight value (or availHeight value +20 pixels) and the height value equals the availWidth value. I have yet to figure out why these values would be legitimate browser values as well. My initial assumption was that this could have to do with rotating screens where the availHeight might be the width and vice versa. However, an initial test on my Laptops rotating screen did not result in the described behavior as the values automatically adjusted for the rotated screen. Maybe this is different on some mobile devices? Feel free to comment below.

Check 3: CHECK_COMPARE_OSCPU_TO_DEVICE_USERAGENT_STRING

function CHECK_COMPARE_OSCPU_TO_DEVICE_USERAGENT_STRING() {
try {
var d = navigator.oscpu;
var f = CHECK_DEVICE_IN_USERAGENT_STRING();
if (typeof d !== "undefined") {
d = d.toLowerCase();
if (d === "" && f === "Kaios") {
return "a3:" + 0;
} else if (d.indexOf("win") >= 0 && f !== "Windows" && f !== "Windows Phone") {
return "a3:" + 1;
} else if (d.indexOf("linux") >= 0 && f !== "Linux" && f !== "Android") {
return "a3:" + 1;
} else if (d.indexOf("mac") >= 0 && f !== "Mac" && f !== "iOS") {
return "a3:" + 1;
} else if ((d.indexOf("win") === -1 && d.indexOf("linux") === -1 && d.indexOf("mac") === -1) !== (f === "Other")) {
return "a3:" + 1;
} else {
return "a3:" + 0;
}
} else {
return "a3:" + 0;
}
} catch (g) {
return "a3:e";
}
}

Check 3 is a rather simple comparison check. It makes use of the navigator.oscpu property. This deprecated property returns a string that identifies the OS used for running the browser that is currently visiting a webpage. The table below shows the expected value of the oscpu property together with the OS it's referring to.

Figure 1: oscpu values (Source: mozilla.org)

Now, using the navigator.oscpu property and the previously discussed method of retrieving the OS from the UserAgent header, VexTrio has 2 ways of receiving the OS. Check 3 now takes these two methods and compares their result under the condition that navigator.oscpu does not return “undefined”. If the OS returned by navigator.oscpu is the same as the UserAgent value, the check is passed (a3:0). If not, VexTrio assumes the visitor is not legitimate, as it suggests someone is fiddling either with the UserAgent or the Browser.

Check4: CHECK_OSCPU_NOT_UNDEFINED_AND_BROWSER_NOT_FIREFOX

function CHECK_OSCPU_NOT_UNDEFINED_AND_BROWSER_NOT_FIREFOX() {
try {
var c = navigator.oscpu;
var d = DETECT_Browser_Type();
if (typeof c !== "undefined" && d !== "Firefox") {
return "a4:" + 1;
} else {
return "a4:" + 0;
}
} catch (f) {
return "a4:e";
}
}

Now, there is one problem with the previously discussed check. The feature “navigator.oscpu” is largely discontinued and currently only still supported by Firefox and Firefox for Android. On all other browsers “navigator.oscpu” returns “undefined”. VexTrio makes use of this check by comparing the return value with the result of the UserAgent Browser Type check (discussed above). If the Browser identified is not Firefox, but the type of the result for navigator.oscpu is not undefined, VexTrio automatically assumes something is wrong. The check is therefore not passed (a4:1).

Check 5: CHECK_COMPARE_PLATFORM_USERAGENT_DEVICEINUSERAGENT

function CHECK_COMPARE_PLATFORM_USERAGENT_DEVICEINUSERAGENT() {
try {
var c = navigator.platform.toLowerCase();
var d = navigator.userAgent.toLowerCase();
var f = CHECK_DEVICE_IN_USERAGENT_STRING();
if (c === "" && f === "Kaios") {
return "a5:" + 0;
} else if (d.indexOf("maui") >= 0 && c.indexOf("pike") >= 0) {
return "a5:" + 0;
} else if (d.indexOf("j2me/midp") >= 0 && c.indexOf("pike") >= 0) {
return "a5:" + 0;
} else if (c === "arm" && f === "Windows Phone") {
return "a5:" + 0;
} else if (c.indexOf("win") >= 0 && f !== "Windows" && f !== "Windows Phone") {
return "a5:" + 1;
} else if ((c.indexOf("linux") >= 0 || c.indexOf("android") >= 0 || c.indexOf("pike") >= 0) && f !== "Linux" && f !== "Android" && f !== "Kaios") {
return "a5:" + 1;
} else if ((c.indexOf("mac") >= 0 || c.indexOf("ipad") >= 0 || c.indexOf("ipod") >= 0 || c.indexOf("iphone") >= 0) && f !== "Mac" && f !== "iOS") {
return "a5:" + 1;
} else if (c === "macintel" && d.indexOf("iphone") >= 0) {
return "a5:" + 1;
} else {
var g = c.indexOf("win") < 0 && c.indexOf("linux") < 0 && c.indexOf("mac") < 0 && c.indexOf("iphone") < 0 && c.indexOf("pike") < 0 && c.indexOf("ipod") < 0 && c.indexOf("ipad") < 0;
if (g !== (f === "Other")) {
return "a5:" + 1;
}
}
return "a5:" + 0;
} catch (h) {
return "a5:e";
}
}

Check 5 is again a comparison check. It takes the values of the native browser properties navigator.platform and navigator.useragent plus the value for the previously described function that returns the OS from the received UserAgent. The function then does several checks based on these straightforward values. For example, it checks if the OS returned by navigator.platform contains typical Apple-based platform indicators (Mac, iPad, iPod or iPhone) but there is no mention of a Mac or iOS operating system in the UserAgent, which would be odd as all 4 platforms would either be Mac or iOS based. Hence if true the check would not be passed (a5:1).

I am not going to explain every single check done in this function as they all follow the pattern of comparison-based checks to detect oddities between navigator.platform and UserAgent-based platform value.

Check 6: CHECK_PLUGINSUNDEFINED_BUT_USERAGENTOS_NOT_WINDOWS

function CHECK_PLUGINSUNDEFINED_BUT_USERAGENTOS_NOT_WINDOWS() {
try {
var c = CHECK_DEVICE_IN_USERAGENT_STRING();
if (typeof navigator.plugins === "undefined" && c !== "Windows" && c !== "Windows Phone") {
return "a6:" + 1;
} else {
return "a6:" + 0;
}
} catch (d) {
return "a6:e";
}
}

Check 6 is based on the navigator.plugins value. Now, the navigator.plugins property is pretty interesting. According to the Mozilla developer documentation, it has been deprecated in all browsers except for Safari. While on Safari this property returns an actual array of all installed browser plugins and their description, on all other browsers it seems to return a hardcoded list of PDF plugins.

Figure 2: Deprecation status of navigator.plugins (source: mozilla.org)

Now, despite the fact that all browsers that all common browsers seem to have some form of return value for the navigator.plugins property, either a real list of plugins or a list of hardcoded pdf values, check 6 assumes that under Windows and Microsoft Phone, the value could potentially be undefined. I am not aware as to why this should be the case. However, VexTrio checks if the return value of the property is undefined despite the fact that the OS returned by the UserAgent OS check function is not Windows or Windows Phone. In this case, the check fails and a6:1 gets returned, else the check is passed. My best guess is that there are browser automation tools that do not bother to define the navigator.plugins array and could therefore be detected as illegitimate browsers using this check.

Check 7: CHECK_BROWSER_BUILDNUMBER_AGAINST_BROWSERTYPE

function CHECK_BROWSER_BUILDNUMBER_AGAINST_BROWSERTYPE() {
try {
var c = navigator.productSub;
var d = DETECT_Browser_Type();
if ((d === "Chrome" || d === "Safari") && c !== "20030107") {
return "a7:" + 1;
} else if (d === "Opera" && c !== "20030107" && typeof c !== "undefined") {
return "a7:" + 1;
} else {
return "a7:" + 0;
}
} catch (f) {
return "a7:e";
}
}

Check 7 is a specific check for Chrome, Safari, and Opera Browsers. Again, a quick look at the Mozilla Developers documentation makes this check pretty easy to understand.

The Navigator.productSub read-only property returns the build number of the current browser.

On IE, this property returns undefined.

On Apple Safari and Google Chrome this property always returns 20030107. (additonally also on Opera after release v. 15)

Check 7 of the analyzed VexTrio sample makes use of the fact that Safari, Chrome and Opera return a fixed version number by assuring that if the identified Browser is Chrome, Safari or Opera, the returned property value is indeed the expected version “20030107". If not, some sort of manipulation is assumed and the check fails with a7:1.

Check 8: CHECK_LENGTH_OF_EVAL_FUNCTION_AGAINST_BROWSER_TYPE

function CHECK_LENGTH_OF_EVAL_FUNCTION_AGAINST_BROWSER_TYPE() {
try {
var c = DETECT_Browser_Type();
var d = CHECK_DEVICE_IN_USERAGENT_STRING();
var f = eval.toString().length;
if (f === 37 && c !== "Safari" && c !== "Firefox" && c !== "Other" && d === "iOS" && c !== "Chrome") {
return "a8:" + 1;
} else if (f === 39 && c !== "Internet Explorer" && c !== "Other") {
return "a8:" + 1;
} else if (f === 33 && c !== "Chrome" && c !== "Opera" && c !== "Edge" && c !== "UC Browser" && c !== "Samsung Browser" && c !== "Other" && c !== "Android Browser") {
return "a8:" + 1;
} else {
return "a8:" + 0;
}
} catch (g) {
return "a8:e";
}
}

Check 8 is quite an interesting one. It relies on the fact that the eval function used in different browsers, when turned into a string, seems to have different lengths. Apparently, there are 3 different lengths for the eval function which can be associated with known browsers. By making use of the previously discussed UserAgent functions and the length of the eval function string, the check assures that if one of the known lengths for the eval function is identified, the browser and OS are the ones that can be directly related to said length. E.g. if the length of the eval function string is 39, the Browser can only be either Microsoft Explorer or “Other”. If the length is 39 but the browser is not one of the 2, then VexTrio assumes it is not looking at a legitimate visitor.

Check 9: CHECK_Mozilla_UNIQUE_TOSOURCE_FKT_AGAINST_BROWSER_TYPE

function CHECK_Mozilla_UNIQUE_TOSOURCE_FKT_AGAINST_BROWSER_TYPE() {
try {
var c = DETECT_Browser_Type();
var d;
try {
throw "a";
} catch (f) {
try {
f.toSource();
d = true;
} catch (g) {
d = false;
}
}
if (d && c !== "Firefox" && c !== "Other") {
return "a9:" + 1;
} else {
return "a9:" + 0;
}
} catch (h) {
return "a9:e";
}
}

Test a9 (which I named “CHECK_Mozilla_UNIQUE_TOSOURCE_FKT_AGAINST_BROWSER_TYPE” for our convenience ^^) is a check that makes use of an obsolete browser function called toSource(). Before its deprecation, this function could be used to convert a function passed as an argument into its source code representation. However, this function is not used by any modern browser anymore. The try/catch statement works in a way that if the function is present, d is set to true, else to false. If d is true but the browser is not Firefox or “Other”, VexTrio assumes something is wrong and the test is failed (a9:1). I am not sure why the Firefox exception is made here. Maybe the toSource() function was supported by Firefox up to very recent versions and so sorting them out too would make a lot of potential users/victims using older Firefox Versions be sorted out.

Anyways, this check is passed for all Browsers that do not implement the function, so all normal browsers should pass the check with a9:0.

Intermezzo: Two additional helper functions

Before we continue with the analysis of VexTrio’s Browser fingerprinting Checks, we need to have a look at two additional helper functions used during the next fingerprinting steps.

function HELPER_WEBGL_DEBUG_TOKENS_FOR_RENDERER_AND_VENDOR() {
try {
var c = document.createElement("canvas");
var d = c.getContext("webgl");
var e = d.getExtension("WEBGL_debug_renderer_info");
var f = d.getParameter(e.UNMASKED_VENDOR_WEBGL);
var g = d.getParameter(e.UNMASKED_RENDERER_WEBGL);
return [f, g];
} catch (h) {
return false;
}
}

First up we are looking at a very interesting little trick. It makes use of debug functionalities to receive information on a victim. So let’s understand what we are looking at.

The first thing the above function does is to create a new DOM element of type “canvas”. A Canvas element in this context is an HTML element that can be manipulated by either the “canvas scripting API” or the “WebGL API” to create graphics and animations on a webpage.

Now, after creating that element, if we want to start manipulating the canvas from the JavaScript context, we need to access its context. This can be done by the getContext function. The function used by VexTrio does set the drawing context of the newly created canvas element to “WebGL”.

Now, the reason for this lies in a certain extension of the WebGL drawing context, called “WEBGL_debug_renderer_info”. This extension of the WebGL API is used for debugging canvas drawings by giving a developer two constant strings representing the current graphics card used to render the website. The constant strings are called UNMASKED_VENDOR_WEBGL and UNMASKED_RENDERER_WEBGL and can be accessed via the getParameter() function of the extension.
In other words, by making use of this method, the above function (and thereby VexTrio) is able to identify the graphics card of the potential victim. We will see the usefulness of this information later on.

var voiceslist = "";
function populateVoiceList() {
try {
var c = speechSynthesis.getVoices();
if (c.length !== 0) {
var d = "";
for (var f = 0; f < c.length; f++) {
d = d + " " + c[f].name;
}
voiceslist = d;
} else {
setTimeout(function () {
populateVoiceList();
}, 5);
}
} catch (g) {}
}
populateVoiceList();

The second new helper function is all about speechSynthesis features. The function tries to access the speechSynthesis interface of the web browser to get a list of all available voices, iterates over it, and concatenates all names of all available voices into one string. If the list of voices can not be retrieved immediately, it retries the process after a 5-second timeout, most likely to account for potential loading delays in different web browsers. If any error occurs the function exits silently. Note that the function is directly executed on page load. The voicelist string will be used in a later check.

Check 10: CHECK_Compare_DEVICEUSERAGENTSTRING_to_BrowserType_to_WEBGL_DEBUG_TOKENS

function CHECK_Compare_DEVICEUSERAGENTSTRING_to_BrowserType_to_WEBGL_DEBUG_TOKENS() {
try {
var c = CHECK_DEVICE_IN_USERAGENT_STRING();
var d = DETECT_Browser_Type();
var f = HELPER_WEBGL_DEBUG_TOKENS_FOR_RENDERER_AND_VENDOR();
if (!f) {
return "a10:" + 0;
} else if (c === "iOS" && f[0].indexOf("Apple") === -1 && f[0].indexOf("Imagination Technologies") === -1) {
return "a10:" + 1;
} else if (c === "Mac" && f[0].indexOf("Intel") === -1 && f[0].indexOf("ATI Technologies") === -1 && f[0].indexOf("NVIDIA Corporation") === -1 && f[0].indexOf("Apple") === -1) {
return "a10:" + 1;
} else if (c === "Android" && (f[0] === "Google Inc. (NVIDIA)" || f[0] === "Google Inc. (Intel)" || f[0] === "Google Inc. (Google)" || f[0] === "Google Inc." || f[0].indexOf("NVIDIA Corporation") !== -1)) {
return "a10:" + 1;
} else if (c === "Windows" && d === "Edge" && f[0].indexOf("Microsoft") === -1) {
return "a10:" + 1;
} else if (c === "Windows" && (d === "Chrome" || d === "Firefox") && f[0].indexOf("Google Inc") === -1) {
return "a10:" + 1;
} else if (f[0].indexOf("VMware") !== -1) {
return "a10:" + 1;
} else {
return "a10:" + 0;
}
} catch (g) {
return "a10:e";
}
}

Check 10 is the first to make use of the newly introduced helper function that receives graphiccard details via a WebGL debug property. Certain operating systems are usually bound to certain graphics card vendors. So by using the initially discussed functions to identify the OS and Browser from the User-agent string, VexTrio compares the identified operating system to the graphic card vendor. As an example, if the identified OS is Android, the check assures that the graphics card vendor (f[0]) is either “Google Inc. (NVIDIA)”, “Google Inc. (Intel)”, “Google Inc. (Google)”, “Google Inc.” or contains the string “NVIDIA Corporation”. If none of these assumed values is given, the check assumes something is wrong and returns a10:1. Similar checks are done for iOS, Mac and Windows and their respective assumed graphics card vendor values.

Additionally, before the check is passed, a check for the presence of the string “VMware” in the graphics card vendor value is done, to ensure that the visiting browser is not running inside a VMWare virtual machine. This check is most likely done to evade the prying eyes of malware analysts trying to analyze the VexTrio malware in a virtual environment.

Check 11: CHECK_Webdriver_in_Navigator_Interface

function CHECK_Webdriver_in_Navigator_Interface() {
try {
var c;
browser = DETECT_Browser_Type();
os = CHECK_DEVICE_IN_USERAGENT_STRING();
c = "webdriver" in navigator && navigator.webdriver;
if (c) {
return "a11:" + 1;
} else {
return "a11:" + 0;
}
} catch (d) {
return "a11:e";
}
}

The next check in VexTrio’s arsenal is rather straightforward. It is probably a web equivalent for the “IsDebuggerPresent” Windows API call commonly used by malicious software to simply ask the system it executes on for the presence of a debugger. To understand the function, let’s first have a look at the Mozilla developer documentation of the navigator.webdriver property.

Figure 3: Navigator.webdriver property docu (Source: mozilla.org)

As you can see, the property is a simple boolean value that returns true if the browser is started in conjunction with different flags commonly used in automation (or debugging) scenarios. VexTrio simply asks the browser: “Hey are you running automated?” and if true, the visitor is not legitimate and fails the check (a11:1).

Intermezzo 2: getPermissionStatus function

The next two checks rely on the set of browser permissions given by the browser to a visited webpage. As you probably know, there are websites with a certain set of features that require additional permissions, like accessing the device's microphone, webcam or sending notifications as Pop-Up messages. Most modern browsers have a default behavior on how to handle such permission requests by a webpage. Here is an example of the default permissions given to the website “medium.com” by Google Chrome.

Figure 4: Partial set of browser permissions given to medium.com by Google Chrome

As you can see, most permissions are set to “Ask” by default, meaning if the website requests permission, the browser will ask the user via a Pop-Up, if the webpage should be allowed to get the required permission or not. And the next thing VexTrio does is to work with those permissions.

var permissions = false;
getPermissionStatus();
function getPermissionStatus() {
try {
browser = DETECT_Browser_Type();
if (browser !== "Samsung Browser" && browser !== "Firefox") {
navigator.permissions.query({
name: "notifications"
}).then(function (c) {
if (Notification.permission === "denied" && c.state === "prompt") {
permissions = true;
} else {
permissions = false;
}
});
return permissions;
}
} catch (c) {}
}

More specifically, if VexTrio identifies the Browser as not being Firefox and not being Samsung Browser, it queries the “notifications” permission using the “navigator.permissions.query()” method. Now, instead of returning the result straight away, the query() method returns a “promise” object. A promise object is an object that allows the use of asynchronous operations in JavaScript. Basically, you define an object before knowing that the object will have a result and what the result will be. Then you can make use of “.then” on this promise to define actions that will be taken once the asynchronous operation has finished, depending on the state it has finished in. The possible result states to handle are “fulfilled” or “rejected”. However, VexTrio does only define an action for a successful query operation (fulfilled). In case of a fulfilled query, the result will be stored in the “c” variable. In the case of this function, “c” is going to be a PermissionStatus object, which has the following properties:

Figure 5: Properties of the PermissionStatus object (Source: mozzilla.org)

This object will then be used together with the return value of “Notifications.permission” to assign a boolean value to the “permissions” variable. The following if-statement is responsible for this assignment:

if (Notification.permission === "denied" && c.state === "prompt") {
permissions = true;
} else {
permissions = false;

Now, I am still not 100% certain but I believe that the condition which sets the permissions variable to true is only ever met if something is odd about the relation between the permission property of the Notification object and the state of the result of the query for the PermissionStatus object. Under normal circumstances one of the following should be the case:

  • Notification.permission === “default” && c.state === “prompt”
  • Notification.permission === “denied” && c.state === “denied”
  • Notification.permission === “granted” && c.state === “granted”

There seems to be no legitimate way where a Chromium-based browser has the “denied” && “prompt” combination set.

Check 12: CHECK_Permission_DENIED_in_State_Prompt

function CHECK_Permission_DENIED_in_State_Prompt() {
try {
if (permissions) {
return "a12:" + 1;
} else {
return "a12:" + 0;
}
} catch (c) {
return "a12:e";
}
}

Consequently, the next Check done by VexTrio fails exactly when “permissions” was set to true.

Check 13: DETECT_NAVIGATORPERMISSIONS_PROPERTIES_ODD

function DETECT_NAVIGATORPERMISSIONS_PROPERTIES_ODD() {
try {
var c = window.navigator.permissions;
if (c.query.toString().replace(/\s+/g, "") !== "function query() { [native code] }".replace(/\s+/g, "")) {
return "a13:" + 1;
}
if (c.query.toString.toString().replace(/\s+/g, "") !== "function toString() { [native code] }".replace(/\s+/g, "")) {
return "a13:" + 1;
}
if (c.query.toString.hasOwnProperty("[[Handler]]") && c.query.toString.hasOwnProperty("[[Target]]") && c.query.toString.hasOwnProperty("[[IsRevoked]]")) {
return "a13:" + 1;
}
if (c.hasOwnProperty("query")) {
return "a13:" + 1;
}
return "a13:" + 0;
} catch (d) {
return "a13:e";
}
}

The next check again involves browser permissions, however, in this case VexTrio is more interested in the code implementation behind the involved functions. First it gets the permissions object of the current window. Then it turns the previously discussed “query” method into a string representation using the .toString() method. This method should return a string representation of the object. Interestingly enough, it rather returns the following string: “function toString() { [native code] }”. I replicated this behavior from the web developer console:

Figure 6: native code result

This check is likely done to check that the query function has not been manipulated. To cite the official documentation: “If the toString() method is called on built-in function objects, a function created by Function.prototype.bind(), or other non-JavaScript functions, then toString() returns a native function string”.

The same is done with the “toString()” function itself. Under normal browser conditions the same “toString(){ [native code] }” result is expected. If any of the 2 toString() methods do not have the expected result, the check is failed (a13:1).

Additionally, a second method is used to check for the ownership of certain properties. To cite the documentation:

The hasOwnProperty() method returns true if the specified property is a direct property of the object — even if the value is null or undefined. The method returns false if the property is inherited, or has not been declared at all.

The fingerprinting check done now is to assure that the properties “[[Handler]]”, “[[Target]]” and “[[IsRevoked]]” of the .toString() value of the query method are not owned by the object itself. The same is checked for the query() function itself in its relation to the permissions object.

I must admit that the reasoning for this check is not completely clear to me as it involves deep knowledge of how the methods converted into strings in a browser javascript context behave. However, it is safe to assume that this is done to assure that all the before-mentioned properties are as expected and nothing indicates that some sort of browser automation or browser virtualization has intervened with the native properties and the browser is a “legitimate visitor/victim” for VexTrio. Failing these checks would lead to the expected return value “a13:1" indicating that the visiting browser is not to be trusted by VexTrio.

Check 14: CHECK_SPOOKYOSCHECK_likely_Devtools

function CHECK_SPOOKYOSCHECK_likely_Devtools() {
try {
os = CHECK_DEVICE_IN_USERAGENT_STRING();
browser = DETECT_Browser_Type();
if (browser === "Chrome" && os !== "iOS") {
var c = 0;
var d = /./;
d.toString = function () {
c++;
return "spooky";
};
console.debug(d);
if (c > 1) {
return "a14:" + 1;
} else {
return "a14:" + 0;
}
} else {
return "a14:" + 0;
}
} catch (f) {
return "a14:e";
}
}

The “Spooky Check” is probably one of those that caused me the most headaches when analyzing VexTrio’s fingerprinting methods. And until now, I can only really explain it by making an assumption.

The check targets the Google Chrome browser under the condition that it is not installed on an iOS device. In case this is true, the check creates a regular expression object with the value “/./”. The toString method of this object is overridden to increment a counter c each time the toString method is called and then returns a string “spooky”.

The object d is then logged to the console using console.debug(d). The trick here is that the console.debug() function makes use of the overwritten toString() function to print the object to the console. By doing so, the counter c is increased by 1. However, if the counter is increased by more than 1, VexTrio assumes foul play and the check is failed. Sadly I don’t have an explanation under which circumstances the counter should be increased by more than one. I assume that this could happen in some sort of virtual/coding environment, or if someone hooks the debug function for some additional functionality. I would appreciate it if someone with more JavaScript and browser automation knowledge would step up to explain it. I will make sure to add the missing information to this blog post if available.

If the toString function is not called more than once, or if the visitor is not using Chrome or is using Chrome under iOS, the test is passed.

Check 15: DETECT_Phantom_in_Window

function DETECT_Phantom_in_Window() {
try {
function c() {
return ["callPhantom" in window, "_phantom" in window, "phantom" in window];
}
result = c().some(function (d) {
return d;
});
if (result) {
return "a15:" + 1;
} else {
return "a15:" + 0;
}
} catch (d) {
return "a15:e";
}
}

Luckily we continue with a way simpler check, which is to make sure that the VexTrio code is not executed in a phantomjs context. PhantomJS is a headless web browser scriptable with JavaScript. To make sure VexTrio is not executed in the context of phantomjs, the code checks if the own DOM contains any of the following properties:

  • callPhantom: A method that used to be provided by PhantomJS for invoking native code from the script.
  • _phantom: An older, now deprecated property that was once used by PhantomJS.
  • phantom: Represents the PhantomJS scripting API.

If any of these is present, the boolean value “true” will be logged to the returned array. Else the value “false” is logged. Then the .some() function is used to execute a function in case any of the returned values is true. In this case, the check fails as VexTrio registered obvious signs of emulation using phantomjs.
Else the check is passed (a15:0)

Check 16: DETECT_BROWSERAUTOMATION_via_domElements

function DETECT_BROWSERAUTOMATION_via_domElements() {
try {
var c = ["__webdriver_evaluate", "__selenium_evaluate", "__webdriver_script_function", "__webdriver_script_func", "__webdriver_script_fn", "__fxdriver_evaluate", "__driver_unwrapped", "__webdriver_unwrapped", "__driver_evaluate", "__selenium_unwrapped", "__fxdriver_unwrapped"];
var d = ["webdriver", "_phantom", "__nightmare", "_selenium", "callPhantom", "callSelenium", "_Selenium_IDE_Recorder", "__stopAllTimers"];
for (var f in d) {
var g = d[f];
if (window[g]) {
return "a16:" + 1;
}
}
;
for (var h in c) {
var i = c[h];
if (window.document[i]) {
return "a16:" + 1;
}
}
;
try {
if (window.external && window.external.toString() && window.external.toString().indexOf("Sequentum") != -1) {
return "a16:" + 1;
}
if (window.document.documentElement.getAttribute("selenium")) {
return "a16:" + 1;
}
if (window.document.documentElement.getAttribute("webdriver")) {
return "a16:" + 1;
}
if (window.document.documentElement.getAttribute("driver")) {
return "a16:" + 1;
}
} catch (j) {
"a16:" + 0;
}
return "a16:" + 0;
} catch (k) {
return "a16:e";
}
}

The next Fingerprinting function checks for the existence of all kinds of window and dom properties which could indicate the use of browser automation. This includes checks for Selenium, Webdriver, PhantomJS andNightmare. To do so, the first step is to check if any of the values from the array:

["webdriver", "_phantom", "__nightmare", "_selenium", "callPhantom", "callSelenium", "_Selenium_IDE_Recorder", "__stopAllTimers"];

are present as window properties. If so, the check has failed because all these are clear signs for automation. One could ask themselves why Check 15 is done after all since all but one value for PhantomJS detection is also checked in Check 16. But I guess someone just copy-pasted code here without really thinking about it.

Then, the check continues with another array of “bad strings”:

["__webdriver_evaluate", "__selenium_evaluate", "__webdriver_script_function", "__webdriver_script_func", "__webdriver_script_fn", "__fxdriver_evaluate", "__driver_unwrapped", "__webdriver_unwrapped", "__driver_evaluate", "__selenium_unwrapped", "__fxdriver_unwrapped"];

this time making sure that none of these appear as a property of the DOM copy saved in the windows.document object. Again, all values are associated with the previously mentioned automation tools. If any exists, the check fails and a16:1 is returned.

After doing these rather simple string-based checks, 4 more checks are done.
The first one checks for the existence of a property of window.external which when turned into a string contains the word: “Sequentum”. This is likely an attempt to block automation solutions and web scraping by https://www.sequentum.com/.

Then we three see checks for the values “selenium”, “webdriver” and “driver” respectively. For each value, the fingerprinting check assures that it is not given as an attribute of the web page's root element (normally the first <html> tag). An example that would get detected is:

<!DOCTYPE html>
<html lang="en" driver="true">
<head>
<title>Sample Page with Driver Attribute</title>
</head>
<body>
<header>
<h1>Welcome to My Page</h1>
</header>
<main>
<p>This is a simple HTML page to demonstrate the "driver" attribute on the root element.</p>
</main>
</body>
</html>

Again, if any of the attributes is present the check is failed (a16:1).
If none of the previously described checks fail, the check is passed.

All parts of this check are well known. Some mentions:

Check 17: DETECT_PHANTOMAS

function DETECT_PHANTOMAS() {
try {
function c() {
return ["__phantomas" in window];
}
result = c().some(function (d) {
return d;
});
if (result) {
return "a17:" + 1;
} else {
return "a17:" + 0;
}
} catch (d) {
return "a17:e";
}
}

Well, 3rd time is a charm. The same method as Check15 and Check16 is used to check for the existence of the string “__phantomas” as a window property. If yes the check is failed, if no it is passed.
This is a detection method of the phantomas tool.

Check 18: DETECT_Selenium_DOM_based

function DETECT_Selenium_DOM_based() {
try {
for (var c in window.document) {
if (c.match(/\$[a-z]dc_/) && window.document[c].cache_) {
return "a18:" + 1;
}
}
return "a18:" + 0;
} catch (d) {
return "a18:e";
}
}

This Check was a bit more difficult to understand, however this mainly results from the regular expression being used and my lack of knowledge of what it could be searching for. The overall functionality however is quite simple. VexTrio uses a regular expression to search over all properties of the window.document. If the name of the property matches the regex and additionally, the property has a subproperty .cache_ then the check is failed. (This second part of the check hints that the searched property is assumed to be an object itself.)

Now the difficulty is to understand what the RegEx is actually looking for.
The RegEx matches on strings that start with a “$” sign, followed by any lowercase letter a-z, followed by “dc_”.

Well, I haven’t encountered a real mention of this regular expression in the context of browser fingerprinting. I am sure somewhere out there is the answer to this secret, but Google is not the best search engine to search regular expressions with.
Anyhow, I asked some people with more knowledge of JavaScript and they suggested it is most likely another method to detect the Selenium browser automation toolkit.

Check 19: DETECT_NodeJS_Buffer

function DETECT_NodeJS_Buffer() {
try {
if (window.Buffer !== undefined) {
return "a19:" + 1;
} else {
return "a19:" + 0;
}
} catch (c) {
return "a19:e";
}
}

Check 19 is straightforward. If the window.Buffer property is defined, the check is failed and the visitor is not legitimate in the eyes of VexTrio.

Googling for the window.Buffer in correlation to JavaScript returns many results hinting at the NodeJS framework. I, therefore, believe that this Check aims to detect the NodeJS framework and visitors using NodeJS to open the VexTrio landing page, hinting at researchers, browser automation or webscraping frameworks making use of NodeJS.

Check 20: DETECT_Chromium_based_automation_driver

function DETECT_Chromium_based_automation_driver() {
try {
if (window.domAutomation || window.domAutomationController) {
return "a20:" + 1;
} else {
return "a20:" + 0;
}
} catch (c) {
return "a20:e";
}
}

Check 20 detects the presence of a webdriver by checking for the existence of the window.domAutomation or the window.domAutomationController properties. If any of these two is present the browser is likely automated and the check is failed, else it is passed (a20:0).

Check 21: CHECK_setTimeout_Integrity && Check 22: CHECK_setInterval_Integrity

function CHECK_setTimeout_Integrity() {
try {
if (setTimeout.toString().replace(/\s+/g, "") !== "function setTimeout() { [native code] }".replace(/\s/g, "")) {
return "a21:" + 1;
} else {
return "a21:" + 0;
}
} catch (c) {
return "a21:e";
}
}

function CHECK_setInterval_Integrity() {
try {
if (setInterval.toString().replace(/\s+/g, "") !== "function setInterval() { [native code] }".replace(/\s/g, "")) {
return "a22:" + 1;
} else {
return "a22:" + 0;
}
} catch (c) {
return "a22:e";
}
}

Another check is based on the toString() method as described in Check13. This time it is used to check that the setTimeout function is not manipulated. This would likely detect any virtualization solution or automation framework that fiddles with the setTimout() function.

The same is also done with the setInterval function in Check 22.

If any of the functions does not return the expected { native code } string, the check is failed.

Check 23: CHECK_XMLHTTPRequest_1 and Check 24: CHECK_XMLHTTPRequest_2

function CHECK_XMLHTTPRequest_1() {
try {
var c = "kl";
var f = "IsCO";
var g = "RSRequest";
var h = c + f + g;
if (window.XMLHttpRequest.prototype.open.toString().indexOf(h) !== -1) {
return "a42:" + 1;
} else {
return "a42:" + 0;
}
} catch (i) {
return "a42:e";
}
}
function CHECK_XMLHTTPRequest_2() {
try {
var c = "klI";
var d = "sCOR";
var f = "SRequest";
var g = c + d + f;
if (window.XMLHttpRequest.prototype.send.toString().indexOf(g) !== -1) {
return "a43:" + 1;
} else {
return "a43:" + 0;
}
} catch (h) {
return "a43:e";
}
}

These next two checks involve the handling of XMLHttpRequests. More specifically, they ensure that the XMLHttpRequests’ open() and send() methods do not contain the value “klISCORSRequest” when turned into a string. Now, while I can tell you that XMLHttpRequests are a way of loading WebContent dynamically on an already loaded page. I can also tell you that the send() method is used to send a request to a server, while the open() method is used to initialize or reopen a request. I can tell you that CORS is short for Cross-Origin Resource Sharing and has something to do with how and if servers are allowed to access content from external HTTP requests. What I sadly can’t tell you is where the “klISCORSRequest” property is used. Googling the term leads to zero results. The official documentation of the CORS protocol and XMLHttpRequests have no reference to it as well. The only sure thing is that if “klISCORSRequest” is encountered in the properties of the XMLHttpRequest send() and open() methods, then VexTrio does not like it. The corresponding check is failed and a42:1/a43:1 is returned.

Sidenote: As you probably noticed, VexTrio suddenly stops to count the checks in numerical order at this point. Instead of continuing with checks 23 and 24, VexTrio uses checks 42 and 43. For me this is an indication that those fingerprinting checks are most likely copy-pasted from an external source. We will get back to this in the Conclusion section of this article.

Check 25: CHECK_UserAgent_not_automation

function CHECK_UserAgent_not_automation() {
try {
var c = "phantomjs";
var d = "headless";
var f = "avira";
var g = "googleweblight";
var h = navigator.userAgent.toLowerCase();
if (h.indexOf(c) !== -1 || h.indexOf(d) !== -1 || h.indexOf(f) !== -1 || h.indexOf(g) !== -1) {
return "a60:" + 1;
} else {
return "a60:" + 0;
}
} catch (i) {
return "a60:e";
}
}

Another of those checks that could have easily been integrated into previous checks working with the victim’s web-requests UserAgent.
This time, VexTrio makes sure that none of the strings “phantomjs”, “headless”, “avira” and “googleweblight” are found in it.

Hereby

  • phantomjs: Would indicate browser automation using PhantomJS
  • headless: Would indicate a browser being run in headless mode (so without any User UI, which is a common case in browser automation
  • avira: Would indicate a webrequest by the known security vendor Avira. My working theory is that a browser using Avira’s security plugins might be sending such requests.
  • googleweblight: A check for the GoogleWebLight proxy service (Discontinued in 2022). This check is likely completely useless.

If any of those strings are encountered in the UserAgent, check 25 (60) is failed, returning a60:1, if the check is passed, the function returns a60:0.

Check 26: CHECK_STACKTRACE_Behavior

function CHECK_STACKTRACE_Behavior() {
var c;
var d;
try {
document.createElement(0);
} catch (e) {
try {
d = e.stack.split("\n");
c = d.length >= 2 ? !!d[1].match(/Ob[cej]{3}t\.a[lp]{3}y[\(< ]{3}an[oynm]{5}us>/) : true;
} catch (f) {}
if (c) {
return "a78:" + 1;
} else {
return "a78:" + 0;
}
}
}

This Check is pretty cool from a technical standpoint. It is a way to detect Puppeter with the extra-stealth plugin, more or less directly from a Github Issue:
https://github.com/berstend/puppeteer-extra/issues/318#issuecomment-699700974
which has since been fixed.
Basically, the check works as follows:

  1. An intentionally erroneous document.createElement() call is made.
    By passing an Integer as the parameter where createElement expects a string, the function runs into an error.
  2. The first error routine is used to catch the error and evaluate the Stack trace which normally comes included when a browser runs into a JavaScript error. The stacktrace is split into lines. Then, if there are more than two lines, the second line is checked for a match on the RegEx “/Ob[cej]{3}t\.a[lp]{3}y[\(< ]{3}an[oynm]{5}us>/”
    This does exactly match on errors returned where the Stack has been manipulated by the above-mentioned Plugin.
  3. If the RegEx does match under the condition that more than 2 lines of Stacktrace are returned, the fingerprint did result in the discovery of an automation and the test is failed (a78:1), else it is passed.

I believe it’s one of the most interesting checks in VexTrio’s arsenal. Using Stacktraces to identify browser automation by intentionally provoking an error is something to keep in mind.

Check 27: CHECK_NumberofLogicalProcessors_IOS

function CHECK_NumberofLogicalProcessors_IOS() {
try {
if (CHECK_DEVICE_IN_USERAGENT_STRING() === "iOS" && window.navigator.hardwareConcurrency !== undefined && window.navigator.hardwareConcurrency > 4) {
return "a86:" + 1;
} else {
return "a86:" + 0;
}
} catch (c) {
return "a86:e";
}
}

Let’s get back to some more simple checks. Check 27 is a fingerprint-based on the number of logical processors. If the device is a device making use of the iOS operating system and the window.navigator.hardwareConcurrency property is defined, it is not allowed to return a value bigger than 4. According to my research, ever since the A11 Apple processor, likely all iPhones from the iPhone 8 onwards have 6 and more logical processors. Oddly enough the iPhone 8 was released in the same year in which Safari dropped support for the hardwareConcurrency() method. So while I initially hoped that this check would prevent some iOS users from passing as legitimate, I assume that since the iPhone 8 attempting to print a Safari User this way would most likely result in them still getting passed on as victims, as the hardwareConcurrency() method likely returns undefined.
At least if you are using Chrome on iOS with an iPhone 8 onwards you are most likely falling through and getting sorted out for the more malicious payloads.

Anyways, if you pass the check and look like innocent prey, the function returns a86:0, else it’s a86:1.

Check 28: CHECK_Browser_VoiceList

function CHECK_Browser_VoiceList() {
var c = CHECK_DEVICE_IN_USERAGENT_STRING();
if (voiceslist.toLowerCase().indexOf("lekha") !== -1 && (c.indexOf("Win") !== -1 || c === "Kaios" || c === "Android" || c === "Linux")) {
return "a89:" + 1;
} else {
return "a89:" + 0;
}
}

Remember that voicelist variable VexTrio filled some checks ago? Well, here it will be used again. In its essence, there seems to be a voicepack for browsers which is called “lekha”. It is only used under Apple devices and if the user agent says you are a Windows, Kaios, Linux or Android user, encountering it in the voicelist means something is wrong. Hence if this voicepack is available and you are on any of the aforementioned non-apple devices, you fail the check (a89:1). Else you pass (a89:0).

Check 29: CHECK_VirtualBox

function CHECK_VirtualBox() {
try {
var c = HELPER_WEBGL_DEBUG_TOKENS_FOR_RENDERER_AND_VENDOR();
if (c[1].indexOf("VirtualBox") !== -1) {
return "a92:1";
} else {
return "a92:0";
}
} catch (d) {
return "a92:e";
}
}

We finally made it to our last check. It makes use of the previously mentioned WebGL debugging values trick again to get the Graphiccard Renderer and Vendor. The value for the Graphiccard Vendor is then checked for occurrences of the string “VirtualBox”. If it is found VexTrio assumes that it is opened in a browser that is run from a virtual machine. And this is enough for the visitor to not look innocent enough to fall victim to their more fraudulent payloads. Hence the check is failed (a92:1). If “VirtualBox” is not discovered in the graphics card vendor string, the check is passed (a92:0).

Conclusion

In summary, VexTrio makes use of 29 different functions to check the legitimacy of a visitor/victim who visits an infected webpage. A central function which is given at the beginning of this article is then responsible for evaluating the fingerprinting results. If any of the checks described before had a return value ending with a “:1”, the result of that analysis is a0:1. Else it's a0:0 if all checks were passed or a0:e if the analysis function failed to execute properly. The final result is then turned into a JSON object, AES CBC encrypted (with a key that is inside the remaining VexTrio source code), and sent to the server as a token value. The server is then responsible for evaluating the fingerprint result together with some additional parameters sent by the remaining VexTrio script. As the serverside code stays hidden, it is not possible to say which steps are taken on the server side. However, as a result of these hidden mechanisms, the server will respond with further JavaScript, redirecting victims to all sorts of scams and advertisements.

Personally, I believe there are some interesting takeaways from the previous analysis. First of all, it is pretty interesting to see the different methods used by VexTrio to fingerprint a modern web browser. Many of the checks done by VexTrio are likely also used by different malware families.

Based on the way the different fingerprinting methods are presented, I assume that most of them are actually copy-pasted from 3rd party sources. While I was able to proof this for some of the checks (see: Check 26), I have yet to find a source that contains all the techniques explained above. If anyone should discover an open-source script that does, don’t hesitate to reach out.

Another thing I noticed is the amount of functionalities used in the above-mentioned fingerprinting methods which are marked as “deprecated” in the Mozilla developer documentation. I demonstrated this in the analysis of Check 6. In total, 7 out of 29 checks would stop working if all browser vendors would follow up on the deprecation status.

Additionally, there is a clear reference in the WebGL documentation about the security implications for the retrieval of the WebGL rendering debug variables used in Check 10 and 29.
It appears to be a real-life example of the good old “usability” vs. “security” problem.

Figure 7: WebGL debug token issues (Source: registry.khronos.org)

I always find it interesting to see such decision problems and how they are handled. In this case, the fingerprinting in VexTrio is a negative example in a much bigger debate. I do not want to point fingers here but rather highlight that it is not always easy to decide if security and privacy are more important than usability and that the above methods of fingerprinting are known to the web development community.

But for now, let's rep up this post. I hope you learned something on the way and enjoyed the read. Who knows, maybe some of you will even fiddle around with the given code samples to discover additional methods of fingerprinting or how to counter them? :)
I am as always happy about any sort of feedback and remain open to suggestions on improving my work.

If you made it here, please know that you are awesome and that I am happy to have you reading my work.

Remember to follow my socials: https://linktr.ee/gi7w0rm to not miss my content. And if you want to go the extra step, I am currently still collecting for a new rig to do my security work. I would greatly appreciate a tip over at https://ko-fi.com/gi7w0rm.

Until the next time

Cheers ❤

--

--