Malvertising

Unravel the layers of malvertising to uncover the Flag

https://malvertising.web.ctfcompetition.com/

Challenge overview

When we open the link we encounter a page that looks like a youtube page but after inspecting the source we can see that the page is built from a photo and an iframe.

...
<html>
...
<head>
...
  <style>
  body {
          background-image: url("bg.png");
          ...
  }
...
  </style>
</head>
<body>
...
<iframe src="ads/ad.html" width="852" height="239" frameBorder="0" scrolling="no"></iframe>
...
</body>
</html>

First Level

At this point it’s unclear what our goal is in this challenge so we just researched the iframe and hoped for the best.
Upon inspecting the iframe we can see a few interesting things:

<!DOCTYPE html>
<html>
...
<body>
...
<script id="adjs" src="./src/metrics.js" type="text/javascript"></script>
<img width=1 height=1 src="./src/apY7ERVvTFvgbHPbDwSC/1x1.png">
<img style="visibility:hidden;display:none;" height="1" width="1" />
<iframe src="bh/drts.png?drts=110322201473" ...>
</iframe>
<iframe src="bh/drts.png?drts=120322201473" ...>
</iframe>
<iframe src="bh/drts.png?drts=130322201473" ...>
</iframe>

Several things that caught our attention:

  • metrics.js
  • 1×1.png
  • 3 iframes that seem to contain nothing

1×1.png always returned an empty response.
We’ve tried playing with the drts parameter but it seems like the response was always empty so we left it.

It was time to investigate metrics.js.
Browsing the metrics.js code the following snippet at the end of the file caught our attention:

var s = b('0x16', '%RuL');
var t = document[b('0x17', 'jAUm')](b('0x18', '3hyK'));
t[b('0x19', 'F#*Z')] = function() {
    try {
        var u = steg[b('0x1a', 'OfTH')](t);
    } catch (v) {}
    if (Number(/\x61\x6e\x64\x72\x6f\x69\x64/i[b('0x1b', 'JQ&l')](navigator[b('0x1c', 'IfD@')]))) {
        s[s][s](u)();
    }
}
;

It seems like there is some access to the DOM. To figure out what this code does we’ve decided to set a breakpoint and decode the strings. We’ve set a breakpoint at the beginning of that snippet and inspected the strings.

Replacing the encoded string with decoded ones makes it easy to see what the script does:

var s = 'constructor';
var t = document['getElementById']('adimg');
t['onload'] = function() {
    try {
        var u = steg['decode'](t);
    } catch (v) {}
    if (Number(/\x61\x6e\x64\x72\x6f\x69\x64/i['test'](navigator['userAgent']))) {
        s[s][s](u)();
    }
}

The code extracts data (later to be discovered as javascript code) from the img known as adimg and saves it to the variable u. It then runs a regex on our user agent and if a match is found then it runs the javascript code stored in u.
You can see that our regex is actually ascii and \x61\x6e\x64\x72\x6f\x69\x64 is just android.

So what this code basically does is check if we have the word android (case insensitive) in our user agent and if we do it runs the code in u.
So what does u contain? We can just set a breakpoint and see:

var dJs = document.createElement('script'); dJs.setAttribute('src','./src/uHsdvEHFDwljZFhPyKxp.js'); document.head.appendChild(dJs);

To sum up, if we have the string android in our user agent a script will be added & executed to our page.

We’ll just execute u ourselves and we’re past the first level.

Second Level

The second level introduces a small script with several functions:

var T = {};
T.e0 = function(a, b) {
    // ...
}
,
T.d0 = function(a, b) {
    // ...
}
,
T.e1 = function(a, b) {
       // ...
}
,
T.d1 = function(a, b) {
    // ...
}
,
T.f0 = function(a) {
    // ...
}
,
T.longsToStr = function(a) {
    // ...
}

function dJw() {
    try {
        return navigator.platform.toUpperCase().substr(0, 5) + Number(/android/i.test(navigator.userAgent)) + Number(/AdsBot/i.test(navigator.userAgent)) + Number(/Google/i.test(navigator.userAgent)) + Number(/geoedge/i.test(navigator.userAgent)) + Number(/tmt/i.test(navigator.userAgent)) + navigator.language.toUpperCase().substr(0, 2) + Number(/tpc.googlesyndication.com/i.test(document.referrer) || /doubleclick.net/i.test(document.referrer)) + Number(/geoedge/i.test(document.referrer)) + Number(/tmt/i.test(document.referrer)) + performance.navigation.type + performance.navigation.redirectCount + Number(navigator.cookieEnabled) + Number(navigator.onLine) + navigator.appCodeName.toUpperCase().substr(0, 7) + Number(navigator.maxTouchPoints > 0) + Number((undefined == window.chrome) ? true : (undefined == window.chrome.app)) + navigator.plugins.length
    } catch (e) {
        return 'err'
    }
}
;a = "A2xcVTrDuF+EqdD8VibVZIWY2k334hwWPsIzgPgmHSapj+zeDlPqH/RHlpVCitdlxQQfzOjO01xCW/6TNqkciPRbOZsizdYNf5eEOgghG0YhmIplCBLhGdxmnvsIT/69I08I/ZvIxkWyufhLayTDzFeGZlPQfjqtY8Wr59Lkw/JggztpJYPWng=="
eval(T.d0(a, dJw()));

Lets try to decode the a variable:

> echo "A2xcVTrDuF+EqdD8VibVZIWY2k334hwWPsIzgPgmHSapj+zeDlPqH/RHlpVCitdlxQQfzOjO01xCW/6TNqkciPRbOZsizdYNf5eEOgghG0YhmIplCBLhGdxmnvsIT/69I08I/ZvIxkWyufhLayTDzFeGZlPQfjqtY8Wr59Lkw/JggztpJYPWng==" | base64 -D
��!!����f�O��#���E���Kk$��W�fS�~:�cū�����`�;i%�֞

The variable seems to be encrypted.
Judging by how it’s used it seems like T.d0 is some sort of decryption routine using the output of dJw() as key.
To continue we’ll probably have to figure out how to decrypt a.

Bruteforcing the Key

The key is built from the following things:

  • navigator.platform.toUpperCase().substr(0, 5) 5 chars (probably LINUX or ANDRO considering the if statement we had in level 1)
  • Number(/android/i.test(navigator.userAgent)) 1 char (2 options 1 or 0)
  • Number(/AdsBot/i.test(navigator.userAgent)) 1 char (2 options 1 or 0)
  • Number(/Google/i.test(navigator.userAgent)) 1 char
  • Number(/geoedge/i.test(navigator.userAgent)) 1 char
  • Number(/tmt/i.test(navigator.userAgent)) 1 char
  • navigator.language.toUpperCase().substr(0, 2) 2 chars
  • Number(/tpc.googlesyndication.com/i.test(document.referrer) || /doubleclick.net/i.test(document.referrer)) 1 char
  • Number(/geoedge/i.test(document.referrer)) 1 char
  • Number(/tmt/i.test(document.referrer)) 1 char
  • performance.navigation.type according to MDN, can only be 0, 1, 2 or 255
  • performance.navigation.redirectCount [0,∞)
  • Number(navigator.cookieEnabled) 1 char
  • Number(navigator.onLine) 1 char
  • navigator.appCodeName.toUpperCase().substr(0, 7) 7 chars, and can only be MOZILLA according to MDN
  • Number(navigator.maxTouchPoints > 0) 1 char
  • Number((undefined == window.chrome) ? true : (undefined == window.chrome.app)) 1 char
  • navigator.plugins.length [0,∞)

We were a bit worried about the unlimited options for the plugins & redirect count so we decided to look at the decrypt function:

T.d0 = function(a, b) {
    var c, d;
    return a = String(a),
    b = String(b),
    0 == a.length ? '' : (c = T.f0(a.b1()),
    d = T.f0(b.u0().slice(0, 16)),
    c.length,
    c = T.d1(c, d),
    a = T.longsToStr(c),
    a = a.replace(/\0+$/, ''),
    a.u1())
}

considering b as the key, we can see that its converted to a string and then the only usage of b is in the following statement d = T.f0(b.u0().slice(0, 16))

u0 is just encoding the string:

'undefined' == typeof String.prototype.u0 && (String.prototype.u0 = function() {
    return unescape(encodeURIComponent(this))
}

Which means only the first 16 chars of the encoded key are used in the encryption & decryption process.
Given our knowledge of dJw – our key function, we know that there are no special characters in our key, that means only the first 16 chars of the actual key are used, which actually solves our problem with the redirects & plugins.
At this point we believed we had enough to write code in order to bruteforce the key.
Our bruteforce code:

// pads number
function pad(x,n) {
    return '0'.repeat(n-x.length) + x;
}
// generates a list of integers
// in range [0, num-1] in binary string
function generate_bits(num, n_pad) {
    var l = [];
    for(var i=0;i<num;i++){
        l.push(pad(num_to_binary(i), n_pad));
    }
    return l;
}
// converts an integer to binary string
function num_to_binary(num){
    return Number(num).toString(2);
}
// navigator.platform.toUpperCase().substr(0, 5)
var platform = ['LINUX', 'ANDRO'];
// first 5 regexes
var first_part = generate_bits(2**5, 5);
// navigator.language.toUpperCase().substr(0, 2)
var possible_languages = ["EN","FR", "ES" /*...*/];
var second_part = generate_bits(2**3, 3);
// can't be 255 since we only use 16 chars
var performance_type = ['0', '1','2'];

// each of the options
var vals=[];
platform.forEach(function(_pt) {
    first_part.forEach(function(_ft){
        possible_languages.forEach(function(_pl){
            second_part.forEach(function(_sp){
                performance_type.forEach(function(_t){
                    vals.push(_pt+_ft+_pl+_sp+_t);
                });
            }); 
        }); 
    });
});
console.log(vals.length);

// actual bruteforce
vals.forEach(function(v){
    eval(T.d0(a, v));
});

We ran the script in the context of the iframe and suddenly this happened:

We got hit with an anti-debugging mechanism and a third script was added to DOM.

We can display an alert to show the code executed by our eval:

var dJs = document.createElement('script'); dJs.setAttribute('src','./src/npoTHyBXnpZWgLorNrYc.js'); document.head.appendChild(dJs);

The code simply adds the script with no other side effects.

Third Level

The last level made us scratch our heads for a bit, and we decided to try and deobfuscate the script.
First thing we had in mind is trying to deobfuscate the strings in the script and see what we can collect from there. Going up a level in the backtrace of the anti debugging code we can observe the following line:

  [_0x5877('0x72', '\x31\x23\x65\x40')](_0x5877('0x73', '\x52\x32\x4f\x54') + _0x5877('0x74', '\x62\x35\x52\x23'))[_0x5877('0x75', '\x75\x49\x67\x68')](_0x5877('0x76', '\x52\x32\x4f\x54'));

It’s quite clear that the _0x5877 method decodes the strings, so we’ve decided to write a runtime script to decode them using the _0x5877 method. Our deobfuscating script:

// our obfuscated js
var _obfuscated_js = /*our obfuscated js*/"...";
/*_0x5877('0x72', '\x31\x23\x65\x40')*/
// method regex per example above
var reg = /_0x5877\([^)]+\)/ig;

m = reg.exec(_obfuscated_js);
var deobfuscated = _obfuscated_js;
// go over all matches
while(m) {
    var method_call = m[0];
    // replace method call with string
    deobfuscated = deobfuscated.replace(method_call, '\''+eval(method_call)+'\'')
    m = reg.exec(_obfuscated_js);
}

Browsing the deobfuscated script reveals something fairly interesting towards the end:

var _0x14d30a = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9]{1,4}(:[a-f0-9]{1,4}){7})/;
                var _0x4f8041 = _0x14d30a['exec'](candidate)[0x1];
                if (_0x4f8041) {
                    if (_0x4f8041['match'](/192.168.0.*/)) {
                        var _0xb9e15d = document['createElement']('script');
                        _0xb9e15d['setAttribute']('src', './src/WFmJWvYBQmZnedwpdQBU.js');
                        document['\x68\x65\x61\x64']['appendChild'](_0xb9e15d);
                    }
                }

It seems like the script uses WebRTC to determine your internal IPv4 address, and if it’s in 192.168.0.0/24 it adds a script to the DOM.
Lets see what’s in the script:

alert("CTF{I-LOVE-MALVERTISING-wkJsuw}")

And that concludes the malvertising challenge.

Summary

The challenge introduced several browser fingerprinting methods covered by obfuscation unravled layer by layer. We had a lot of fun solving this challenge and look forward to see how other teams solved it.