tl;dr
- Hash length extension to forge an impossible winning board.
- Need to perform a PCO in game.
Challenge Points: 298
No. of solves: 28
Solved by: AeroSol, Ankith Abhayan
Challenge Description
1 | Ready for a classic, yet modern gaming experience? |
Source Code Analysis
game.py
- If we look at
game.pythere are couple of interesting stuff, we have awinning_boardvar which is a matrix comprising mostly of 0s but some digits in a zigzag manner. - there is a
make_checksum()func which takes in a username and save_code, has an assertion whereassert len(SECRET) == 40happens and returns:Hash((SECRET + username + save_code).strip())definitely very interesting as you will see soon. - Some standard funcs that you would associate with how the game renders elements and such, not very useful and interesting..
- Under main, we have give username as an input which should not exceed 32 length and a load from save functionality where it prompts for checksum val in hex and checks it against
make_checksum(username, base64.b64decode(save)).encode()if it the checksum matches, the game loads from the given save, again interesting mechanism. - Couple of keybinds and set actions for them in the rest of the code and there is a check which fetches our board and compares it against the winning board if they match, we get the flag.
board.py
- We see a bunch of block_shapes, kicks and a dict with piece_to_type
- We have two classes, one is
Boardand the other isBlockseems to contain everything you would normally expect from a tetris game, nothing really special here (I manually did go through the functions line by line to deduce nothing really special there 💀) - Except of-course for the save functionality, which describes how to load a save when provided one.
Overall, we are looking at quite some interesting mechanism associated with save functionality and the checksum derivation from the username and save_code.
How does the game work?
- So we know this works like usual tetris from reading the code and most importantly playing it, we have all the usual blocks which are T, J, L, S, Z, O and finally the I block.
- We can hold a block, i.e, we can mark a block as held and the block will now disappear and the next block in the queue will appear on the screen. If this was done when there is already a block held, the current block will swap out with the current block but this action cannot be done again until unless this block is placed.

We can load a game from a save, we can also export our current game in the checksum format.

Let’s see how the save_code is represented:
current, hold, nexts, queue, board = save.decode().split("|")
from this we can deduce that our currently active block is stored first, then the held block, next array, queue array and finally the board of length200Let’s convert the above image’s save_code and see:
1
'L|S|ZIZT|SOLJI| OO T JOO TT JJJ T '
All the gaps that you can see is/are the air/places where there are no blocks placed.
Now, as we have an idea how the board is represented, we can move our focus to the checksum especially on how it is formed.
Let’s have a visualization:
- We have 40 byte
SECRETvalue that is appended to our username and save_code which let’s assume is randomly generated, this prevents us from outright forging a game state we want for an username.
BUT
We are using a Merkle–Damgård hash function (SHA256 here) which is vulnerable to Hash Length Extension Attack, can we do something possibly with it?
Small Primer of Length Extension
- All MD hash functions use a compression function internally to create the hash but it cannot take arbitrary length data, so a MD-compliant padding function is applied to create an input of size which is a multiple of 512 bits in the case of SHA256.
- This result is then broken down as blocks of fixed size and they are processed one at a time with $f$ .

- Since they are processed one at a time, given
H(X)for an unknown valueX, we can easily find the value ofH(pad(X)|Y)for a chosen valueY. This requires you to know the length of the unknown so you can perform the Hash’s padding.
The Exploitation
We know we have to perform Hash Length Extension of some kind BUT we have a problem, we simply can’t query H(SECRET), we have to worry about the username and save_code, to circumvent, we can do the following
- Use an empty string for an username so our hash now looks like:

So we have to worry only about the save_code state.
But that is also a problem as:
- save_code contains the board state, which is exactly what we are trying to forge
Simple, can we not save at the start of the game so there is nothing in the screen placed?
NO, because of this line ingame.pyunderdraw_save_window():
1 | if game_board.score == 0: |
So the only option is to get a Perfect Clear Opener for which I referred this blog (I was pretty much baffled when I saw the speed of the PCOs T-T)
which means me and my teammate had to skill up in tetris…
Basically..
if we can form these structures in the game in some orientation with the I block on hold, we have 86.64% of a PCO.
so we tried and tried…
local recreation of this masterpiece:

NOW, we can do Hash Length Extension because this is how our checksum construction would look like:
since there is a .strip() happening, all the “air” will be eliminated, so we save the state, get the save_code and checksum.
We can perform the appropriate MD-compliant padding for round-info since that will now occupy the place of our username and we hash-extend.
1 | import hlextend |
We can send this now to the server:
Exploit Script
1 | import pexpect |
Flag : lactf{T3rM1n4L_g4mE5_R_a_Pa1N_2e075ab9ae6ae098}