10 min read

CCE 2024 Quals Write Up

CCE 2024 Quals Write Up (Rev)
CCE 2024 Quals Write Up

CCE 2024 Quals


CCE 2024 예선에 있었던 비극적인 사건을 여러분은 아는가? 작년 디미고 1학년 4명은 CCE 예선에 참가했고 4명 중 3명이 0솔을 기록하며 예선 14등으로 막을 내렸다. (당시는 본선 진출팀이 더 적었다.) 2024 CCE의 예선 리버싱 문제는 분명히 올 해 문제들보다 훨씬 쉬웠다. 개인적으로 정말 한 맺히는 사건이 아닐 수 없고 지금 보면 한 문제를 제외한 모든 문제가 쉽다는 것을 알 수 있다. 선배에게 문제 파일을 받고 업솔빙을 하였으나 내가 푼 것이 아니라서 개인 블로그에 올려두진 않았는데 여기에 적어보도록 하겠다.

Auto REV

int __fastcall main(int argc, const char **argv, const char **envp)
{
  __int64 v3; // rcx
  __int64 v4; // r8
  __int64 v5; // r9
  _QWORD s2[6]; // [rsp+0h] [rbp-C0h] BYREF
  int v8; // [rsp+30h] [rbp-90h]
  char s1[64]; // [rsp+40h] [rbp-80h] BYREF
  char s[64]; // [rsp+80h] [rbp-40h] BYREF

  s2[4] = 0LL;
  s2[5] = 0LL;
  LOWORD(v8) = 0;
  __isoc99_scanf(
    &unk_2004,
    s,
    0x5ADC1CD256D3DC5DLL,
    v3,
    v4,
    v5,
    0x58D413DA1BDC5C9CLL,
    0x5ADC1CD256D3DC5DLL,
    231497173LL,
    0);
  if ( strlen(s) == 20 )
  {
    process(s, s1);
    if ( !memcmp(s1, s2, 0x14uLL) )
      printf("OK");
    else
      printf("Fail");
    return 0;
  }
  else
  {
    printf("Fail");
    return 0;
  }
}

전형적인 Auto 리버싱 문제이다. 당연히 문제 파일을 base64 인코딩하여 던져주고 이를 일정한 횟수 이상 자동으로 성공하면 플래그를 주는 문제이다. 기본적인 바이너리는 위 구조로 이루어지며, process 함수의 연산과 비교하는 값이 바뀐다.

size_t __fastcall process(const char *a1, __int64 a2)
{
  size_t result; // rax
  int i; // [rsp+1Ch] [rbp-14h]

  for ( i = 0; ; ++i )
  {
    result = strlen(a1);
    if ( i >= result )
      break;
    *(_BYTE *)(i + a2) = ((unsigned __int8)a1[i] >> 1) | (a1[i] << 7);
  }
  return result;
}
size_t __fastcall process(const char *a1, __int64 a2)
{
  size_t result; // rax
  int i; // [rsp+1Ch] [rbp-14h]

  for ( i = 0; ; ++i )
  {
    result = strlen(a1);
    if ( i >= result )
      break;
    *(_BYTE *)(i + a2) = a1[i] ^ 0x62;
  }
  return result;
}
size_t __fastcall process(const char *a1, __int64 a2)
{
  size_t result; // rax
  int i; // [rsp+1Ch] [rbp-14h]

  for ( i = 0; ; ++i )
  {
    result = strlen(a1);
    if ( i >= result )
      break;
    *(_BYTE *)(i + a2) = a1[i] - 99;
  }
  return result;
}

process 함수는 위 세 가지 중 한 가지로 결정된다. 모든 항에 임의의 값으로 xor 연산을 가하거나, 임의의 값으로 ror(rol) 연산을 시행한다. 또는 임의의 값을 뺀다. 따라서 process 함수가 어떤 연산을 가하는지, 어떤 값으로 연산을 가하는지를 알아내면 역연산을 통해 적절한 입력값을 찾아낼 수 있다.

0x1169: push    rbp
0x116a: mov     rbp, rsp
0x116d: push    rbx
0x116e: sub     rsp, 0x28
0x1172: mov     qword ptr [rbp - 0x28], rdi
0x1176: mov     qword ptr [rbp - 0x30], rsi
0x117a: mov     dword ptr [rbp - 0x14], 0
0x1181: jmp     0x11c4
0x1183: mov     eax, dword ptr [rbp - 0x14]
0x1186: movsxd  rdx, eax
0x1189: mov     rax, qword ptr [rbp - 0x28]
0x118d: add     rax, rdx
0x1190: movzx   eax, byte ptr [rax]
0x1193: movzx   eax, al
0x1196: shl     eax, 7
0x1199: mov     ecx, eax
0x119b: mov     eax, dword ptr [rbp - 0x14]
0x119e: movsxd  rdx, eax
0x11a1: mov     rax, qword ptr [rbp - 0x28]
0x11a5: add     rax, rdx
0x11a8: movzx   eax, byte ptr [rax]
0x11ab: shr     al, 1
0x11ad: or      ecx, eax
0x11af: mov     eax, dword ptr [rbp - 0x14]
0x11b2: movsxd  rdx, eax
0x11b5: mov     rax, qword ptr [rbp - 0x30]
0x11b9: add     rax, rdx
0x11bc: mov     edx, ecx
0x11be: mov     byte ptr [rax], dl
0x11c0: add     dword ptr [rbp - 0x14], 1
0x11c4: mov     eax, dword ptr [rbp - 0x14]
0x11c7: movsxd  rbx, eax
0x11ca: mov     rax, qword ptr [rbp - 0x28]
0x11ce: mov     rdi, rax
0x11d1: call    0x1030
0x11d6: cmp     rbx, rax
0x11d9: jb      0x1183
0x11db: nop
0x11dc: nop
0x11dd: mov     rbx, qword ptr [rbp - 8]
0x11e1: leave
0x11e2: ret
0x1169: push    rbp
0x116a: mov     rbp, rsp
0x116d: push    rbx
0x116e: sub     rsp, 0x28
0x1172: mov     qword ptr [rbp - 0x28], rdi
0x1176: mov     qword ptr [rbp - 0x30], rsi
0x117a: mov     dword ptr [rbp - 0x14], 0
0x1181: jmp     0x11a9
0x1183: mov     eax, dword ptr [rbp - 0x14]
0x1186: movsxd  rdx, eax
0x1189: mov     rax, qword ptr [rbp - 0x28]
0x118d: add     rax, rdx
0x1190: movzx   edx, byte ptr [rax]
0x1193: mov     eax, dword ptr [rbp - 0x14]
0x1196: movsxd  rcx, eax
0x1199: mov     rax, qword ptr [rbp - 0x30]
0x119d: add     rax, rcx
0x11a0: xor     edx, 0x62
0x11a3: mov     byte ptr [rax], dl
0x11a5: add     dword ptr [rbp - 0x14], 1
0x11a9: mov     eax, dword ptr [rbp - 0x14]
0x11ac: movsxd  rbx, eax
0x11af: mov     rax, qword ptr [rbp - 0x28]
0x11b3: mov     rdi, rax
0x11b6: call    0x1030
0x11bb: cmp     rbx, rax
0x11be: jb      0x1183
0x11c0: nop
0x11c1: nop
0x11c2: mov     rbx, qword ptr [rbp - 8]
0x11c6: leave
0x11c7: ret
0x11e4: push    rbp
0x11e5: mov     rbp, rsp
0x11e8: sub     rsp, 0xc0
0x11ef: movabs  rax, 0x58d413da1bdc5c9c
0x11f9: movabs  rdx, 0x5adc1cd256d3dc5d
0x1203: mov     qword ptr [rbp - 0xc0], rax
0x120a: mov     qword ptr [rbp - 0xb8], rdx
0x1211: mov     qword ptr [rbp - 0xb0], 0xdcc5dd5
0x1169: push    rbp
0x116a: mov     rbp, rsp
0x116d: push    rbx
0x116e: sub     rsp, 0x28
0x1172: mov     qword ptr [rbp - 0x28], rdi
0x1176: mov     qword ptr [rbp - 0x30], rsi
0x117a: mov     dword ptr [rbp - 0x14], 0
0x1181: jmp     0x11a9
0x1183: mov     eax, dword ptr [rbp - 0x14]
0x1186: movsxd  rdx, eax
0x1189: mov     rax, qword ptr [rbp - 0x28]
0x118d: add     rax, rdx
0x1190: movzx   edx, byte ptr [rax]
0x1193: mov     eax, dword ptr [rbp - 0x14]
0x1196: movsxd  rcx, eax
0x1199: mov     rax, qword ptr [rbp - 0x30]
0x119d: add     rax, rcx
0x11a0: sub     edx, 0x63
0x11a3: mov     byte ptr [rax], dl
0x11a5: add     dword ptr [rbp - 0x14], 1
0x11a9: mov     eax, dword ptr [rbp - 0x14]
0x11ac: movsxd  rbx, eax
0x11af: mov     rax, qword ptr [rbp - 0x28]
0x11b3: mov     rdi, rax
0x11b6: call    0x1030
0x11bb: cmp     rbx, rax
0x11be: jb      0x1183
0x11c0: nop
0x11c1: nop
0x11c2: mov     rbx, qword ptr [rbp - 8]
0x11c6: leave
0x11c7: ret

세 어셈블리어 코드는 위에 있는 세 가지의 process 함수를 capstone을 이용하여 출력한 것이다. 여러분은 두 어셈블리 코드의 차이를 알겠는가? 위의 코드를 보면 shrshl의 명령어를 사용하는 것을 볼 수 있고, xor 명령어를 사용하는 것을 볼 수 있다. 또한 세 번째 코드는 sub를 사용하는 것을 볼 수 있다. 따라서 해당 명령어들을 찾고 오프코드를 파싱해서 임의의 값을 찾아 역연산을 할 수 있다.

0x11e3: push    rbp
0x11e4: mov     rbp, rsp
0x11e7: sub     rsp, 0xc0
0x11ee: movabs  rax, 0x3ba9a8bbb6b221b8
0x11f8: movabs  rdx, 0x3ba43298ac9a3a19
0x1202: mov     qword ptr [rbp - 0xc0], rax
0x1209: mov     qword ptr [rbp - 0xb8], rdx
0x1210: mov     qword ptr [rbp - 0xb0], 0x249aba34
0x121b: mov     qword ptr [rbp - 0xa8], 0
0x1226: mov     qword ptr [rbp - 0xa0], 0
0x1231: mov     qword ptr [rbp - 0x98], 0
0x123c: mov     word ptr [rbp - 0x90], 0
0x1245: lea     rax, [rbp - 0x40]
0x1249: mov     rsi, rax
0x124c: lea     rax, [rip + 0xdb1]
0x1253: mov     rdi, rax
0x1256: mov     eax, 0
0x125b: call    0x1060
0x1260: lea     rax, [rbp - 0x40]
0x1264: mov     rdi, rax
0x1267: call    0x1030
0x126c: cmp     rax, 0x14
0x1270: je      0x128d
0x1272: lea     rax, [rip + 0xd8e]
0x1279: mov     rdi, rax
0x127c: mov     eax, 0
0x1281: call    0x1040
0x1286: mov     eax, 0
0x128b: jmp     0x12ee
0x128d: lea     rdx, [rbp - 0x80]
0x1291: lea     rax, [rbp - 0x40]
0x1295: mov     rsi, rdx
0x1298: mov     rdi, rax
0x129b: call    0x1169
0x12a0: lea     rcx, [rbp - 0xc0]
0x12a7: lea     rax, [rbp - 0x80]
0x12ab: mov     edx, 0x14
0x12b0: mov     rsi, rcx
0x12b3: mov     rdi, rax
0x12b6: call    0x1050
0x12bb: test    eax, eax
0x12bd: jne     0x12d5
0x12bf: lea     rax, [rip + 0xd46]
0x12c6: mov     rdi, rax
0x12c9: mov     eax, 0
0x12ce: call    0x1040
0x12d3: jmp     0x12e9
0x12d5: lea     rax, [rip + 0xd2b]
0x12dc: mov     rdi, rax
0x12df: mov     eax, 0
0x12e4: call    0x1040
0x12e9: mov     eax, 0
0x12ee: leave
0x12ef: ret

비교 배열은 main 함수에서 찾을 수 있다.

from pwn import *
from capstone import *

def rev(xv, rv, sv):
    if xv:
        for i in range(20):
            ans[i] ^= xv
    elif rv:
        for i in range(20):
            ans[i] = (ans[i] << rv | ans[i] >> (8 - rv)) & 0xff
    elif sv:
        for i in range(20):
            ans[i] += sv
            ans[i] &= 0xff

p = process('./binary')
e = ELF('./binary', checksec = False)
md = Cs(CS_ARCH_X86, CS_MODE_64)

elf = ELF('./binary', checksec = False)
func_addr = elf.symbols['main']
func_size = elf.functions['main'].size
func_code = elf.read(func_addr, func_size)

mov_cnt = 0
ans = b''
for i in md.disasm(func_code, func_addr):
    print(f"0x{i.address:x}:\t{i.mnemonic}\t{i.op_str}")
    if i.mnemonic == 'movabs':
        ans += bytes.fromhex(i.op_str[7:])[::-1]
    if i.mnemonic == 'mov':
        mov_cnt += 1
        if mov_cnt == 4:
            ans += bytes.fromhex(i.op_str[-8:].replace('x', '0'))[::-1]
            break
ans = list(ans)

elf = ELF('./binary', checksec = False)
func_addr = elf.symbols['process']
func_size = elf.functions['process'].size
func_code = elf.read(func_addr, func_size)

xor_val = 0
ror_val = 0
sub_val = 0
for i in md.disasm(func_code, func_addr):
    # print(f"0x{i.address:x}:\t{i.mnemonic}\t{i.op_str}")
    if i.mnemonic == 'shr':
        ror_val = int(i.op_str[4:], 16)
        break
    if i.mnemonic == 'xor':
        xor_val = int(i.op_str[5:], 16) & 0xff
        break
    if i.mnemonic == 'sub' and i.op_str[:3] == 'edx':
        sub_val = int(i.op_str[5:], 16)
        
print(len(ans))
        
rev(xor_val, ror_val, sub_val)
pay = ''.join([chr(i) for i in ans])

# print(p)

p.sendline(pay)
p.interactive()

위와 같은 코드로 일반화 시켜 하나의 바이너리를 익스했으므로 반복문을 돌리면 풀이할 수 있을 것이다.

very scalable

╭─tuplest@BOOK-8JHQNP0CQB ~/Hack/ctf/CCE2024/verysc
╰─$ ./problem
zsh: exec format error: ./problem

실행시켜 보면 심상치 않은 것을 볼 수 있다.

FROM arm64v8/ubuntu:20.04

COPY ./problem /usr/local/bin/

CMD ["sh"]

함께 주어진 Dockerfile을 보면 arm의 프로그램인 것을 알 수 있다.

╭─tuplest@BOOK-8JHQNP0CQB ~/Hack/ctf/CCE2024/verysc
╰─$ qemu-aarch64-static -L /usr/aarch64-linux-gnu ./problem
cce2024{ignoring_sve_is_a_meme}

qemu를 통해 x86-64에서도 실행시켜 볼 수 있는데 실행시키면 플래그가 나온다. (...) 참고로 이 문제는 딱 1솔이 나온 문제이다. ida를 통해 분석해보기 전에 꼭 파일은 실행시켜보도록 하자...

Interlag

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  __int64 v3; // rax
  __int64 v4; // rax
  __int64 v5; // rax
  __int64 v7; // rax
  __int64 v8; // rax
  __int64 v9; // rbx
  unsigned int v10; // eax
  __int64 v11; // rax
  int i; // [rsp+1Ch] [rbp-64h]
  int j; // [rsp+1Ch] [rbp-64h]
  int v14; // [rsp+20h] [rbp-60h]
  unsigned int v15; // [rsp+24h] [rbp-5Ch]
  char *s; // [rsp+28h] [rbp-58h]
  _DWORD *v17; // [rsp+30h] [rbp-50h]
  void *v18; // [rsp+38h] [rbp-48h]
  _BYTE v19[16]; // [rsp+40h] [rbp-40h] BYREF
  _BYTE v20[24]; // [rsp+50h] [rbp-30h] BYREF
  unsigned __int64 v21; // [rsp+68h] [rbp-18h]

  v21 = __readfsqword(0x28u);
  if ( a1 > 1 )
  {
    sub_13B6(v19, 145LL, 76LL, 48LL, 257LL);
    s = a2[1];
    v14 = strlen(s);
    v17 = malloc(4LL * v14);
    v18 = malloc(4LL * v14);
    memset(v18, 0, 8uLL);
    for ( i = 0; i < v14; ++i )
      v17[i] = s[i];
    sub_1298(v20, v17, (unsigned int)(v14 - 1), 257LL);
    for ( j = 0; j < v14; ++j )
    {
      do
        v15 = sub_143A(v19, 1LL, 257LL);
      while ( (unsigned __int8)sub_14A9(v15, v18) );
      *((_DWORD *)v18 + j) = v15;
      v7 = std::operator<<<std::char_traits<char>>(&std::cout, "(");
      v8 = std::ostream::operator<<(v7, v15);
      v9 = std::operator<<<std::char_traits<char>>(v8, ", ");
      v10 = sub_1330(v20, v15);
      v11 = std::ostream::operator<<(v9, v10);
      std::operator<<<std::char_traits<char>>(v11, ")  ");
    }
    std::ostream::operator<<(&std::cout, &std::endl<char,std::char_traits<char>>);
    return 0LL;
  }
  else
  {
    v3 = std::operator<<<std::char_traits<char>>(&std::cout, "usage : ");
    v4 = std::operator<<<std::char_traits<char>>(v3, *a2);
    v5 = std::operator<<<std::char_traits<char>>(v4, " [message to encrypt]");
    std::ostream::operator<<(v5, &std::endl<char,std::char_traits<char>>);
    return 0LL;
  }
}

어김없이 등장한 선형방정식 풀이 문제이다.

__int64 __fastcall sub_143A(__int64 a1, int a2, int a3)
{
  int v5; // [rsp+18h] [rbp-8h]
  int i; // [rsp+1Ch] [rbp-4h]

  v5 = sub_13FA(a1);
  if ( a3 > *(_DWORD *)(a1 + 12) )
  {
    for ( i = 0; i <= a3 / *(_DWORD *)(a1 + 12); ++i )
      v5 += sub_13FA(a1);
  }
  return (unsigned int)((v5 + a2) % a3);
}

핵심 로직만 간략하게 분석해보면 입력값이랑 곱해서 더한 후 비교한다. 딱 봐도 선형 시스템 구축이라는 것을 알 수 있다.

따라서 output.txt의 첫 번째 항이 곱해지는 계수, 두 번째 항이 배교 배열인 것을 수 있다.

from sage.all import *

c = [(18, 145) , (56, 95) , (117, 23) , (127, 207) , (116, 253) , (51, 224) , (251, 187),  (31, 31),  (16, 83) , (161, 86) , (130, 30) , (87, 224) , (160, 179),  (54, 209),  (222, 13),  (140, 59),  (76, 24),  (95, 85) , (254, 64),  (2, 56),  (125, 12),  (221, 38),  (64, 206),  (211, 114),  (75, 72),  (19, 33),  (132, 40),  (239, 134),  (147, 78),  (94, 250),  (178, 79) , (137, 132) , (105, 33) , (243, 41) , (194, 64) , (68, 253),  (1, 221) , (49, 147),  (99, 53) , (44, 207) , (233, 219) , (205, 105) , (133, 250) , (58, 241) , (12, 31) , (114, 49) , (156, 108)]  

flag_len = len(c)
field = 0x101

A = []
B = []

for a, b in c:
    row = []
    for i in range(flag_len):
        row.append(pow(a, i, field))
    B.append(b)
    A.append(row)

A = matrix(IntegerModRing(field), A)
B = vector(IntegerModRing(field), B)

sol = A.solve_right(B)
print(''.join([chr(i) for i in list(sol)]))

위와 같이 from sage.all import *의 메서드들을 이용하여 플래그를 구할 수 있다.

Comment

리버싱 문제는 이 외의 Dying Message라는 문제와 Intel's Dream이라는 문제가 있었는데 생략하도록 하겠다. 여기서 배워야 할 교훈은,

  1. 바이너리가 주어지면 일단 실행시켜 보자.
  2. 기본적으로 선형방정식을 풀 수 있어야 한다.

잘 알아두도록 하자.

1학년들이 이번 CCE 예선에 통과한 것으로 알고 있는데 CCE 본선에는 Live Fire라는 다른 대회와는 차별화된 개념이 존재하기에 몰랐다면 찾아보길 바란다.