TFC CTF 2024 Writeup

x8, MCKNIGHT, VIRTUAL-REV

2024년 8월 2일부터 4일까지 48시간 간 진행된 TFC CTF 2024이다.

Problem Level Type Remarks
VIRTUAL-REV -
Reversing
논리 및 사칙연산 등 구현 해석
MCKNIGHT -
Reversing
Pyarmor Deobfuscation
x8 -
Reversing
Rust / VM

Level(1-10) : Score / Highest Score

VIRTUAL-REV

코드 해석

전반적 로직

사용자로부터 입력 값을 받아 l0,l1,l2,l3,l4,l5,lax 레지스터에 대하여 연산을 진행한다. l0~l4 의 값이 특정 값이 되었을 때 FLG 명령을 수행하면 flag를 출력할 수 있다. 사용자 입력의 형식은 [instruction] [register1],[register2]\n 형태가 된다.

src = strtok(luma_code, " \n");
    ...
    step1_1(dest, &check1_1);
    step1_2(dest, &check1_2);
    step1_3(dest, &check1_3);
    if ( check1_1 == -1 && check1_2 == -1 && check1_3 == -1 )// step1 3개 중 하나라도 만족시 통과
    {
      puts("LUMA_ERROR (1): Invalid instruction name!");
      exit(0);
    }
    src = strtok(0LL, ",");                     // [step1] [step2_1],[step2_2]\n
    if ( src )
    {
      strncpy(s, src, 3uLL);
      if ( s[0] == ' ' )                        // 띄어쓰기 방지?
      {
        s_len = strlen(s);
        memmove(s, &s[1], s_len);
      }
      step2(s, &index2_1);                      // [step2_1] : l0 ~l5 lax lip
    }
    src = strtok(0LL, "\n");
    if ( src )
    {
      strncpy(s2, src, 3uLL);
      if ( s2[0] == ' ' )
      {
        v5 = strlen(s2);
        memmove(s2, &s2[1], v5);
      }
      step2(s2, &index2_2);                     // [step2_1] : l0 ~l5 lax lip
    }


instruction은 크게 3종류로 입력 형식 또한 3종류이며, reg1 = instruction(reg1,reg2) 혹은 reg1 = instruction(reg1)를 실행한다. 또한 각 instruction 실행 시마다 count가 측정되어, 동일 instruction이 10번 이상 호출되면 에러를 출력한다.

 if ( check1_2 != -1 && (index2_2 != -1 || index2_1 == -1)// step 1_2 :: 2_1 (X) or 2_2 (O) :: 2_1에 입력
      || check1_1 != -1 && (index2_1 == -1 || index2_2 == -1)// step 1_1은 2_1, 2_2 모두 입력
      || check1_3 != -1 && (index2_1 != -1 || index2_2 != -1) )// step1_3 :: 2_1(O) or 2_2(O) :: 레지스터 미입력
    {
      puts("LUMA_ERROR (3): Invalid registers for this type of instruction!");
      exit(0);
    }


특히 FLG instruction은 호출 시 l0,l1,l2,l3,l4 값이 각각 1337,108,117,109,996인 경우에 flag를 출력하므로, 다른 instruction을 통하여 레지스터 값을 원하는 값으로 조정하고 FLG를 호출할 필요가 있다.

int __fastcall get_flag(__int64 *a1)
{
  __int64 v1; // rax
  char v3; // [rsp+17h] [rbp-9h]
  FILE *stream; // [rsp+18h] [rbp-8h]

  v1 = *a1;
  if ( *a1 == 1337 )
  {
    v1 = a1[1];
    if ( v1 == 108 )
    {
      v1 = a1[2];
      if ( v1 == 117 )
      {
        v1 = a1[3];
        if ( v1 == 109 )
        {
          v1 = a1[4];
          if ( v1 == 97 )
          {
            stream = fopen("./flag.txt", "r");
           ...
}


Instruction 종류

호출하는 Instruction은 각각 입력된 register에 대하여 사칙, 논리 연산 등을 시행한다. 각 함수 명은 의미 없는 문자 나열이기 때문에 각 Instruction의 기능은 직접 파악하여 한다. Instruction의 수가 너무많기 때문에 파악 과정은 생략한다. [name]_[function] 으로 정리하면 다음과 같다.

.data:000055B0F3BD10D0                 dq offset XZD_AandB
.data:000055B0F3BD10D8                 dq offset STF_AshiftleftB
.data:000055B0F3BD10E0                 dq offset QER_AshiftrightB
.data:000055B0F3BD10E8                 dq offset LQE_AandB
.data:000055B0F3BD10F0                 dq offset SQL_AorB
.data:000055B0F3BD10F8                 dq offset RALK_SUM
.data:000055B0F3BD1100                 dq offset MQZL_SUB
.data:000055B0F3BD1108                 dq offset LQDM_divide
.data:000055B0F3BD1110                 dq offset SAMO_mod
.data:000055B0F3BD1118                 dq offset XKA_mul
.data:000055B0F3BD1120                 dq offset MISZ_AisB
.data:000055B0F3BD1128                 align 10h
.data:000055B0F3BD1130                 dq offset NEAZ_not
.data:000055B0F3BD1138                 dq offset MINL_not_plus_1
.data:000055B0F3BD1140                 dq offset OAN_inc
.data:000055B0F3BD1148                 dq offset MAZ_dec
.data:000055B0F3BD1150                 dq offset NO_returnA
.data:000055B0F3BD1158                 dq offset BRAILA_???


PoC

Instruction 조합

Instruction 종류를 파악했으므로 아래와 같이 연산을 수행하면 목표 Value에 도달 및 Flag를 출력할 수 있다.

l0 = 1337 = 0101 0011 1001
l1 = 108 = 0110 1100
l2 = 117 = 0111 0101
l3 = 109 = 0110 1101
l4 = 97 = 0110 0001


  l0 l1 l2 l3 l4 l5 lax
inc l5           1  
shl l5,l5           10  
inc lax             1
sum lax,l5             11
mov l2,lax     11        
inc l2     100        
mul l2,l5     1000        
shl lax,lax             0001 1000
shl lax,l5             0110 0000
l4              
mov l4,lax         0110 0000    
inc l4         0110 0001    
l1              
mov l1,lax   0110 0000          
shr l1,l5   0001 1000          
divide l1,l5   0000 1100          
sum l1,lax   0110 1100          
l3              
mov l3, l1       0110 1100      
inc l3       0110 1101      
l2              
sum l2,l3     0111 0101        
l0              
mov l0,l5 10            
mul l0,l5 100            
inc l0 101            
shl l0,l0 1010 0000            
shl l0, l5 10 1000 0000            
mul l0,l5 101 0000 0000            
mov lax,l2             0111 0101
div lax,l5             0011 1010
dec lax             0011 1001
or l0.lax              


PoC.py

from pwn import *

p = remote('localhost', 1337)

p.recvuntil(b'Insert luma code: \n')

payload = b'''OAN l5
STF l5,l5
OAN lax
RALK lax,l5
MISZ l2,lax
OAN l2
XKA l2,l5
STF lax,lax
STF lax,l5
MISZ l4,lax
OAN l4
MISZ l1,lax
QER l1,l5
LQDM l1,l5
RALK l1,lax
MISZ l3, l1
OAN l3
RALK l2,l3
MISZ l0,l5
XKA l0,l5
OAN l0
STF l0,l0
STF l0,l5
XKA l0,l5
MISZ lax,l2
LQDM lax,l5
MAZ lax
SQL l0,lax
FLG
'''

p.sendline(payload)

response = p.recv()
print(response.decode())

p.close()


MCKNIGHT

PyArmor Deobfuscate

문제에는 PyArmor로 난독화된 hasher.py가 존재한다. 이를 PyArmor-Unpackermethods3 - pybass.py를 활용하여 난독화 해제한다. 문제 파일 중 init.cpython-310.pyc, _pytransform.so 가 존재하므로 python3.10Linux 환경에서 구동한다. 구동 결과로 hasher.pyc 를 얻을 수 있다.

https://github.com/Svenskithesource/PyArmor-Unpacker/blob/main/methods/method 3/bypass.py

hasher.pyc로부터 pycdc를 활용하여 원본 소스코드를 얻을 수 있다. 평문 password를 읽어 자체 알고리즘을 통한 hash dump (fllag.tfc)를 생성하는 코드이다.

def generator(cnt):
    coeffs = []
    for i in range(cnt):
        aux = []
        for j in range(cnt):
            aux.append(nums[(i + j) * 1337 % 256])
        coeffs.append(aux)
    return coeffs

coeffs = generator(FLAG_LEN)

def calc_line(k, password):
    rez = 0
    for i in range(len(password)):
        rez += password[i] * coeffs[k][i]
    return rez

def hash(password):
    password = password.encode()
    rez = []
    for i in range(FLAG_LEN):
        rez.append(calc_line(i, password))
    final = []
    for k in range(FLAG_LEN):
        aux = 0
        for i in range(FLAG_LEN):
            aux += coeffs[i][i] * rez[k] ** i
        final.append(aux)
    data = 'X'.join((lambda .0: [ str(i) for i in .0 ])(final))
    data = lzma.compress(data.encode())
    return data

def protect_pytransform():
    pass

protect_pytransform()
if __name__ == '__main__':
    if len(sys.argv) != 2:
        print('Usage: python3 hasher.py <password>')
        sys.exit(1)
    password = sys.argv[1]
    f = open('flag.tfc', 'wb')
    f.write(hash(password))
    f.close()
    return None


패스워드 역산

flag.tfc to rez

final[k] == Σcoeffs[i][i] * (rez[k] ** i) 이므로, rez[k]진법 으로 final[k] 를 표현하였을 때 각 자리 숫자가 coeffs[i][i] 라고 할 수 있다. 근거하여 역산 코드를 작성하면 아래와 같다.

def brute_force(n):
    res = 256
    while True:
        fin = 0
        for i in range(FLAG_LEN-1,-1,-1):
            fin += coeffs[i][i] * res**i
            if fin > n:
                res += 1
                break
        if fin < n:
            res += 1
        elif fin ==n:
            return res
        elif fin > n :
            return 0       # res < 256 인 케이스 (없었음)
            
def fin_to_rez(data):
    final = lzma.decompress(data).decode().split('X')
    rez = []  
    for i in range(FLAG_LEN):
        fin = int(final[i])
        rez.append(brute_force(fin))
    return rez


rez to password

rez[k] == Σpassword[i]*coeff[k][i] 이므로, password 각 자리를 변수(17개)로 하는 일차 방정식(17개)가 주어졌다고 볼 수 있으므로, 아래와 같이 역산 알고리즘을 짤 수 있다.

def rez_to_password(rez):
    result =''
    rez_vector = np.array(rez)
    coeff_matrix = np.array(coeffs)
    solution_vector = np.linalg.solve(coeff_matrix,rez_vector)
    for sol in solution_vector:
        result +=chr(int(round(sol)))
    return result


결과

passwordlum41sv3ryskibid1 이다. 원본 코드에 이 패스워드를 대입하여 실행하면 문제 파일과 동일한 flag.tfc파일이 생성된다.


부록(PoC.py)

import sys
import lzma
import np
FLAG_LEN = 17
nums = [
    203,
    99,
    1,
    ... # 표기 상 생략
    63,
    179,
    136]

def generator(cnt):
    coeffs = []
    for i in range(cnt):
        aux = []
        for j in range(cnt):
            aux.append(nums[(i + j) * 1337 % 256])
        coeffs.append(aux)
    return coeffs

coeffs = generator(FLAG_LEN)

def brute_force(n):
    res = 256
    
    while True:
        fin = 0
        for i in range(FLAG_LEN-1,-1,-1):
            fin += coeffs[i][i] * res**i
            if fin > n:
                res += 1
                print("ouch")
                break
        if fin < n:
            res += 1
        elif fin ==n:
            return res
        elif fin > n :
            return 0       # res < 256 인 케이스 
            
def fin_to_rez(data):
    final = lzma.decompress(data).decode().split('X')
    rez = []
    for i in range(FLAG_LEN):
        fin = int(final[i])
        rez.append(brute_force(fin))
    return rez

def rez_to_password(rez):
    result =''
    rez_vector = np.array(rez)
    coeff_matrix = np.array(coeffs)
    solution_vector = np.linalg.solve(coeff_matrix,rez_vector)
    for sol in solution_vector:
        result +=chr(int(round(sol)))
    return result

if __name__=='__main__':
    f = open('./dist/flag.tfc', 'rb')
    data = f.read()
    rez = fin_to_rez(data)
    password = rez_to_password(rez)
    print(password)


x8

./x8 --file program.bin 의 형태로 program.bin과 함께 실행하며, 이후 사용자 입력값을 요구한다. Rust로 작성된 것으로 추정된다.

코드 분석

메인 로직

메인 로직은 쉽게 찾을 수 있다. program.bin으로부터 값을 읽어 <dyn x8::instruction::Instruction>::parse를 통해 opcode를 생성하고, 그에 따라 (v3 + 0x28), (v4 + 0x20) 함수를 실행한다.

그 중 (v3 + 0x28)program.bin에서 단순 return 3 등의 함수만 호출하여, program.binoffset 를 옮기는 일종의 rip 역할만 한다. 그러므로 (v4 + 0x20) 에 집중할 필요가 있다.

__int64 (__fastcall *__fastcall x8::vm::VM::run(_BYTE *s, __int64 v17))(__int64)
{
	...
  while ( 1 )
  {
    v10 = (char **)&s[(unsigned __int8)s[0x409] + 1];// program.bin 앞 파트
    v11 = (__int64)(s + 0x101);                 // program.bin 뒤파트
    pc = &v10;
    v2 = <dyn x8::instruction::Instruction>::parse((__int64)&pc, (__int64)&unk_55555561B2B8);// parse(program.bin[i,i+3?])
    v4 = v3;
    s[0x409] += (*(__int64 (__fastcall **)(__int64))(v3 + 0x28))(v2);// [rdx+28h](v2)
    (*(void (__fastcall **)(__int64, _BYTE *))(v4 + 0x20))(v2, s);// [rdx+20h](v2,s)
    result = *(__int64 (__fastcall **)(__int64))v4;
    if ( *s )
      break;
    if ( result )
      result(v2);
    v6 = *(_QWORD *)(v4 + 8);
    if ( v6 )
      _rust_dealloc(v2, v6, *(_QWORD *)(v4 + 0x10));
  }
	...
}

(v4 + 0x20)

사용자 입력을 요구하므로 올바른 입력 값이 무엇인지 체크하는 부분이 있을 것으로 예상되므로, 아래와 같은 방법으로 v4+0x20 이 호출하는 함수들을 순차적으로 파싱하였다.

import idc
import idaapi
import idautils
import ida_dbg

# 결과를 저장할 파일 경로
output_file_path = "./function.txt"

def hook_call_instruction(ea, iterations=10):
    with open(output_file_path, "a") as file:
        count = 0
        while count < iterations:
            ida_dbg.add_bpt(ea)
            idaapi.run_to(ea)
            ida_dbg.wait_for_next_event(ida_dbg.WFNE_SUSP, -1)

            r15_value = idc.get_reg_value("r15")
            call_address = idc.get_qword(r15_value + 0x20) # 호출 함수 주소
            
            result = f"Iteration {count+1}: Address: 0x{ea:X}, r15: 0x{r15_value:X}, call [r15+20h]: 0x{call_address:X}\n"
            print(result)
            file.write(result)

            ida_dbg.del_bpt(ea)
            count += 1

        print(f"Finished {iterations} iterations. Results saved to {output_file_path}")

call_instruction_address = 0x5555555659D7  # 'call qword ptr [r15+20h]' 명령어의 주소
hook_call_instruction(call_instruction_address, iterations=10000)


마지막 jump_not_equal함수가 equal조건을 만족하도록 하면 flag를 얻을 수 있다.

r15: 0x55555561B3B0, call [r15+20h]: 0x555555566180
...
r15: 0x55555561B5A8, call [r15+20h]: 0x555555566EC0  //xor_final
r15: 0x55555561B618, call [r15+20h]: 0x555555567080  //cmp_2
r15: 0x55555561B458, call [r15+20h]: 0x555555566400  //jump not equal
r15: 0x55555561B5E0, call [r15+20h]: 0x555555566F90  //write
r15: 0x55555561B5E0, call [r15+20h]: 0x555555566F90
r15: 0x55555561B5E0, call [r15+20h]: 0x555555566F90
r15: 0x55555561B5E0, call [r15+20h]: 0x555555566F90
r15: 0x55555561B5E0, call [r15+20h]: 0x555555566F90
r15: 0x55555561B378, call [r15+20h]: 0x555555566380  //rtn 1

xor_final

사용자 입력 값([rsi+rax+401h])과 어떤 값(cl)xor한다. (동적 디버깅으로 파악)

.text:0000555555566EC0 xor_final       proc near
.text:0000555555566EC0 ; __unwind { // 555555554000
.text:0000555555566EC0                 push    rax
.text:0000555555566EC1                 mov     rax, rdi
.text:0000555555566EC4                 movzx   edi, byte ptr [rdi+1]
.text:0000555555566EC8                 cmp     rdi, 10h
.text:0000555555566ECC                 jnb     short loc_555555566EE8
.text:0000555555566ECE                 movzx   eax, byte ptr [rax]
.text:0000555555566ED1                 cmp     rax, 10h
.text:0000555555566ED5                 jnb     short loc_555555566EFA
.text:0000555555566ED7                 movzx   ecx, byte ptr [rsi+rdi+401h]
.text:0000555555566EDF                 xor     [rsi+rax+401h], cl
.text:0000555555566EE6                 pop     rax
.text:0000555555566EE7                 retn

cmp_2

xor_final 결과 값(cl)과 어떤 값([rsi+rax+401h])을 비교한다.

.text:0000555555567080 cmp_2           proc near               
.text:0000555555567080 ; __unwind { // 555555554000
.text:0000555555567080                 push    rax
.text:0000555555567081                 mov     rax, rdi
.text:0000555555567084                 movzx   edi, byte ptr [rdi]
.text:0000555555567087                 cmp     rdi, 10h
.text:000055555556708B                 jnb     short loc_5555555670BD
.text:000055555556708D                 movzx   eax, byte ptr [rax+1]
.text:0000555555567091                 cmp     rax, 10h
.text:0000555555567095                 jnb     short loc_5555555670CF
.text:0000555555567097                 movzx   ecx, byte ptr [rsi+rdi+401h]
.text:000055555556709F                 cmp     cl, [rsi+rax+401h]
.text:00005555555670A6                 setz    al
.text:00005555555670A9                 movzx   ecx, byte ptr [rsi+411h]
.text:00005555555670B0                 and     cl, 0FEh
.text:00005555555670B3                 or      cl, al
.text:00005555555670B5                 mov     [rsi+411h], cl
.text:00005555555670BB                 pop     rax
.text:00005555555670BC                 retn


PoC

(올바른 사용자 입력값) = (cmp_2 어떤 값) xor (cmp_2 어떤 값) 이 된다. ida python을 이용하여 각 값을 추출한 후 flag를 연산하였다. 추가적으로 원활한 flag 추출을 위하여 cmp_2가 한번에 그치지 않도록, cmp_2 결과가 항상 참이 되도록 패치하였다.

import idc
import idaapi
import idautils
import ida_dbg

output_file_path = "flag.txt"

def set_cl_to_1(): # mov cl, 1
    cl_value = 1
    rcx_value = idc.get_reg_value("rcx")
    new_rcx_value = (rcx_value & 0xFFFFFFFFFFFFFF00) | cl_value
    idc.set_reg_value(new_rcx_value, "rcx")

def extract_and_xor(xor_ea, cmp_ea, iterations=10):
    flag = ''
    patch_ea = 0x00005555555670B3
    with open(output_file_path, "a") as file:
        count = 0
        ida_dbg.add_bpt(xor_ea)
        ida_dbg.add_bpt(cmp_ea)
        ida_dbg.add_bpt(patch_ea)

        while count < iterations:
            # 1 .text:0000555555566EDF xor [rsi+rax+401h], cl
            idaapi.run_to(xor_ea)
            ida_dbg.wait_for_next_event(ida_dbg.WFNE_SUSP, -1)
            cl_value = idc.get_reg_value("cl")

            # 2 .text:000055555556709F cmp cl, [rsi+rax+401h]
            idaapi.run_to(cmp_ea)
            ida_dbg.wait_for_next_event(ida_dbg.WFNE_SUSP, -1)

            rsi_value = idc.get_reg_value("rsi")
            rax_value = idc.get_reg_value("rax")
            memory_address = rsi_value + rax_value + 0x401
            memory_value_after = idc.get_wide_byte(memory_address)

            ### result
            xor_result = cl_value ^ memory_value_after
            result_char = chr(xor_result)
            
            result = f"Iteration {count+1}: CL: 0x{cl_value:X}, After: 0x{memory_value_after:X}, chr(a^b): {result_char}\n"
            flag +=result_char
            print(result)
            file.write(result)
            file.write(f"flag:{flag}\n")
            
            # 3. cmp2 결과 항상 참으로 패치
            idaapi.run_to(patch_ea)
            ida_dbg.wait_for_next_event(ida_dbg.WFNE_SUSP, -1)
            set_cl_to_1()
            count += 1
        
xor_instruction_address = 0x555555566EDF  # xor_final -> 'xor [rsi+rax+401h], cl' 
cmp_instruction_address = 0x55555556709F  # cmp2 -> 'cmp cl, [rsi+rax+401h]' 
iterations = 100

extract_and_xor(xor_instruction_address, cmp_instruction_address, iterations=iterations)

flag:TFCCTF{3ede51da1709268b2cefddcd93c4cd98}