Solving LACTF Lazy Bigrams: Phonetic Bigram Substitution Challenge

Barely Tame CTF Player. Debugging Addict. Worshipper of Wi-Fi Signals. Human? Depends on the Ping.
Detailed Walkthrough
The challenge provides us with a Python script, chall.py, and a ciphertext file, ct.txt. Our goal is to reverse the encryption process to recover the flag.
Analyzing the Encryption Script
We begin by examining the logic in chall.py. The script defines a phonetic_map containing NATO phonetic alphabet equivalents for letters, numbers, and some symbols like underscores and curly braces. There are two main functions involved in the transformation of the flag:
phonetic_mapping(ptext): This function cleans the input text to include only supported characters, converts them to uppercase, and then replaces each character with its corresponding phonetic word from the map. If the resulting string has an odd length, it appends an 'X'.encryption(ptext): This function performs a bigram substitution. It takes a string of letters, splits it into two-character pairs (bigrams), and replaces each pair with a random bigram from a shuffled list calledsub_bigrams.
The flag is processed through the phonetic mapping twice before being encrypted:
pt = phonetic_mapping(phonetic_mapping(flag))
ct = encryption(pt)
This means if the flag starts with "l", the first pass turns it into "LIMA". The second pass then takes "L", "I", "M", "A" and turns them into "LIMA", "INDIA", "MIKE", and "ALPHA" respectively. The final string consists only of capital letters, which are then grouped into bigrams and substituted.
Identifying the Vulnerability
While the bigram substitution uses a randomized mapping (sub_bigrams), the underlying "plaintext" being encrypted (the output of the second phonetic pass) is extremely structured. It is composed entirely of words from the NATO alphabet.
Because the mapping is applied twice, the repetitions become very predictable. For example, every time the character 'A' appears in the flag, it will consistently produce the sequence "ALPHALIMAPAPAHOTELALPHA" (the phonetic words for A-L-P-H-A) in the final pre-encryption stage.
Since we know the flag format starts with lactf{, we can determine the first several bigram mappings immediately. From there, we can perform a brute-force search for the remaining characters.
Developing the Solver
We can reconstruct the flag character by character. For each position, we try every possible character (A-Z, 0-9, _, }). For each trial character, we generate the corresponding segment of the second-pass phonetic string and check if it remains consistent with the ciphertext bigrams and the mappings we have already established.
A mapping is consistent if:
The same plaintext bigram always maps to the same ciphertext bigram.
The same ciphertext bigram always maps back to the same plaintext bigram.
Here is the final solver script:
import re
# Load ciphertext and define the NATO map
with open("ct.txt", "r") as f:
ct = f.read().strip()
ct_bigrams = [ct[i:i+2] for i in range(0, len(ct), 2)]
phonetic_map = {
"A":"ALPHA","B":"BRAVO","C":"CHARLIE","D":"DELTA","E":"ECHO","F":"FOXTROT",
"G":"GOLF","H":"HOTEL","I":"INDIA","J":"JULIETT","K":"KILO","L":"LIMA",
"M":"MIKE","N":"NOVEMBER","O":"OSCAR","P":"PAPA","Q":"QUEBEC","R":"ROMEO",
"S":"SIERRA","T":"TANGO","U":"UNIFORM","V":"VICTOR","W":"WHISKEY","X":"XRAY",
"Y":"YANKEE","Z":"ZULU","_":"UNDERSCORE","{":"OPENCURLYBRACE","}":"CLOSECURLYBRACE",
"0":"ZERO","1":"ONE","2":"TWO","3":"THREE","4":"FOUR","5":"FIVE","6":"SIX",
"7":"SEVEN","8":"EIGHT","9":"NINE"
}
def solve():
flag = "LACTF{"
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_}"
while not flag.endswith("}"):
for c in chars:
test = flag + c
# Simulate Layer 1 and Layer 2
p1 = "".join([phonetic_map[x] for x in test.upper()])
p2 = "".join([phonetic_map[x] for x in p1])
p2_bs = [p2[i:i+2] for i in range(0, len(p2)//2 * 2, 2)]
# Check mapping consistency
m, mi, ok = {}, {}, True
for i in range(len(p2_bs)):
p_b, ct_b = p2_bs[i], ct_bigrams[i]
if (p_b in m and m[p_b] != ct_b) or (ct_b in mi and mi[ct_b] != p_b):
ok = False; break
m[p_b], mi[ct_b] = ct_b, p_b
if ok:
flag = test
break
return flag.lower()
print(solve())
Running this script reveals the flag sequentially by identifying which characters satisfy the strict bigram mapping constraints.
Final Flag
lactf{n0t_r34lly_4_b1gr4m_su8st1tu7ion_bu7_1_w1ll_tak3_1t_f0r_n0w}



