TFCCTF2025 | FONT LEAGUES: When Your Flag is Hidden in a Typeface — Writeup

Quick Note: This post may contain affiliate links. I may earn a commission at no extra cost to you.

👉 Practice real hacking labs here

So, here I am, happily cruising through this CTF, when I open the next challenge: FONT LEAGUES.

At first glance I thought, “Cool, maybe some font trivia? A logo? Something artsy?”
…Nope. It was a straight-up font file. A .ttf.

And suddenly I realized: Oh no. They want me to reverse engineer a font.

 

Step 1: The Suspicious Font

Inside the provided ZIP was an HTML demo page and a .ttf font. When I typed random junk into the textbox, nothing really happened. But the challenge description said something like:

“This time, YOU give me the flag.”

That hint, combined with the title FONT LEAGUES, screamed ligatures.

If you’re not into typography: ligatures are when a font replaces a sequence of letters with a single glyph. It says that “You will get an O if it is correct. Put it in the TFCCTF{…} format before submitting

So if I type the correct flag string, it causes the font to replace it with some secret glyph?

Step 2: Opening the Font

I dumped it with FontTools.

Sure enough, the GSUB table (that’s the Glyph Substitution table in OpenType) was packed with ligature rules.

I want to see GSUB (glyph substitutions) and cmap (Unicode → glyph mapping) at minimum. So I dumped just those:

ttx -t GSUB -o gsub.ttx font-leagues.ttf
ttx -t cmap -o cmap.ttx font-leagues.ttf

 

I opened `gsub.ttx` (XML). I find lots of blocks.

And also, I opened `cmap.ttx`(XML). It has lots of blocks too.

There are a lot of ligatures, which probably map specific letter sequences to the ‘O’ glyph. I went through the lookups to find the substitutions that generate this glyph. For now, I know the ‘O’ glyph is named ‘O’ in Arial, so I focused on those sequences and collect them.

Step 3: The Hunt for the “O”

Here’s where it gets fun. I realized that typing the right string would eventually collapse into a final glyph. And in the demo page, if you got it right, it would draw a big letter O.

So my job was:

  • Trace all these ligature substitutions,
  • Find the one that eventually turns into the glyph shaped like an O,
  • Backtrack what character sequence leads there.
"""
First we load up FontTools and open the Arial-custom.ttf file.
"""
from fontTools.ttLib import TTFont
font = TTFont("Arial-custom.ttf")
candidates = [] # And we initialize this list to store the flag candidates.
"""
This part grabs the cmap table: basically a dictionary 
that says “glyph1234 = letter ‘a’”.
We’ll need this later so when we decode glyphs, 
they turn into actual readable text instead of random alien runes.
"""
cmap = font.getBestCmap()
glyph_to_char = {g: chr(cp) for cp, g in cmap.items()}
"""
Here’s the juicy part: we dive into the GSUB table.
We’re collecting all the ligature rules — these are like secret recipes.
"""
ligs = []
for lookup in font["GSUB"].table.LookupList.Lookup:
    for sub in lookup.SubTable:
        if sub.LookupType == 4:  # Ligature
            for first, entries in sub.ligatures.items():
                for lig in entries:
                    seq = [first] + lig.Component
                    ligs.append((seq, lig.LigGlyph))
"""
Example: "a" + "b" → glyphXYZ.
If you type the right sequence, the font replaces it with a new glyph.
Basically: font alchemy!
"""
def expand(glyph):
    """
    This function traces backwards.
    => If a glyph is the result of other glyphs, it expands recursively until 
    we reach real characters (a–f, 0–9).
    => If it's already a "base" glyph, we convert it directly to a character.
    """
    for seq, out in ligs:
        if out == glyph:
            return "".join(expand(g) for g in seq)
    return glyph_to_char.get(glyph, "")
# reverse-engineering the font’s ligature "Jenga tower" :D
"""
And finally, the grand reveal:
=> inputs = all glyphs used as “ingredients.”
=> outputs = all glyphs that appear as results.
=> outputs - inputs = glyphs that nobody else uses → aka 'final forms'
"""
inputs = {g for seq, _ in ligs for g in seq}
outputs = {out for _, out in ligs}
for f in outputs - inputs:
    s = expand(f) # We expand those finals, then filter:
    # 1. Is it long (≥32 chars)?
    # 2. Is it all hex (0–9, a–f)?
    if len(s) >= 32 and all(c in "0123456789abcdef" for c in s.lower()):
        # If yes → add the flag candidate to candidates list
        candidates.append(s)
print(candidates)

There are so many candidates! But, you might ask:

Why ≥ 32 chars?Because most flags or hashes in CTFs aren’t just 3 characters long. 32 chars = MD5-ish, 64 chars = SHA-256-ish.

Why `all(c in “0123456789abcdef” for c in s.lower())`? — to check if the expansion looks like hex.

  • Why hex? Because among all expansions, one will stand out as a clean, structured string.
  • Random junk might contain punctuation or letters outside a–f.
  • The challenge title “FONT LEAGUES” hinted at ligatures, but the payload hidden in those ligatures looked like a hash.

I copied the list and then created a Vanilla JavaScript to automate the testing of each candidate on the demo page’s text area.

const candidates = ['1b312e2d0c639c138a6d4d770d728cf4289e4aab169349a0ef4864bd8a6d68a2', 
'f84cf84c6c9f4d8f2d355f27402a897d', ...];
const textarea = document.querySelector("textarea");
let i = 0;
let paused = false;
let timer = null;
function testNext() {
  if (paused || i >= candidates.length) return;
  textarea.value = "TFCCTF{" + candidates[i] + "}";
  textarea.dispatchEvent(new Event("input", { bubbles: true }));
  console.log("Testing:", candidates[i]);
  i++;
  timer = setTimeout(testNext, 100);
}
// Pause/resume on any key press
document.addEventListener("keydown", () => {
  paused = !paused;
  if (paused) {
    console.log("Paused at candidate", i);
    clearTimeout(timer);
  } else {
    console.log("Resuming at candidate", i);
    testNext();
  }
});
testNext();

I paused the script as soon as I saw the “O” on the text area.

Step 4: Wrapping It Up

So I copied these candidates, reloaded the page, and tested them one by one.

So the flag was:

TFCCTF{1f89a957a0816e3bea3fa026cd9a47cf181fb2c0e0c9e9442a2c783b01c083d2}

When I pasted that into the demo page, the font cheerfully spat out a big fat O. Victory!

Why This Challenge Was Awesome?

Most CTF challenges make you dig through binaries, scripts, or network captures. This one made me squint at a font file. Totally unexpected, and it was a great reminder that any digital format can hide logic — even the thing you normally use to pick whether your text is in Comic Sans or Times New Roman.

Also, it was a fun “aha!” moment to realize that ligatures (a typography feature meant to make text pretty) were being abused to hide a flag.

TL;DR

  • Got a font.
  • Found ligatures chaining characters together.
  • Only one chain collapsed into the “O” glyph.
  • Reconstructed the input → got the flag.

✍️ That’s the story of how I learned that fonts aren’t just about making PowerPoint slides look fancy — they can also be cryptic validators in a CTF. Next time you download a .ttf, maybe check if it’s hiding a flag. 😅

Ready to practice?

Try HackTheBox