IntroFortID 2026
Baby Rev
A compiled Python checker hides its answer behind a reversed touch-tone (phone keypad) encoding, disambiguated by a SHA-256 check, recovered by reading the .pyc bytecode by hand.
What this one teaches
- A
.pycfile keeps every constant, name and control-flow decision even without a matching decompiler. - Touch-tone (T9) encoding maps several letters to one digit, so decoding a digit string alone is ambiguous.
- A hash check at the end of a chain of checks is there to pick one candidate out of several equally valid guesses, not to hide the answer itself.
Task description
I’ve been texting a lot recently, but can’t seem to strike a conversation with this person. They keep asking about some kind of flag…
Reading a .pyc without running it
The handout is a single compiled Python module, chall.pyc, 1154 bytes. Run, it would print a prompt and check an input, but I never ran it. The first two header bytes are 6f 0d, magic number 3439, which marks Python 3.10 bytecode; my local interpreter is 3.14, and marshal.loads refuses a 3.10 code object outright:
marshal.loads failed: ValueError bad marshal data (unknown type code)
The 3.10 code-object layout is known, though, so a short marshal reader that walks the fields by hand pulls out the module’s constants and names directly. That is enough: the constant pool and name table carry the whole logic.
code <<module>>
names = ('string', 'hashlib', 'sha256', 't9text', 't9', 'nope', 'input',
'user_input', 'len', 'startswith', 'endswith', 'words', 'split',
'w', 'all', 'join', 'get_touchtone', 'encode', 'hexdigest', 'print')
consts =
'pls text me the flag :P\n> '
81
'FortID{'
'}'
'_'
'-'
-1
'33696-7383-3824-4-9465664238-36-32347-878267-8766-348-55487-74-0133-24566'
'utf-8'
'3576c495f89236515fbbfbfb519dceb9899122e92460d418d2ce37a1ff357e0c'
'*\\0/* I knew you could do it! ^5'
The names point at string.ascii_lowercase, string.digits, a t9text.get_touchtone helper, and hashlib.sha256. The constants give the two values the input is checked against: a digit string and a SHA-256 hex digest. Reading those alongside the opcode sequence, the checker comes out to:
user_input = input("pls text me the flag :P\n> ")
if len(user_input) != 81:
nope()
if not (user_input.startswith("FortID{") and user_input.endswith("}")):
nope()
words = user_input[len("FortID{"):-len("}")] # strip FortID{ and }
for w in words.split("_"):
if not all(c in string.ascii_lowercase for c in w) and \
not all(c in string.digits for c in w): # each word all-letters or all-digits
nope()
words = "-".join(words.split("_")) # underscores become dashes
if t9text.get_touchtone(words)[::-1] != \
"33696-7383-3824-4-9465664238-36-32347-878267-8766-348-55487-74-0133-24566":
nope()
if sha256(user_input.encode("utf-8")).hexdigest() != \
"3576c495f89236515fbbfbfb519dceb9899122e92460d418d2ce37a1ff357e0c":
nope()
The task’s wording (“texting”) is the nudge toward old phone-keypad text entry: touch-tone (T9) encoding, where each letter maps to the digit it is printed on (2 = abc, 3 = def, and so on up to 9 = wxyz), while digits pass through unchanged.
Why the hash is the disambiguator
Touch-tone encoding is many-to-one: a digit can stand for several letters (4 is g, h or i), so a digit string does not pin down a unique spelling. The checker reverses the touch-tone string before comparing, which just scrambles how the constant reads, nothing cryptographic. The SHA-256 check at the end is what actually selects one spelling out of the keypad-equivalent candidates. Here every word turns out to be ordinary English, so the natural reading was obvious well before the hash mattered; it never had to be brute-forced or treated as a barrier.
Decoding the digits
Undoing the reversal gives the forward touch-tone string, which splits on - into one group per word:
stored (reversed): 33696-7383-3824-4-9465664238-...-24566
forward: 66542-3310-47-78455-843-6678-762878-...-69633
Reading each group off a keypad, 66542 maps digit by digit to n-o-k-i-a (6=mno picks n then o, 5=jkl picks k, 4=ghi picks i, 2=abc picks a), so it spells nokia. 3310 is a pure-digit group that passes through unchanged, 47 is is, 78455 is still, and so on. The groups assemble into a short joke about an old phone being sturdy hardware, opening FortID{nokia_3310_is_still... and closing with }.
Verifying against the real constants
I rebuilt the candidate from the keypad reading and ran the checker’s own two comparisons against the constants pulled from the .pyc, in a standalone script that never touches the handout. Each word is lowercased letters or digits, underscores become dashes, every character maps to its keypad digit, and the result is reversed and compared, then the whole input is hashed:
import hashlib
TT = "33696-7383-3824-4-9465664238-36-32347-878267-8766-348-55487-74-0133-24566"
DIG = "3576c495f89236515fbbfbfb519dceb9899122e92460d418d2ce37a1ff357e0c"
keypad = {c: d for d, s in zip("23456789",
["abc","def","ghi","jkl","mno","pqrs","tuv","wxyz"]) for c in s}
flag = "FortID{" + "_".join([...the decoded words...]) + "}" # clipped here
body = "-".join(flag[len("FortID{"):-1].split("_"))
touchtone = "".join(keypad.get(c, c) for c in body)
print("len == 81 :", len(flag) == 81)
print("reversed == stored :", touchtone[::-1] == TT)
print("sha256 == stored :", hashlib.sha256(flag.encode("utf-8")).hexdigest() == DIG)
len == 81 : True
reversed == stored : True
sha256 == stored : True
All three of the checker’s gates pass: the length is 81, the reversed touch-tone string matches the stored constant byte for byte, and the SHA-256 digest matches. The reconstructed sentence is the accepted input, confirmed without ever executing the module.
Why compiled Python is not protection
A .pyc keeps every constant, name and control-flow decision, so even with no decompiler for the exact version, a small module’s constant pool and name table expose the full logic. The two transforms layered on top, reversing a string and mapping letters to keypad digits, are both trivially invertible. The only step an attacker genuinely cannot undo is the SHA-256, and here it hides nothing, because the digit string already spells the answer out. A secret that must be checked locally should ship only as a salted, slow hash of a high-entropy value, or be checked server-side instead.
The flagReveal
FortID{nokia_3310_is_still_the_most_robust_piece_of_technology_i_have_ever_owned}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.