archlinux / r3t0x@portfolio
ALL SYSTEMS NOMINAL /
← Back to archive · Misc

Securinets Friendly CTF 2026 / Misc WRITEUP

PAID IN FULL

Accounting insists this invoice is ordinary. It looks right, prints right, and even copy-pastes right. Still, something in the type does not add up.

EVENTSecurinets Friendly CTF 2026CATEGORYMiscDIFFICULTYmediumPOINTS500
pdfsteganographyfonts

Walkthrough

Challenge

The player receives only invoice.pdf. It renders as an ordinary archival invoice, and copying the document or running pdftotext returns the same innocent text that is visible on the page.

Concept

PDF text has three separate identities:

  • the character code stored in the page content stream;
  • the glyph selected by the font encoding and drawn by the viewer;
  • the Unicode character returned through the font's ToUnicode map.

The invoice uses a custom encoding for its embedded FreeMono font. Every printable ASCII character has two valid codes:

normal code     = ASCII byte       -> hidden bit 0
alternate code  = ASCII byte | 80h -> hidden bit 1

For example, both 0x41 and 0xC1 are mapped to the glyph named A, and the ToUnicode CMap maps both codes to Unicode U+0041. They therefore look and copy identically even though their stored codes differ.

Reconnaissance

Normal inspection is deliberately unhelpful:

file invoice.pdf
pdfinfo invoice.pdf
pdftotext invoice.pdf -
pdffonts invoice.pdf

pdffonts reveals the unusual embedded font. Expanding the PDF makes its font objects and content stream easier to inspect:

qpdf --qdf --object-streams=disable invoice.pdf unpacked.pdf

The /Differences array maps codes 0xA0 through 0xFE back onto the same glyph names used by printable ASCII. The /ToUnicode CMap performs the same double mapping for text extraction.

Solution

Decompress the page content stream and collect the hexadecimal strings rendered with the /FG font. For every byte, its high bit is the hidden bit; masking that bit away produces the innocent visible character.

The hidden bits are packed most-significant-bit first. Their byte structure is:

"GFNT" | two-byte flag length | flag | CRC32(flag)

The marker fixes the alignment, the length determines when to stop, and the CRC confirms that the glyph stream was decoded correctly.

Run the included solver:

python3 solver.py

Output:

Securinets{wow_y0u_c4n_t3ll_1d3nt1c4l_l3tt3rs_4p4rt}

Run python3 make.py whenever the flag or invoice cover text changes. The generator requires GNU FreeMono at /usr/share/fonts/gnu-free/FreeMono.otf, or an alternate path supplied through the GHOST_FONT environment variable.

Reference solver

The reference solver supplied with the writeup:

#!/usr/bin/env python3

from __future__ import annotations

from pathlib import Path
import re
import struct
import zlib


MAGIC = b"GFNT"


def decoded_streams(pdf: bytes):
    pattern = re.compile(
        rb"<<(.*?)>>\s*stream\r?\n(.*?)\r?\nendstream",
        re.DOTALL,
    )
    for dictionary, stream in pattern.findall(pdf):
        if b"/FlateDecode" in dictionary:
            try:
                yield zlib.decompress(stream)
            except zlib.error:
                continue
        else:
            yield stream


def extract_ghost_codes(pdf: bytes) -> bytes:
    line_pattern = re.compile(
        rb"BT\s+/FG\s+[-0-9.]+\s+Tf\s+[-0-9.]+\s+[-0-9.]+\s+Td\s+"
        rb"<([0-9A-Fa-f]+)>\s+Tj\s+ET"
    )

    codes = bytearray()
    for stream in decoded_streams(pdf):
        for encoded_hex in line_pattern.findall(stream):
            codes.extend(bytes.fromhex(encoded_hex.decode("ascii")))

    if not codes:
        raise ValueError("no text rendered with the /FG font was found")

    for code in codes:
        visible = code & 0x7F
        if not 0x20 <= visible <= 0x7E:
            raise ValueError(f"unexpected ghost character code: 0x{code:02x}")

    return bytes(codes)


def decode_payload(codes: bytes) -> bytes:
    bits = [(code >> 7) & 1 for code in codes]
    packed = bytearray()
    for offset in range(0, len(bits) - 7, 8):
        value = 0
        for bit in bits[offset:offset + 8]:
            value = (value << 1) | bit
        packed.append(value)

    if not packed.startswith(MAGIC):
        raise ValueError("the hidden stream does not start with the GFNT marker")
    if len(packed) < 6:
        raise ValueError("truncated payload header")

    flag_length = struct.unpack(">H", packed[4:6])[0]
    payload_end = 6 + flag_length
    crc_end = payload_end + 4
    if len(packed) < crc_end:
        raise ValueError("truncated hidden payload")

    flag = bytes(packed[6:payload_end])
    stored_crc = struct.unpack(">I", packed[payload_end:crc_end])[0]
    actual_crc = zlib.crc32(flag) & 0xFFFFFFFF
    if stored_crc != actual_crc:
        raise ValueError(
            f"CRC mismatch: stored 0x{stored_crc:08x}, calculated 0x{actual_crc:08x}"
        )

    return flag


def main() -> None:
    path = Path(__file__).with_name("invoice.pdf")
    flag = decode_payload(extract_ghost_codes(path.read_bytes()))
    print(flag.decode("ascii"))


if __name__ == "__main__":
    main()

Flag

Securinets{wow_y0u_c4n_t3ll_1d3nt1c4l_l3tt3rs_4p4rt}
Published 2026-09-28 · by r3t0x← Back to archive · Misc
0xSEARCH_INDEX

Trace a memory.

INDEX ONLINE0 addresses
↑↓ TRACEENTER OPENESC DISMISS