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.
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
ToUnicodemap.
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}