Post

LA CTF 2026 - ttypsin

LA CTF 2026 - ttypsin

Ready for a classic, yet modern gaming experience?

Source files:

Challenge analysis

The entire challenge involves the game of tetris.
gameboard

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:

  1. Playing the game until we clear the board.
  2. 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.
perfect clear opener
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.
win
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:
hashing

  • 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 0x80 byte.
    • 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).

Example: padding

What is essentially beind done here is the continuation of the hashing process for more blocks.

Solve Steps

  1. Start playing, input an empty username.
  2. Clear the board with the approach described in the blog.
  3. 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}

References

This post is licensed under CC BY 4.0 by the author.