Challenge: Compression isn’t encryption · CTF: DalCTF 2026 · Category: Crypto · Difficulty: Medium

“What could this binary mean? Seems to have a pretty variable length…”

The challenge title basically hands you the moral of the story up front: compression isn’t encryption. Squeezing data down with a code table doesn’t make it secret — if you can recover the table, you can recover the message. The whole challenge is about taking that idea seriously and grinding through the one detail everybody gets wrong on their first try.

This is a fun one because the “obvious” solution gives you something that looks like a flag, is wrapped in dalctf{...} perfectly, consumes every single bit with nothing left over… and is still completely wrong. The last 20% is where the actual challenge lives.


The files

We’re given two files.

alpha.txt — one long string of bits:

1
101110101011011001101111110011011111101111011010110111110100100000100111101101001110100100001001111111011011101111000011011011011111011001001001111110010110010110101110010110110111101011110111

That’s exactly 192 bits. No spaces, no obvious byte boundaries.

alphabet.txt — a table mapping characters to small floating-point numbers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
z	0.00021
j	0.0003
q	0.00033
x	0.00051
{   0.001
}   0.001
k	0.00207
_   0.003
v	0.00333
...
e	0.03609
3   0.0640
2   0.0808
1   0.0908

41 entries total, sorted by that number in ascending order.

Reading the clues

Two things jump out immediately:

  1. The title literally says compression.
  2. The hint says the binary has “a pretty variable length.”

“Variable length” + “a table of per-character frequencies” is the textbook fingerprint of Huffman coding. Huffman is the classic variable-length prefix code: frequent symbols get short bit-strings, rare symbols get long ones, and no codeword is a prefix of another so the stream decodes unambiguously.

The numbers in alphabet.txt are the symbol probabilities (the frequency model). They don’t need to sum to 1 for our purposes — they only establish the relative ordering used to build the Huffman tree. (For the record, they sum to ~0.745, which is enough to rule out a clean arithmetic-coding interpretation and keep us on the Huffman track.)

So the plan is:

  1. Build a Huffman tree from the frequency table.
  2. Derive the codeword for each symbol.
  3. Walk the 192-bit stream through the tree to decode the message.

First attempt — the textbook Huffman

Building a Huffman tree is mechanical:

  • Treat every symbol as a leaf node weighted by its probability.
  • Repeatedly take the two lowest-weight nodes, join them under a new parent whose weight is their sum, label one branch 0 and the other 1.
  • Repeat until a single tree remains.

Here’s a faithful version of the “GeeksforGeeks-style” implementation almost everyone reaches for first — a min-heap keyed on frequency:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import heapq

bits = open('alpha.txt').read().strip()

rows = []
for line in open('alphabet.txt'):
    p = line.split()
    if len(p) < 2:
        continue
    rows.append((p[0], float(p[-1])))

# Build the tree with a min-heap
heap = [[f, [ch, ""]] for ch, f in rows]
heapq.heapify(heap)
while len(heap) > 1:
    lo = heapq.heappop(heap)
    hi = heapq.heappop(heap)
    for pair in lo[1:]:
        pair[1] = '0' + pair[1]   # left  branch -> 0
    for pair in hi[1:]:
        pair[1] = '1' + pair[1]   # right branch -> 1
    heapq.heappush(heap, [lo[0] + hi[0]] + lo[1:] + hi[1:])

codes = {p[0]: p[1] for p in heap[0][1:]}

# Decode the stream
rev = {v: k for k, v in codes.items()}
out, cur = '', ''
for b in bits:
    cur += b
    if cur in rev:
        out += rev[cur]
        cur = ''

print(out, repr(cur))

Run it:

1
dalctf{y311e8wi11_4m4eypt_32tni274} ''

Look at that. It’s wrapped in dalctf{}. The leftover-bits string is empty — all 192 bits were consumed exactly, dividing perfectly into valid codewords. Every signal screams “you solved it.”

I submitted dalctf{y311e8wi11_4m4eypt_32tni274}.

Incorrect.

Why a “perfect” decode can still be wrong

This is the heart of the challenge, and it’s a genuinely sharp piece of crypto pedagogy.

Huffman trees are not unique. Whenever two nodes share the same weight, the algorithm has a free choice about which one to grab first, and a free choice about which child becomes 0 vs 1. Different choices produce different — but equally optimal — trees. Each of those trees defines a different code table, and a given bitstream can decode to different valid strings under different tables.

Look at the frequency table again: tons of ties. Three symbols at 0.02, two at 0.001, several sharing other values, and a cascade of ties that appears as soon as you start merging nodes (a merged node’s weight will frequently equal some other node’s weight). Every one of those ties is a fork in the road.

So my decode wasn’t the answer — it was an answer. A self-consistent parse produced by my tie-breaking, which differed from the encoder’s tie-breaking. The structure (dalctf{...}, the underscores, perfect bit alignment) survived because that structure is shared across all the optimal trees. The labels on equal-length codewords got permuted.

You can actually see the damage clearly. Group the symbols by codeword length:

1
2
3
4
5
6
7
8
 len  3 : ['1', '2']
 len  4 : ['3', 'e']
 len  5 : ['!','0','4','5','6','8','9','a','h','i','n','o','r','s','t']
 len  6 : ['$','7','d','l','u']
 len  7 : ['b','c','f','g','m','p','w','y']
 len  8 : ['_','k','v']
 len 10 : ['{','}']
 len 11 : ['j','q','x','z']

The codeword lengths are essentially pinned by the symbol probabilities, and so is the segmentation of the 192-bit stream into chunks — swapping the labels of two equal-length codewords keeps it a valid prefix code and keeps every bit boundary in place. What changes is which character each chunk maps to, but only within a length group.

That’s why dalctf{, the _ separators, and } came out right (their codes happened to match, and {/} are alone-ish in their length groups), while the meat of the flag got scrambled into leetspeak soup.

In fact a crib-drag makes the scramble obvious. The garbled middle word 4m4eypt ends in ypt, screaming “…rypt”, and wi11 is plainly “will.” The tree is almost right. I just had the encoder’s tie-breaking policy wrong.

Second attempt — matching the encoder’s tie-break

So the real task isn’t “build a Huffman tree.” It’s “build the same Huffman tree the challenge author built.” That comes down to two policy knobs:

  1. Tie-break order — when picking/placing nodes of equal weight, which goes first?
  2. Child labeling — does the first child get 0 or 1?

A min-heap (heapq) imposes its own implementation-defined ordering on ties, which is a convention but not the only one. A very common alternative — especially in pedagogical and from-scratch implementations — is the list-based build: keep a sorted list, pop the two smallest, and insert the merged node back into the list. The single most consequential decision there is where the merged node lands among other nodes of equal weight: at the front of its tie-group, or the back.

So I parameterized all of it and swept every combination — initial order (ascending/descending), insert-merged-node-at-front vs at-back, and first-child = 0 vs 1 — keeping only decodes that start with dalctf{ and consume all 192 bits:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def build(order, insert_at_front, first_child_zero):
    nodes = [[f, ch] for ch, f in order]
    while len(nodes) > 1:
        nodes.sort(key=lambda x: x[0])          # stable: preserves tie order
        a = nodes.pop(0)
        b = nodes.pop(0)
        children = (a[1], b[1]) if first_child_zero else (b[1], a[1])
        comb = [a[0] + b[0], children]
        if insert_at_front:
            nodes.insert(0, comb)               # merged node jumps ahead of ties
        else:
            nodes.append(comb)                  # merged node sits behind ties
    codes = {}
    def walk(n, pre):
        if isinstance(n, str):
            codes[n] = pre
        else:
            walk(n[0], pre + '0')
            walk(n[1], pre + '1')
    walk(nodes[0][1], '')
    return codes

The sweep:

1
2
asc  insert_front=True   first0=True  -> dalctf{y0u_wi11_3ncrypt_4lw4y$}
asc  insert_back =True   first0=True  -> dalctf{y311e8wi11_4m4eypt_32tni274}

The second line is my original wrong answer. The first line is the one:

dalctf{y0u_wi11_3ncrypt_4lw4y$}

Both consume all 192 bits with zero leftover. The only difference between them is whether the freshly merged node is inserted at the front or the back of its tie-group. The author’s encoder placed it at the front (and used first-child = 0). One line of policy stood between “convincing gibberish” and the real flag.

The flag

1
dalctf{y0u_wi11_3ncrypt_4lw4y$}

De-leeted: “you will encrypt always.” Which is the punchline of the whole challenge title — you compressed your data, and now you’ve learned the lesson: next time, actually encrypt it.

Lessons learned

  • Compression ≠ encryption. A Huffman stream offers exactly zero confidentiality. Recover (or guess) the code table and the plaintext falls out. Compressing before encrypting is fine; compressing instead of encrypting is the bug being mocked here.
  • A perfect-looking decode can be a lie. All 192 bits consumed, dalctf{...} wrapper intact, no leftover — and still wrong. With a code that has many tie-breaks, multiple distinct strings can all be “valid” decodes. Don’t let a clean wrapper switch your brain off.
  • Huffman trees aren’t unique. Equal weights create forks. To decode someone’s stream you must reproduce their tree, which means matching their tie-break order and 0/1 child convention — not just any optimal tree.
  • When you’re close, crib-drag. wi11 → “will” and ...ypt → “…rypt” told me the structure was right and only the labels were scrambled, which pointed straight at tie-breaking rather than at a wrong algorithm. Sweeping the few policy knobs is cheap; flailing at the wrong model is expensive.

Solved. Flag: dalctf{y0u_wi11_3ncrypt_4lw4y$}.