오늘은 CodeGate 2026 write up을 작성할 것입니다.

 

greybox

__int64 __fastcall main(int a1, char **a2, char **a3)
{
  setvbuf(stdout, 0LL, 2, 0LL);
  setvbuf(stdin, 0LL, 2, 0LL);
  memset(&unk_404140, 0, 0x40uLL);
  printf("Input: ");
  if ( read(0, &unk_404140, 0x40uLL) != 64 )
  {
    puts("Input length must be 64bytes...");
    _exit(-1);
  }
  sub_401883();
  return 0LL;
}

 

먼저 prob 파일의 main 함수를 디컴파일한 결과입니다.

main 함수에서는 입력값을 unk_404140에 저장합니다.

그 다음 입력값이 64바이트가 맞는지 체크하고 sub_401883 함수를 호출합니다.

 

__int64 sub_401883()
{
  int i; // [rsp+4h] [rbp-3Ch]
  int j; // [rsp+8h] [rbp-38h]
  int k; // [rsp+Ch] [rbp-34h]
  __int64 v4; // [rsp+10h] [rbp-30h]
  _DWORD *v5; // [rsp+18h] [rbp-28h]
  __int64 v6[4]; // [rsp+20h] [rbp-20h]

  v6[3] = __readfsqword(0x28u);
  v4 = *(_QWORD *)(stderr + 216); // stderr의 vtable 포인터 저장
  v5 = calloc(1uLL, 0x430uLL); // VM 구조체 0x430 바이트 크기 할당
  v5[9] = 256;  
  sub_401790(v5); // target 파일 바이트 코드 읽어, 포인터 저장
  for ( i = 0; i <= 15; ++i ) // 입력값을 VM의 레지스터에 저장
    v5[i + 12] = *((_DWORD *)&unk_404140 + i); // 입력값을 4바이트 씩 16번 저장
  v6[0] = (__int64)calloc(1uLL, 0xF0uLL) + 16; // 힙에 메모리 할당
  v6[1] = (__int64)calloc(1uLL, 0xF0uLL) + 16;
  for ( j = 0; j <= 1; ++j ) // 가짜 FILE 구조체 생성
  {
    *(_QWORD *)(v6[j] + 200) = v5; // v5 구조체를 포인터에 저장
    *(_QWORD *)(v6[j] + 104) = v6[(j + 1) % 2]; // v6[0]과 v6[1]을 연결
    *(_QWORD *)(v6[j] + 136) = v6[j] - 16; 
    *(_QWORD *)(v6[j] + 216) = v4 + 504; // 호출할 함수
    sub_4016D5(v6[j]);
  }
  for ( k = 0; k <= 18; ++k ) // dword_4040A0에 호출할 핸들러 offset 존재
    *((_QWORD *)&unk_404140 + k + 16) = (char *)sub_4012B6 + dword_4040A0[k]; // 19개의 핸들러 실제 주소를 unk_404140+0x80에 저장 (dispatch table)
  *(_QWORD *)(stderr + 104) = v6[0]; // 프로그램 종료될 때 v6[0] 실행 -> v6[1] -> v6[0] 반복
  return 0LL;
}

 

sub_401883 함수는 v5에 VM 구조체를 생성하며 0x430 바이트의 크기를 할당합니다.

그 후, 반복문을 통해 입력값이 담긴 &unk_404140을 v5의[i+12]에 반복적으로 저장하는 것을 통해 레지스터에 입력값을 저장합니다.

 

v6[0]과 v6[1]에 힙 메모리를 할당하고 반복문을 통해 가짜 FILE 구조체를 만듭니다. 여기서 v5 구조체를 저장하고, v6[(j + 1) % 2]를 통해 v6[0]과 v6[1]을 반복적으로 서로 연결합니다. 그리고 v4+504를 저장하여 glibc가 호출할 함수를 저장합니다.

 

dword_4040A0    dd 11h, 25h, 59h, 89h, 0C1h, 0F9h, 12Eh, 163h, 197h, 1CFh
                dd 207h, 23Bh, 26Fh, 2A3h, 2D7h, 30Bh, 348h, 369h, 39Ch

 

마지막으로 [unk_404140 + 0x80 + k]에 sub_4012B6(base 주소) + dword_4040A0[k](핸들러 offset)을 저장하여 총 19개의 핸들러를 저장합니다. 

 

__int64 __fastcall sub_401790(__int64 a1)
{
  FILE *stream; // [rsp+10h] [rbp-10h]
  __int64 size; // [rsp+18h] [rbp-8h]

  stream = fopen("./target", "rb"); // target 파일 열기
  if ( !stream )
    _exit(-1);
  fseek(stream, 0LL, 2);
  size = ftell(stream); // 파일 크기 구하기
  if ( !size )
  {
    fclose(stream);
    _exit(-1);
  }
  fseek(stream, 0LL, 0);
  *(_QWORD *)(a1 + 40) = calloc(1uLL, size); // v5 + 0x28에 target 크기만큼 힙 메모리 할당
  if ( size != fread(*(void **)(a1 + 40), 1uLL, size, stream) ) // target 파일 바이트 크기만큼 읽어서 v5 + 0x28에 저장
    _exit(-1);
  fclose(stream);
  return 0LL;
}

 

sub_401790 함수를 보면 target의 바이트 코드를 힙에 저장하고, 그 포인터를 v5 + 0x28에 저장합니다.

 

__int64 __fastcall sub_4016D5(_QWORD *a1) // a1[25](= a1 + 200) = v5 = VM 구조체
{
  memset(a1, 0, 0x60uLL); 
  a1[5] = 322376503LL;
  a1[20] = a1 - 2;
  a1[26] = (char *)&unk_404140 
         + 8 * *(unsigned __int8 *)(*(_QWORD *)(a1[25] + 40LL) + *(unsigned int *)(a1[25] + 32LL))
         + 8 * (unsigned __int64)(a1[17] < a1[13]); // vtable = &unk_404140 + (opcode + carry) * 8 <- glibc가 a1[26]을 통해 핸들러 호출
  return 0LL;
}

 

sub_4016D5 함수는 v6[0], v6[1]이 실행될 때 호출할 VM 핸들러를 결정하는 vtable을 세팅합니다.

sub_401883 함수 마지막에 unk_404140 + 0x80 + k에서 19개의 핸들러를 저장하고 여기서 opcode로 꺼내 사용합니다.

 

그 후 sub_401883 함수가 리턴되고 main 함수에서 return 0을 실행하면 glibc가 exit() 함수를 호출합니다.

이 때 glibc는 FILE 체인을 순회하는데 stderr의 _chain에 v6[0]을 저장해두었기에 stderr를 통해 v6[0]이 실행되고,

v6[0] -> v6[1] -> v6[0]이 반복됩니다.

 

import struct

data = open("deploy/target", "rb").read()

names = {
    0:'NOP', 1:'MOV', 2:'LDI', 3:'LDX', 4:'STX', 5:'LD', 6:'ST',
    7:'ADD', 8:'SUB', 9:'MUL', 10:'AND', 11:'OR', 12:'XOR',
    13:'SHL', 14:'SHR', 15:'CMP', 16:'JMP', 17:'JNE', 18:'END'
}

def instr_size(j):
    if j in (0, 18):  return 1
    if j == 2:        return 6
    if j in (16, 17): return 5
    return 3

def carry_at(target_pc):
    carry = 1
    pc = 0
    while pc < target_pc:
        op = data[pc]
        j  = op + carry - 3
        pc += instr_size(j)
        carry ^= 1
    return carry

def disasm(start_pc, start_carry):
    pc = start_pc
    carry = start_carry
    while pc < len(data):
        op = data[pc]
        j  = op + carry - 3
        if j not in names:
            print(f"PC={pc:4d}  UNKNOWN (raw=0x{op:02x})")
            break
        name = names[j]
        size = instr_size(j)
        if j == 18:
            print(f"PC={pc:4d}  END")
            break
        elif j == 0:
            print(f"PC={pc:4d}  NOP")
        elif j == 2:
            B   = data[pc+1]
            imm = struct.unpack_from('<I', data, pc+2)[0]
            print(f"PC={pc:4d}  LDI  r{B}, {imm:#010x}")
        elif j in (16, 17):
            imm = struct.unpack_from('<i', data, pc+1)[0]
            print(f"PC={pc:4d}  {name:4s} {imm:+d}  -> PC={pc+imm}")
        else:
            A, B = data[pc+1], data[pc+2]
            print(f"PC={pc:4d}  {name:4s} r{A}, r{B}")
        pc    += size
        carry ^= 1

print("=== [초기화] PC=0 ===")
disasm(0, carry_at(0))

print("\n=== PC=429 ===")
disasm(429, carry_at(429))

print("\n=== [검증] PC=23 ===")
disasm(23, carry_at(23))

 

해당 코드로 target 파일을 디스어셈블했습니다.

 

=== PC=429 ===
PC= 429  LD   r0, r0
PC= 432  LD   r1, r4
PC= 435  LD   r2, r8
PC= 438  LD   r3, r12
PC= 441  ADD  r0, r1
PC= 444  XOR  r3, r0
PC= 447  LDI  r6, 0xb1293119
PC= 453  LDI  r5, 0xb129311e
PC= 459  XOR  r6, r5 // 0x7
PC= 462  MOV  r4, r3
PC= 465  SHL  r3, r6 // 7비트 시프트
PC= 468  LDI  r6, 0xc0b814eb
PC= 474  LDI  r5, 0xc0b814f2
PC= 480  XOR  r6, r5 // 0x19 = 25
PC= 483  SHR  r4, r6 // 25비트 시프트
PC= 486  OR   r3, r4 
PC= 489  ADD  r2, r3
PC= 492  XOR  r1, r2 
PC= 495  LDI  r6, 0x0a71fa8b
PC= 501  LDI  r5, 0x0a71fa82
PC= 507  XOR  r6, r5 // 0x9
PC= 510  MOV  r4, r1
PC= 513  SHL  r1, r6 // 9비트 시프트
PC= 516  LDI  r6, 0xdf0d02d4
PC= 522  LDI  r5, 0xdf0d02c3
PC= 528  XOR  r6, r5 // 0x17 = 23
PC= 531  SHR  r4, r6 // 23비트 시프트
PC= 534  OR   r1, r4
PC= 537  ADD  r0, r1
PC= 540  XOR  r3, r0
PC= 543  LDI  r6, 0xd4334860
PC= 549  LDI  r5, 0xd433486d
PC= 555  XOR  r6, r5 // 0xd = 13
PC= 558  MOV  r4, r3
PC= 561  SHL  r3, r6 // 13비트 시프트
PC= 564  LDI  r6, 0xe522dd57
PC= 570  LDI  r5, 0xe522dd44
PC= 576  XOR  r6, r5 // 0x13 = 19
PC= 579  SHR  r4, r6 // 19비트 시프트
PC= 582  OR   r3, r4
PC= 585  ADD  r2, r3
PC= 588  XOR  r1, r2
PC= 591  LDI  r6, 0xcaf211d2
PC= 597  LDI  r5, 0xcaf211c0
PC= 603  XOR  r6, r5 // 0x12 = 18
PC= 606  MOV  r4, r1
PC= 609  SHL  r1, r6 // 18비트 시프트
.
.
.

 

디스어셈블 결과 중 일부를 보면 7, 9, 13, 18 순서로 왼쪽 시프트를 반복하는데, 이를 통해 Salsa20 통해 입력값을 암호화한다는 것을 알 수 있습니다. shr을 하는 부분은 VM에 비트 회전 명령어가 없어 SHL 7 + SHR 25 + OR 3개로 비트 회전을 구현합니다.

 

=== PC=23 (carry=0) ===
PC=  23  LD   r0, r0
PC=  26  LDI  r1, 0x721f4fa3
PC=  32  LDI  r4, 0xb36c7aa9
PC=  38  XOR  r1, r4 // correct[0] = 0x721f4fa3 ^ 0xb36c7aa9
PC=  41  XOR  r0, r1 // Salsa20_input[0] ^ correct[0]
PC=  44  OR   r7, r0 // 결과 r7에 누적
PC=  47  LD   r0, r1
PC=  50  LDI  r1, 0xc7d0da5e
PC=  56  LDI  r4, 0x73e48aab
PC=  62  XOR  r1, r4 // correct[1] = 0xc7d0da5e ^ 0x73e48aab
PC=  65  XOR  r0, r1 // Salsa20_input[1] ^ correct[1]
PC=  68  OR   r7, r0 // 결과 r7에 누적
PC=  71  LD   r0, r2
.
.
.

 

마지막 검증 부분은 다음과 같습니다.

해당 부분에서 반복적으로 xor을 진행하며 정답 배열을 만들고 Salsa20으로 암호화된 입력값을 비교합니다.

마지막 END 핸들러로 sub_401652 함수로 이동합니다.

 

__int64 __fastcall sub_401652(__int64 a1)
{
  if ( *(_DWORD *)(*(_QWORD *)(a1 + 200) + 28LL) ) // a1 + 200(0xc8) = v5 VM 구조체
  {						   // v5 + 28(0x1c) = r7
    puts("Wrong!\n");	                           // 즉, r7이 0이면 Success, 아니면 Wrong
  }
  else
  {
    puts("Sucess!");
    printf("Flag is codegate2026{%.*s}\n\n", 64, byte_404140);
  }
  *(_QWORD *)(a1 + 104) = stdout;
  return 0LL;
}

 

sub_401652 함수는 r7가 0인지 아닌지 판단하여 0이면 Success, 0이 아니면 Wrong을 출력합니다.

r7이 0이라는 것은 Salsa20_input[n] ^ correct[n]의 결과가 0이므로 같은 값을 의미합니다.

r7이 0일 때 Success를 출력하며 Flag를 출력하는데 byte_404140이며, 이는 입력값이 Flag임을 의미합니다.

 

import struct

EXPECTED_XOR_PAIRS = [
    (0x721f4fa3, 0xb36c7aa9),
    (0xc7d0da5e, 0x73e48aab),
    (0x123da2a9, 0xe519b93d),
    (0xce0725d7, 0x2cd8786c),
    (0x6049942b, 0xaf91e8e1),
    (0x673f94c0, 0x470a5443),
    (0xeeb3de53, 0x3cdf0408),
    (0x4a3a3b30, 0x272544be),
    (0xe4de6612, 0xab106c98),
    (0xee94b713, 0x01326b92),
    (0x151c98c3, 0xa45be7c6),
    (0xf078cf76, 0xed7f4048),
    (0x7ccaf263, 0x9c64f3b8),
    (0xbf750bc1, 0x36c3044b),
    (0x2601456e, 0x2db5a696),
    (0x4f54acea, 0xcce4d035),
]

MASK = 0xffffffff
NUM_ROUNDS = 8

def rotl(v, n):
    return ((v << n) | (v >> (32 - n))) & MASK

def rotr(v, n):
    return ((v >> n) | (v << (32 - n))) & MASK

def qr(a, b, c, d):
    a = (a + b) & MASK;  d = rotl(d ^ a, 7)
    c = (c + d) & MASK;  b = rotl(b ^ c, 9)
    a = (a + b) & MASK;  d = rotl(d ^ a, 13)
    c = (c + d) & MASK;  b = rotl(b ^ c, 18)
    return a, b, c, d

def qr_inv(a, b, c, d):
    b = rotr(b, 18);  b ^= c;  c = (c - d) & MASK
    d = rotr(d, 13);  d ^= a;  a = (a - b) & MASK
    b = rotr(b,  9);  b ^= c;  c = (c - d) & MASK
    d = rotr(d,  7);  d ^= a;  a = (a - b) & MASK
    return a, b, c, d

def double_round(s):
    s = list(s)
    s[0],  s[4],  s[8],  s[12] = qr(s[0],  s[4],  s[8],  s[12])
    s[1],  s[5],  s[9],  s[13] = qr(s[1],  s[5],  s[9],  s[13])
    s[2],  s[6],  s[10], s[14] = qr(s[2],  s[6],  s[10], s[14])
    s[3],  s[7],  s[11], s[15] = qr(s[3],  s[7],  s[11], s[15])
    s[0],  s[5],  s[10], s[15] = qr(s[0],  s[5],  s[10], s[15])
    s[1],  s[6],  s[11], s[12] = qr(s[1],  s[6],  s[11], s[12])
    s[2],  s[7],  s[8],  s[13] = qr(s[2],  s[7],  s[8],  s[13])
    s[3],  s[4],  s[9],  s[14] = qr(s[3],  s[4],  s[9],  s[14])
    return s

def double_round_inv(s):
    s = list(s)
    s[3],  s[4],  s[9],  s[14] = qr_inv(s[3],  s[4],  s[9],  s[14])
    s[2],  s[7],  s[8],  s[13] = qr_inv(s[2],  s[7],  s[8],  s[13])
    s[1],  s[6],  s[11], s[12] = qr_inv(s[1],  s[6],  s[11], s[12])
    s[0],  s[5],  s[10], s[15] = qr_inv(s[0],  s[5],  s[10], s[15])
    s[3],  s[7],  s[11], s[15] = qr_inv(s[3],  s[7],  s[11], s[15])
    s[2],  s[6],  s[10], s[14] = qr_inv(s[2],  s[6],  s[10], s[14])
    s[1],  s[5],  s[9],  s[13] = qr_inv(s[1],  s[5],  s[9],  s[13])
    s[0],  s[4],  s[8],  s[12] = qr_inv(s[0],  s[4],  s[8],  s[12])
    return s

def solve():
    E = [(a ^ b) & MASK for a, b in EXPECTED_XOR_PAIRS]

    state = list(E)
    for _ in range(NUM_ROUNDS):
        state = double_round_inv(state)

    check = list(state)
    for _ in range(NUM_ROUNDS):
        check = double_round(check)
    assert check == E

    return b''.join(struct.pack('<I', v) for v in state)

if __name__ == '__main__':
    flag_bytes = solve()
    print(f"codegate2026{{{flag_bytes.decode()}}}")

 

다음과 같이 익스를 작성했습니다. 

해당 익스에서는 correct를 만드는 값들을 모두 가져와 xor을 통해 correct를 만들고, 이를 역연산하여 정답 input값을 구합니다.

 


Ghost

#!/usr/bin/env python3

import secrets
from utils import dm_compress, hex_to_words, round_core
from secret import SBOXES, BANNER, FLAG

def main():
    iv = secrets.randbits(64)
    chances = 2**7

    print(BANNER)
    print(f"IV = {iv:016x}")
    print(f"Chances = {chances}/{2**7}")

    while True:
        print(
            "\n"
            "[1] query\n"
            "[2] submit\n"
            "[3] quit"
        )

        choice = input("> ")

        if choice == "1":
            if chances <= 0:
                print("Nope!\n")
                continue

            right_s = input("right > ")
            key_s = input("subkey > ")

            try:
                right = int(right_s, 16)
                subkey = int(key_s, 16)
            except:
                print("Bad input\n")
                continue

            chances -= 1
            y = round_core(right, subkey, SBOXES)

            print(f"core = {y:08x}")

        elif choice == "2":
            m1s = input("m1 > ")
            m2s = input("m2 > ")

            try:
                w1 = hex_to_words(m1s)
                w2 = hex_to_words(m2s)
            except Exception as e:
                continue

            if w1 == w2:
                print("Blocks must differ\n")
                continue

            h1 = dm_compress(iv, w1, SBOXES)
            h2 = dm_compress(iv, w2, SBOXES)

            if h1 == h2:
                print("Good!")
                print(f"flag = {FLAG()}\n")
            else:
                print("Nope!")

            return

        elif choice == "3":
            print("Bye!\n")
            return
        
        else:
            print("Only 1,2,3 are allowed\n")

if __name__ == "__main__":
    main()

 

server.py는 다음과 같습니다.

해당 코드는 시작 시 64비트 랜덤 값 iv를 생성해 16진수로 출력하고, 총  2^7(128)번의 query 기회를 제공합니다.

1번 메뉴(query)를 선택하면 right_s와 key_s를 입력 받아 정수로 변환한 뒤, SBOXES와 함께 round_core 연산을 하여 결과를 출력합니다. 2번 메뉴(submit)를 선택하면  256비트 크기의 m1s와 m2s를 받아 두 입력이 다른지 확인한뒤, hex_to_words 8개의 32비트 워드로 변환하고 dm_compress를 계산합니다.

즉, m1s와 m2s가 서로 다른 값이어야 하며, dm_compress 결과가 같은 경우 flag를 출력합니다.

 

MASK32 = 0xFFFFFFFF
MASK64 = 0xFFFFFFFFFFFFFFFF

def dm_compress(iv, key_words, sboxes):
    return encrypt_block(iv, key_words, sboxes) ^ iv

def encrypt_block(block, key_words, sboxes):
    state = split_block(block)
    state = encrypt_rounds_from_state(state, full_schedule(key_words), sboxes)
    return join_block(*state)

def split_block(block):
    return ((block >> 32) & MASK32, block & MASK32)

def encrypt_rounds_from_state(state, round_keys, sboxes):
    cur = state
    for k in round_keys:
        cur = apply_round(cur, k, sboxes)
    return cur

def apply_round(state, subkey, sboxes):
    left, right = state
    return (right & MASK32, (left ^ round_core(right, subkey, sboxes)) & MASK32)

def round_core(right, subkey, sboxes):
    return rotl32(sbox_layer((right + subkey) & MASK32, sboxes), 11)

def rotl32(x, r):
    x &= MASK32
    return ((x << r) & MASK32) | (x >> (32 - r))

def sbox_layer(x, sboxes):
    y = 0
    for i in range(8):
        nib = (x >> (4 * i)) & 0xF
        y |= (sboxes[i][nib] & 0xF) << (4 * i)
    return y & MASK32

def full_schedule(key_words):
    if len(key_words) != 8:
        raise ValueError("expected 8 key words")
    return list(key_words) * 3 + list(reversed(key_words))

def hex_to_words(hex_string):
    s = hex_string.strip().lower()
    if s.startswith("0x"):
        s = s[2:]
    if len(s) != 64 or any(c not in "0123456789abcdef" for c in s):
        raise ValueError("message block must be 64 hex chars")
    return [int(s[i : i + 8], 16) for i in range(0, 64, 8)]

def join_block(left, right):
    return ((left & MASK32) << 32) | (right & MASK32)

 

utils.py를 보면 압축 함수를 확인할 수 있습니다.

dm_compress 함수는 iv를 key_words로 암호화한 뒤, iv로 XOR하는 방식입니다. 

encrypt_block은 64비트 블록을 상위 32비트, 하위 32비트로 나누고 encrypt_rounds_from_state 함수로 반복적으로 값을 수정하며, 실제 연산은 apply_round 함수에서 진행합니다.

 

def apply_round(state, subkey, sboxes):
    left, right = state
    return (right & MASK32, (left ^ round_core(right, subkey, sboxes)) & MASK32)

 

apply_round 함수를 보면 right 값을 왼쪽으로 넣고, left 값은 round_core(right, subkey, sboxes)와 XOR하여 오른쪽으로 넣습니다.

즉, 오른쪽 값으로 계산한 결과를 왼쪽 값과 XOR하여 오른쪽 값을 만드는 구조입니다.

 

def round_core(right, subkey, sboxes):
    return rotl32(sbox_layer((right + subkey) & MASK32, sboxes), 11)
    
def sbox_layer(x, sboxes):
    y = 0
    for i in range(8):
        nib = (x >> (4 * i)) & 0xF
        y |= (sboxes[i][nib] & 0xF) << (4 * i)
    return y & MASK32
  
def rotl32(x, r):
    x &= MASK32
    return ((x << r) & MASK32) | (x >> (32 - r))

 

round_core 함수는 sbox_layer(right + subkey, sboxes)를 계산하고 11비트 왼쪽 rotate를 수행합니다. 

sbox_layer 함수는 32비트 값을 4비트 씩 총 8개에 대하여 S-box를 적용합니다.

 

def full_schedule(key_words):
    if len(key_words) != 8:
        raise ValueError("expected 8 key words")
    return list(key_words) * 3 + list(reversed(key_words))

 

full_schedule 함수에서 사용자가 입력한 256비트 입력값은 8개의 32비트 워드로 나뉘고, 이 값들이 32개의 라운드 key로 확장되어 암호화 과정에 사용됩니다. 결국 입력값은 그냥 입력값이 아닌, 내부 암호화에서 key 역할을 하는 것을 알 수 있습니다.

이제 SBOXES를 구해야 합니다.

 

def round_core(right, subkey, sboxes):
    return rotl32(sbox_layer((right + subkey) & MASK32, sboxes), 11)

 

SBOXES를 구하기 위해서는 1번 메뉴에서 호출하는 round_core 함수를 사용하면 됩니다.

사용자가 right 입력 값을 0으로 주면 round_core(0, subkey, sboxes)가 되며 결과는 rotl32(sbox_layer(subkey, sboxes), 11)이 됩니다. 회전은 단순 비트 재배치이기에, 출력값을 다시 11비트 오른쪽 rotate를 수행하면 sbox_layer(subkey, sboxes) 결과를 구할 수 있습니다.

 

def sbox_layer(x, sboxes):
    y = 0
    for i in range(8):
        nib = (x >> (4 * i)) & 0xF
        y |= (sboxes[i][nib] & 0xF) << (4 * i)
    return y & MASK32

 

sbox_layer 함수는 32비트 값을 4비트 씩 8개로 나눠 각 위치마다 하나의 S-box를 적용합니다.

가능한 입력은 0~15로 총 16개이며, 총 8개의 S-box가 있으므로 총 128개를 알아내야 하지만 입력 횟수가 128번입니다.

 

만약 right를 0으로 두고 subkey도 0으로 둔다면, x는 0이기에 모든 S-box에 0이 들어간 결과를 구할 수 있습니다.

그래서 7개의 입력 횟수를 아껴 총 121번의 입력으로 S-box를 구할 수 있습니다.

이후 subkey를 0x1, 0x2....0xf로 입력하여 첫 번째 S-box를 구할 수 있고 0x10, 0x20...0xf0으로 두 번째 S-box를

구할 수 있습니다. 이를 반복해 8개의 S-box를 구하면 됩니다.

 

문제는 세션마다 SBOXES와 iv가 달라지기에, SBOXES를 구하고, SBOXES와 iv로 메시지 충돌을 계산한 뒤, submit까지 해야 합니다.

 

#!/usr/bin/env python3
import re
import subprocess
from pwn import remote, context
import z3

HOST = "43.200.71.14"
PORT = 13479

context.log_level = "error"

MASK32 = 0xffffffff

def rotl32(x, r):
    x &= MASK32
    return ((x << r) & MASK32) | (x >> (32 - r))

def rotr32(x, r):
    x &= MASK32
    return ((x >> r) | ((x << (32 - r)) & MASK32)) & MASK32

def swap64(x):
    return ((x & MASK32) << 32) | ((x >> 32) & MASK32)

def solve_pow(chal: str) -> str:
    out = subprocess.check_output(
        ["python3", "kctf_pow.py", "solve", chal],
        text=True
    )
    lines = [x.strip() for x in out.splitlines() if x.strip()]
    sol = lines[-1]
    if not sol.startswith("s."):
        raise RuntimeError(f"bad pow output: {out!r}")
    return sol

def build_queries():
    vals = [0]
    for i in range(8):
        for v in range(1, 16):
            vals.append(v << (4 * i))
    return vals

def recover_sboxes(core_map):
    base = rotr32(core_map[0], 11)

    sboxes = [[0] * 16 for _ in range(8)]
    for i in range(8):
        sboxes[i][0] = (base >> (4 * i)) & 0xF

    for i in range(8):
        for v in range(1, 16):
            x = v << (4 * i)
            y = rotr32(core_map[x], 11)
            sboxes[i][v] = (y >> (4 * i)) & 0xF
    return sboxes

def z3_sbox_layer(x, sboxes):
    y = z3.BitVecVal(0, 32)
    for i in range(8):
        nib = z3.Extract(4 * i + 3, 4 * i, x)
        expr = z3.BitVecVal(sboxes[i][0], 4)
        for v in range(1, 16):
            expr = z3.If(nib == z3.BitVecVal(v, 4), z3.BitVecVal(sboxes[i][v], 4), expr)
        y = y | (z3.ZeroExt(28, expr) << (4 * i))
    return y

def z3_round_core(right, subkey, sboxes):
    return z3.RotateLeft(z3_sbox_layer(right + subkey, sboxes), 11)

def z3_U(block: int, keys, sboxes):
    L = z3.BitVecVal((block >> 32) & MASK32, 32)
    R = z3.BitVecVal(block & MASK32, 32)
    for k in keys:
        newL = R
        newR = L ^ z3_round_core(R, k, sboxes)
        L, R = newL, newR
    return z3.Concat(L, R)

def solve_one(iv: int, sboxes, block_first_word=None, seed=1, timeout_ms=30000):
    keys = [z3.BitVec(f"k{i}_{seed}", 32) for i in range(8)]
    s = z3.Solver()
    s.set(timeout=timeout_ms)
    s.set(random_seed=seed)

    siv = swap64(iv)

    s.add(z3_U(iv, keys, sboxes) == z3.BitVecVal(siv, 64))
    s.add(z3_U(siv, keys, sboxes) == z3.BitVecVal(iv, 64))

    if block_first_word is not None:
        s.add(keys[0] != z3.BitVecVal(block_first_word, 32))

    if s.check() != z3.sat:
        return None

    m = s.model()
    return [m[k].as_long() for k in keys]

def words_to_hex(words):
    return "".join(f"{w:08x}" for w in words)

def main():
    vals = build_queries()

    io = remote(HOST, PORT, timeout=15)

    banner = io.recvuntil(b"Solution? ").decode(errors="ignore")
    m = re.search(r"solve\s+(s\.[A-Za-z0-9+/=._-]+)", banner)
    if not m:
        raise RuntimeError("pow challenge not found")
    chal = m.group(1)

    sol = solve_pow(chal)
    io.sendline(sol.encode())

    text = io.recvuntil(b"> ").decode(errors="ignore")
    print(text)

    m_iv = re.search(r"IV\s*=\s*([0-9a-fA-F]{16})", text)
    if not m_iv:
        raise RuntimeError("IV not found")
    iv = int(m_iv.group(1), 16)
    print(f"[+] IV = {iv:016x}")

    payload = b""
    for x in vals:
        payload += b"1\n"
        payload += b"0\n"
        payload += f"{x:x}\n".encode()

    io.send(payload)

    cores = []
    buf = b""
    while len(cores) < 121:
        chunk = io.recv(timeout=10)
        if not chunk:
            raise RuntimeError("connection closed while reading cores")
        buf += chunk
        found = re.findall(rb"core\s*=\s*([0-9a-fA-F]{8})", buf)
        if found:
            cores = [int(x, 16) for x in found]

    if len(cores) < 121:
        raise RuntimeError(f"not enough cores: {len(cores)}")

    cores = cores[:121]
    core_map = {x: c for x, c in zip(vals, cores)}
    sboxes = recover_sboxes(core_map)

    print("[+] SBOXES recovered")
    for i, box in enumerate(sboxes):
        print(f"SBOX[{i}] = {box}")

    msg1_words = solve_one(iv, sboxes, block_first_word=None, seed=1)
    if msg1_words is None:
        raise RuntimeError("failed to find first solution")

    msg2_words = None
    for seed in range(2, 50):
        cand = solve_one(iv, sboxes, block_first_word=msg1_words[0], seed=seed)
        if cand is not None and cand != msg1_words:
            msg2_words = cand
            break

    if msg2_words is None:
        raise RuntimeError("failed to find second distinct solution")

    m1 = words_to_hex(msg1_words)
    m2 = words_to_hex(msg2_words)

    print(f"[+] m1 = {m1}")
    print(f"[+] m2 = {m2}")

    if b"> " not in buf:
        io.recvuntil(b"> ")

    io.sendline(b"2")
    io.recvuntil(b"m1 > ")
    io.sendline(m1.encode())
    io.recvuntil(b"m2 > ")
    io.sendline(m2.encode())

    print(io.recvall(timeout=5).decode(errors="ignore"))

if __name__ == "__main__":
    main()

 

다음과 같이 익스를 작성했습니다.

해당 익스는 먼저 서버가 출력한 iv를 읽고, right=0으로 고정한 상태에서 subkey 값을 증가시키며 query 메뉴에 입력합니다. 이 과정을 통해 core 값을 수집하고, 수집한 core 값들을 각각 11비트 오른쪽 rotate를 적용하여 SBOXES를 구합니다. 

SBOXES를 구한 뒤, 해당 세션의 iv와 SBOXES를 사용하여 서버와 같은 dm_compress를 구현할 수 있습니다.

이후 z3를 이용해 조건을 만족하는 8개의 32비트 워드를 계산합니다. z3는 단순히 임의의 값을 구하는 것이 아닌, 현재 세션의 iv에 대해 동일한 압축 결과가 나오는 두 개의 서로 다른 입력값을 구해줍니다.

z3를 통해 구한 2개의 256비트 입력값을 submit 메뉴에 입력하여 flag를 획득할 수 있습니다.