$ cat writeup.md…
$ cat writeup.md…
alfactf
Task: a Verilog keypad/display module stored typed text in one 64-byte buffer but exposed only a transformed 64-byte display blob. Solution: simulate the keypad, recognize a white-box AES-128 ECB core in the Verilog tables, recover the key, and decrypt each reversed 16-byte block.
В модном коворкинге есть две зоны — «Нищета» и «Покой». Доступ решает аппаратный модуль. Один человек ввёл флаг задачи на клавиатуре, а после завершения на дисплее остался 64-байтный hex-blob.
The archive contained a Verilog design and a Python/cocotb interface for a keypad-driven display module. We were given the 64-byte blob shown after someone typed the real flag and had to recover the original plaintext.
Given output:
71 80 dd 08 6c 43 37 6b 3c a8 e2 68 6d 0a 97 9b f8 23 b9 66 6a b9 92 d3 cd ad 80 cf e8 5a ed 1f c5 c4 6c 60 c0 a3 a5 c9 11 a1 d1 98 f1 83 53 95 53 e4 b9 7c 16 5f c4 3b a3 f5 1e 12 53 1f ea ca
The supplied files immediately suggested a hardware-reversing task rather than a normal software crackme:
peace.v was the synthesized Verilog design.peace.py was a cocotb TUI that let us press keys and view the display.The first key observation was that the display buffer was not a plain echo of the typed text. Even with empty input, the internal output area already contained nonzero bytes. That ruled out a direct framebuffer copy and strongly suggested some fixed transform or whitening stage.
Before reversing the crypto-like core, it was useful to understand how text entered the device.
By driving the keypad in simulation, the text entry logic turned out to be old-phone multi-tap input:
2 -> 222 -> a222 -> b2222 -> c# toggles an uppercase latch, so # then 22 -> Acc -> _ddd -> {dddd -> }This was important for two reasons. First, it confirmed the flag could be entered entirely through the keypad. Second, it let us generate controlled plaintexts and compare them with the transformed display bytes.
Simulation exposed two especially important memories:
M_00160: the 64-byte plaintext input bufferM_00161: the 64-byte display/output bufferThe output was processed as four independent 16-byte blocks. Looking at block boundaries made the structure much clearer: changes in one 16-byte region of M_00160 only affected the corresponding 16-byte region of M_00161.
That behavior matched ECB-style per-block processing rather than a stream cipher or chained mode.
The Verilog was huge, but the memory layout gave the game away.
Important layout facts:
M_00000..M_00015: 16 final 8-bit tablesM_00016..M_00159: 9 rounds × 16 32-bit tablesThose tables matched classic AES encryption components:
Te0..Te3 T-tables with round-key material already folded in,SBOX[x ^ k] ^ c,In other words, the design did not implement a custom cipher at all. It implemented white-boxed AES-128 encryption, with key material hidden inside lookup tables instead of appearing as a normal round-key array.
After identifying the AES structure, the embedded AES-128 key could be reconstructed from the tables:
3d2e73730ded60ec44750fe3b83b1572
The only remaining wrinkle was byte order. The exact per-block transform used by the display was:
disp_block = reverse_bytes(AES128_ECB_encrypt(reverse_bytes(plain_block), key))
So decryption is:
plain_block = reverse_bytes(AES128_ECB_decrypt(reverse_bytes(disp_block), key))
Applying that to each of the four 16-byte blocks recovered the full plaintext.
The final recovery script is short once the AES key and byte-order convention are known:
#!/usr/bin/env python3 from Crypto.Cipher import AES KEY = bytes.fromhex("3d2e73730ded60ec44750fe3b83b1572") TARGET = bytes.fromhex( "7180dd086c43376b3ca8e2686d0a979b" "f823b9666ab992d3cdad80cfe85aed1f" "c5c46c60c0a3a5c911a1d198f1835395" "53e4b97c165fc43ba3f51e12531feaca" ) def decrypt_display_blob(blob: bytes) -> bytes: aes = AES.new(KEY, AES.MODE_ECB) out = bytearray() for i in range(0, len(blob), 16): block = blob[i:i + 16] out.extend(aes.decrypt(block[::-1])[::-1]) return bytes(out) def encrypt_plaintext_blob(blob: bytes) -> bytes: aes = AES.new(KEY, AES.MODE_ECB) out = bytearray() for i in range(0, len(blob), 16): block = blob[i:i + 16] out.extend(aes.encrypt(block[::-1])[::-1]) return bytes(out) if __name__ == "__main__": pt = decrypt_display_blob(TARGET) print(pt.decode()) assert encrypt_plaintext_blob(pt) == TARGET
Verification steps:
python3 recover_flag.py prints the flag.alfa{whitebox_means_open_floor_plan_3f16ce7b86f56a86f645c24e4b1}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar