LA CTF 2026 - ttypsin
Ready for a classic, yet modern gaming experience?
Source files:
Challenge analysis
The entire challenge involves the game of tetris.

The game allows us to hold a piece temporarily which will prove to be useful later.
Additionally, we are allowed to save the state of the game and import it later to continue off from where it was left off.
1
2
3
save_code = repr(game_board)
save_code_64 = base64.b64encode(save_code.encode())
checksum = make_checksum(username, save_code.encode())
We are given something like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Exported games can be resumed after being imported
(your score will not be saved)
Be sure to save the checksum, otherwise the import
will fail!
Save code:
SXwgfEpUU1p8fCAgICAgICAgICAgICAgICAgICAgICAgICAgIC
AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg
ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC
AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg
ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC
AgICAgIEwgIE9PICAgTExMICBPTyAg
Checksum:
c0c3e06a8b198332f06a24479e2a80ef447b4de3e7f8820804
fb8e7d9527666b
The repr() returns a string of this form.
1
cur_block(1)|held_piece(1)|next_blocks(5)|piece_queue(5)|board(200)
An example would be:
1
'T|J|SZJO|ITZSL| IIII L OO LLL OO '
Spaces indicate the non-existence of placed blocks in the corresponding position on the board.
Save code is just the base64 encoded game board after calling repr()
Checksum?
1
2
3
def make_checksum(username, save_code):
assert len(SECRET) == 40
return hashlib.sha256((SECRET + username + save_code).strip()).hexdigest()
SECRET is 40 bytes, save_code is of length 200 in the worst case (unless the board is clear and the strip() function removes all trailing whitespaces).
Username has to be of length between 0 and 32. This is inputted at the start of the game like this:
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
if __name__ == "__main__":
try:
print("Please enter a username: ")
username = sys.stdin.buffer.readline()[:-1]
while len(username) > 32:
print("Username too long. Please try again")
username = sys.stdin.buffer.readline()[:-1]
print("Import save code (leave blank for new game): ")
save = sys.stdin.buffer.readline()[:-1]
if save:
print("Checksum (hex): ")
user_checksum = sys.stdin.buffer.readline()[:-1]
if user_checksum != make_checksum(username, base64.b64decode(save)).encode():
print("User checksum does not match save code.")
print("Please also provide the correct checksum to import save states.")
sys.exit()
game_board.start(save=base64.b64decode(save))
.
.
.
quit_game = False
while not quit_game:
key_event = game_window.getch()
if (game_board.board == winning_board):
curses.endwin()
print("Congratulations! You won!")
print(FLAG)
sys.exit()
Goal
Get our board state to be of this form:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
winning_board = [
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[7, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 4, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 6, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 3, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 5, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 2, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 7, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 4, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 6],
[0, 0, 0, 0, 0, 0, 0, 0, 3, 0],
[0, 0, 0, 0, 0, 0, 0, 5, 0, 0],
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
[0, 0, 0, 0, 0, 2, 0, 0, 0, 0],
[0, 0, 0, 0, 7, 0, 0, 0, 0, 0],
[0, 0, 0, 4, 0, 0, 0, 0, 0, 0],
[0, 0, 6, 0, 0, 0, 0, 0, 0, 0],
[0, 3, 0, 0, 0, 0, 0, 0, 0, 0],
[5, 0, 0, 0, 0, 0, 0, 0, 0, 0]
]
The numbers correspond to the shape that is occupying each cell on the board.
If a single Z block is placed on the board, the game board would look like this:
1
2
3
4
5
.
.
.
[0, 0, 0, 0, 0, 0, 4, 4, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 4, 4, 0]
This winning configuration is impossible to reach for two reasons:
- The blocks are hovering in mid-air.
- Each shape occupies exactly 1 cell.
Win condition:
- Input the savecode for the winning board.
- Input the correct hash of
(SECRET + username + winning_savecode).strip()
We do not know the value of secret.
We cannot play the game and get it to the winning state.
Solution
Solving this challenge involves two things:
- Playing the game until we clear the board.
- Hash length extension.
1. Clearing the board
If we start playing the game and we manage to clear the board of all blocks (by burning all rows and leaving nothing behind), the |board| section of the savecode would be all spaces. The strip function would eliminate all of that and we’d be left with a considerably small savecode.
This, combined with an empty username would give us a hash like this:
1
2
hash(SECRET + savecode)
hash(SECRET + "T|J|SZJO|ITZSL|")
Can we not export immediately after the game starts?
No.
The board is empty when the game has just begun with no blocks dropped.
Exporting the game just after the game starts is foribidden in the code here.
1
2
3
4
5
6
7
8
9
10
11
12
13
def draw_save_window(window, username):
"""Draw save window"""
window.border()
window.addstr(1, 2, "Exported games can be resumed after being imported")
window.addstr(2, 2, "(your score will not be saved)")
window.addstr(4, 2, "Be sure to save the checksum, otherwise the import")
window.addstr(5, 2, "will fail!")
if game_board.score == 0:
window.addstr(7, 2, "No point in exporting, place some pieces")
else:
.
.
How can I clear the board?
I discovered this particular webpage which describes a method to do so.

This is what is described as a ‘perfect clear opener’. A particular arrangement of shapes at the start such that the probability of clearing the board with the next shapes would be very large.

The hold piece option comes in handy to swap out an undesirable shape for another one.
Now, after this piece is dropped, the board would clear and I would have to export immediately by pressing e.
I received this:
1
2
3
4
5
6
7
8
9
10
11
Save code:
WnxJfEpTVEl8U0xaT0p8ICAgICAgICAgICAgICAgICAgICAgIC
AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg
ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC
AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAg
ICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC
AgICAgICAgICAgICAgICAgICAgICAgICAgICA=
Checksum:
76127818b168396db49461ea9a28255c701b6838cfbf24ce9f
794640ef1b5854
So I currently have hash(SECRET + savecode) (I set my username as empty)
Where the savecode is Z|I|JSTI|SLZOJ|
This is small enough for me to leverage the length extension attack.
2. Hash length extension
Hash length extension allows me to calculate Hash(x1+x2) given just Hash(x1) and x2 as well as the length of x1.
Why is this possible?
The sha256 uses the Merkle-Damgård construction, which functions like this:

- The message is split into blocks of 64.
-
Each block is fed into the compression function and it’s output is fed along with the next block into the compression function.
- The last block is padded as follows:
- Add one
0x80byte. - Keep adding
0x00(null) bytes until the last block length is 56. - Add the length of the message as a 8 byte integer (big endian).
- Add one
What is essentially beind done here is the continuation of the hashing process for more blocks.
Solve Steps
- Start playing, input an empty username.
- Clear the board with the approach described in the blog.
- Export the savecode and hash.
You now have
$\texttt{Hash(SECRET+savecode)}$
Secret is 40 bytes. Savecode is 15. (since the board is clear and stripped of all whitespaces)
Since padding is applied what you actually have is:
$\texttt{Hash(SECRET+savecode+padding)}$
Extend the hash of the above with the value of winning_savecode.
You now have:
$\texttt{Hash(SECRET+savecode+padding+winning_savecode)}$
Restart the game:
- Input the username as
savecode+padding. - Input the checksum above.
- Input the winning_savecode.
The conditions will be satisfied and we’ll get the flag!
len(savecode) is 15 when the board is clear.
Padding is just 64 - 40(secret) - 15(savecode) = 9 bytes.
So len(name) = len(savecode+padding) = 24 bytes which is less than 32 as required in the challenge.
Solve Script
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import hashlib
import base64
import pexpect
import time
import hlextend
original_hash = '76127818b168396db49461ea9a28255c701b6838cfbf24ce9f794640ef1b5854'
original_state = 'Z|I|JSTI|SLZOJ|' # savecode of cleared board
winning_state = 'Z|I|JSTI|SLZOJ| I S O L Z T J I S O L Z T J I S O L Z'
length = 40 + len(original_state)
pad = b"\x80" + b"\x00"*(64-length-1) + int.to_bytes(length*8, 8)
username = b"Z|I|JSTI|SLZOJ|"+pad
savestate = base64.b64encode(winning_state)
H = hlextend.new('sha256')
H.extend(winning_state.encode(), original_state.encode(), 40, original_hash)
checksum = H.hexdigest()
host = 'chall.lac.tf'
port = 32123
user = 'ttyspin'
password = b'ttyspin'
cmd = f'ssh -p {port} {user}@{host}'
child = pexpect.spawn(cmd, encoding=None, timeout=60)
child.delaybeforesend = 0
child.expect(b'password:')
child.send(password + b"\n")
child.expect(b'username:')
print("Sending username...")
child.send(username + b"\n")
child.expect(b'save code')
print("Sending savestate...")
child.send(savestate + b"\n")
child.expect(b'Checksum')
print("Sending checksum...")
child.send(checksum.encode() + b"\n")
try:
time.sleep(2)
res = child.read_nonblocking(size=10000, timeout=5)
print(res)
except Exception as e:
print(f"Error reading: {e}")
child.close()
Flag
1
lactf{T3rM1n4L_g4mE5_R_a_Pa1N_2e075ab9ae6ae098}
