Whitehat Conference 2024 Writeup

malware, prepare_attack

2024년 11월 24일 개최된 Whitehat Conference 2024 국방트랙에 싸축 C 팀으로 참여, 간발의 차로 4위로 마무리하며 수상에는 아쉽게 실패하였다. 동일 점수면 공동 3위로 처리해줘야 한다고 생각해요.

image.png

여담인데 싸축은 학부시절 우리 과 축구 동아리 이름이다. 안그래도 작은 과 인원 중 약 10%에 달하는 11명+a를 차출해야 하다보니, 싸축은 창단 이래 교내 공식 대회에서 단 1승도 거둔 적이 없다. 그런 와중에 다른 동아리들은 오히려 인원이 넘쳐, 총 2군을 편성하여 출전하곤 했다. 이 때 보통 팀 이름에 A가 붙으면 1군, B가 붙으면 2군이다. 아축 A, 아축 B 뭐 이런 느낌으로…

우린 매년 아무개 B에게 패배한 후 우린 싸축 C라고, 싸축 A가 나왔다면 너흰 죽도 못 쑬 것이라고 소곤소곤 자기 위안하곤 했다. 이번에도 싸축 C가 아닌 B였다면 AESpa쯤은

아무튼…

Problem Level Type Remarks
malware -
Reversing
RC4 /
prepare_attack -
Reversing
Golang / Salsa20

Level(1-10) : Score / Highest Score


malware

무한루프가 동작하는 malware가 존재하는데 이를 멈출 수 있는 key를 찾으면 되는 문제이다.


기본 동작

sub_1B74()return 값이 True가 되도록 하면 문제 조건을 성립시킬 수 있다.

__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  pthread_t newthread[2]; // [rsp+0h] [rbp-10h] BYREF

  newthread[1] = __readfsqword(0x28u);
  dword_5010 = sub_158D(a1, a2, a3);
  if ( dword_5010 == -1 )
    return 1LL;
  pthread_create(newthread, 0LL, start_routine, 0LL);
  while ( !dword_5018 && !(unsigned int)sub_1B74() )
  {
    sub_1D3A();
    sleep(0xAu);
  }
  dword_5018 = 1;
  pthread_join(newthread[0], 0LL);
  return 0LL;
}


sub_1B74()

v3, 사용자 입력값 /tmp/stop을 통해 연산한 값이 s와 동일하도록 하는 입력값 /tmp/stop을 찾으면 된다.

_BOOL8 sub_1B74()
{
  int i; // [rsp+4h] [rbp-84Ch]
  FILE *stream; // [rsp+8h] [rbp-848h]
  char v3[48]; // [rsp+10h] [rbp-840h] BYREF
  char s[1024]; // [rsp+40h] [rbp-810h] BYREF
  char s2[1032]; // [rsp+440h] [rbp-410h] BYREF
  unsigned __int64 v6; // [rsp+848h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  qmemcpy(s2, &unk_3088, 0x400uLL);
  strcpy(v3, "1a5c4c610811f8b45fb9bcb452123621");
  srand(0);
  stream = fopen("/tmp/stop", "r");
  if ( !stream )
    return 0LL;
  memset(s, 0, sizeof(s));
  fgets(s, 1024, stream);
  fclose(stream);
  for ( i = 0; i <= 1023; ++i )
    s[i] ^= rand();
  sub_18C5(v3, s, 1024LL);
  return memcmp(s, s2, 0x400uLL) == 0;
}


위 연산 중 마지막에 수행하는 sub_18C5의 경우 RC4 알고리즘임을 알 수 있다.

__int64 __fastcall sub_18C5(const char *a1, __int64 a2, int a3)
{
  ...
  v17 = __readfsqword(0x28u);
  v11 = 0;
  for ( i = 0; i <= 255; ++i ) // S-Box 초기화
    v16[i] = i;
  for ( j = 0; j <= 255; ++j ) // Key-Scheduling Algorithm (KSA)
  {
    v3 = v16[j] + v11;
    v4 = strlen(a1);
    v5 = v3 + a1[j % v4];
    LODWORD(v4) = (unsigned int)((v3 + a1[j % v4]) >> 31) >> 24;
    v11 = (unsigned __int8)(v4 + v5) - (_DWORD)v4;
    v15 = v16[j];
    v16[j] = v16[v11];
    v16[v11] = v15;
  }
  v12 = 0;
  v10 = 0;
  for ( k = 0; k < a3; ++k ) //Pseudo-Random Generation Algorithm (PRGA)
  {
    v10 = (v10 + 1) % 256;
    v12 = (v12 + v16[v10]) % 256;
    v14 = v16[v10];
    v16[v10] = v16[v12];
    v16[v12] = v14;
    *(_BYTE *)(k + a2) ^= v16[(v16[v12] + v16[v10]) % 256];
  }
  return 1LL;
}


PoC

아래 PoC를 통해 sub_18C5(RC4)의 역연산 결과를 알 수 있다. 즉, s[i] ^= rand();의 결과 result.dump를 알 수 있다.

문제에서 srand(0);에 의하여 동일한 시퀀스의 난수가 생성되므로, 문제 파일을 재실행하여 아래 result.dump/tmp/stop으로 사용하고, s[i] ^= rand();의 결과 s를 보면 구하고자 했던 입력값을 구할 수 있다. 혹은 rand() 값을 직접 생성하여 result.dumpxor 연산을 직접수행하여도 된다.

def reverse_keygen(key, modified_s, int_1024):
    # 초기화 단계 (256개 S-Box 초기화)
    key_len = len(key)
    v16 = list(range(256))
    v11 = 0

    # Key-Scheduling Algorithm (KSA)
    for j in range(256):
        v11 = (v11 + v16[j] + ord(key[j % key_len])) % 256
        v16[j], v16[v11] = v16[v11], v16[j]  # Swap

    # 복구를 위한 Pseudo-Random Generation Algorithm (PRGA)
    v12 = 0
    v10 = 0
    original_s = bytearray(modified_s)  # 연산 이후 s를 복제
    for k in range(int_1024):
        v10 = (v10 + 1) % 256
        v12 = (v12 + v16[v10]) % 256
        v16[v10], v16[v12] = v16[v12], v16[v10]
        key_stream = v16[(v16[v12] + v16[v10]) % 256]
        original_s[k] ^= key_stream 

    return original_s


if __name__=='__main__':
    key = "1a5c4c610811f8b45fb9bcb452123621"
    int_1024 = 1024
    # 암호화된 데이터 (연산 이후의 s)
    with open("s2.dump","rb") as f:
        modified_s = f.read(0x400)

    # 연산 이전의 s 복구
    original_s = reverse_keygen(key, modified_s, len(modified_s))
    #print(original_s)
    with open ("result.dump","wb") as f:
        f.write(original_s)


prepare_attack

실행 시 임의 포트를 함께 파라미터로 입력 받아, 해당 포트로 listen함과 동시에 무한 루프를 실행한다. 해당 포트로 무한 루프를 멈출 수 있는 적절한 명령(입력값)을 전달하면 flag를 얻을 수 있다. GoLang으로 작성되어서 분석하기에 다소 복잡한 면이 있다.


문제 분석

이 문제의 실질적 main 함수는 아래의 main_handleConn()이다. 최초 입력 시 입력값의 길이가 32가 아니면 Invalid key length를 출력하는데, 이 string을 기반으로 search해보면 쉽게 메인 로직을 찾을 수 있다.

기본적인 동작 순서는 이렇다.

  1. main_NewEncSocket
  2. main__ptr_EncSocket_Read
  3. runtime_cmpstring / main_get_flag / main__ptr_EncSocket_Write
// main.handleConn
// local variable allocation has failed, the output may be wrong!
__int64 __golang main_handleConn(
...
  result_1 = (_ptr_main_EncSocket)main_NewEncSocket(a1, a2, (int)v52, a4, a5, a6, a7, a8, a9);
  v10 = (main_EncSocket *)runtime_duffzero(v44);
  v61.ptr = (uint8 *)v44;
  v61.len = 1024LL;
  v61.cap = 1024LL;
  result_2 = main__ptr_EncSocket_Read(v10, v61);
  ...
  v48 = runtime_slicebytetostring(0LL, (unsigned __int8 *)v44, result_2.0, 1024LL, a5, v11, v12, v13, v14);
  v46 = v44;
  v17 = (_slice_uint8 *)&unk_4;
  if ( !runtime_cmpstring(v48, (__int64)v44, (__int64)"exit", 4LL) )
  {
    flag = main_get_flag(0LL, (__int64)v44, v18, 4LL, a5, v19, v20, v21, v22);
    v24 = &unk_6428A0;
    if ( flag )
      v24 = (void *)flag;
    v17 = v44;
    v25 = v24;
    v26 = v44;
    v59 = main__ptr_EncSocket_Write(result_1, *(_slice_uint8 *)(&v17 - 2));
    ...
    os_Exit(0, 0, (int)v59.1.data, (int)v44, a5, v27, v28, v29, v30, (int)v40);
  }
  ...
}


main_NewEncSocket

사용자로부터 32 byte 입력을 받은 후, libcRand를 이용하여 keynonce를 생성하고, 이를 이용하여 사용자 입력을 salsa20으로 암호화하고 그 결과를 사용자에게 회신한다.

// main.NewEncSocket
char *__golang main_NewEncSocket(__int64 a1, main_libcRand *a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9)
{
  ...
  user_input = runtime_makeslice((unsigned __int64 *)&RTYPE_uint8, 32LL, 32LL, a4, a5, a6, a7, a8, a9);
  ...
  if ( len_input != 32 )
  {
    *(_QWORD *)&v47 = &RTYPE_string;
    *((_QWORD *)&v47 + 1) = &Invalid_key_length_str;
    sub_4B4AE0((int)&v47, 1, 1, 32, (int)v10, v13, v14, v15, v16);
  }
  v44 = ((__int64 (__golang *)(RTYPE **))(*off_61C408)[3])(off_61C408[1]);
  p_main_libcRand = (main_libcRand *)runtime_newobject(&RTYPE_main_libcRand);
  p_main_libcRand->seed = (int)(v44 >> 33) % 256;
  for ( i = 0LL; i < 32; ++i )
  {
    v23 = 1103515245 * p_main_libcRand->seed + 12345;
    v10 = (__int64 (__golang *)(main_libcRand *, _OWORD *, __int64, __int64))(i + 1);
    p_main_libcRand->seed = v23;
    *((_BYTE *)&key + i) = v23;
  }
  for ( j = 0LL; j < 8; ++j )
  {
    v25 = 1103515245 * p_main_libcRand->seed + 12345;
    v10 = (__int64 (__golang *)(main_libcRand *, _OWORD *, __int64, __int64))(j + 1);
    p_main_libcRand->seed = v25;
    *((_BYTE *)&nonce + j) = v25;
  }
  v45 = p_main_libcRand;
  encrypted_input_maybe = main_salsa20Encrypt(
                            (__int64)user_input,
                            32LL,
                            32LL,
                            32LL,
                            (__int64)v10,
                            v18,
                            v19,
                            v20,
                            v21,
                            key,
                            v42,
                            nonce);
  send_1 = *(void (__golang **)(main_libcRand *, __int64, __int64, __int64))(a1 + 80);
  v29 = v28;
  v30 = encrypted_input_maybe;
  send_1(a2, encrypted_input_maybe, 32LL, v28); // send1
  ...
  *(_QWORD *)result = v37;
  *((_QWORD *)result + 1) = a1;
  *((_QWORD *)result + 2) = v38;
  *(_OWORD *)(result + 24) = key;
  *(_OWORD *)(result + 40) = v42;
  return result;
}


main__ptr_EncSocket_Read

main_NewEncSocket과 비슷하게 사용자 입력을 salsa20으로 암호화한다. 여기서 주목할 부분은, keymain_NewEncSocket에서 사용한 것을 재활용하고, nonce의 경우 이전에 사용하던 LCG(libCRand)를 이어서 활용하여 랜덤 생성한다는 점이다.

// main.(*EncSocket).Read
// local variable allocation has failed, the output may be wrong!
retval_506540 __golang main__ptr_EncSocket_Read(_ptr_main_EncSocket a1, _slice_uint8 a2)
{
  ...
  len = a2.len;
  ptr = a2.ptr;
  v24 = (void *)runtime_makeslice((unsigned int)&RTYPE_uint8, a2.len, a2.len, a2.cap, v2, v3, v4, v5, v6);
  v7 = v24;
  rand_func = (_ptr_main_libcRand)len;
  v9 = (*((__int64 (__golang **)(void *, void *, __int64, __int64))a1->conn.tab + 5))(a1->conn.data, v24, len, len);
  if ( v24 )
  {
    v16 = 0LL;
  }
  else
  {
    nonce2 = 0LL;
    for ( i = 0LL; i < 8; ++i )
    {
      rand_func = a1->rand_func;
      v11 = 1103515245 * a1->rand_func->seed + 12345;
      v12 = i + 1;
      a1->rand_func->seed = v11;
      *((_BYTE *)&nonce2 + i) = v11;
    }
    if ( v9 > len )
      runtime_panicSliceAcap(v9, 0LL, v9);
    v23 = v9;
    key = *(_OWORD *)a1->key;
    v22 = *(_OWORD *)&a1->key[16];
    v17 = v9;
    v18 = main_salsa20Encrypt((__int64)v24, v9, len, (__int64)rand_func, nonce2, v11, v12, v13, v14, key, v22, nonce2);
    ...
  }
  result.1.tab = v7;
  result.1.data = v10;
  result.0 = v16;
  return result;
}


runtime_cmpstring / main_get_flag / main__ptr_EncSocket_Write

앞서 두번째 사용자 입력값을 암호화한 값이 만약에 exit 라면 flag를 읽어온다. main__ptr_EncSocket_Write 함수를 통해 flag 값을 salsa20 암호화하여 사용자에게 전송하고, 무한 루프가 종료된다. 여기에서도 key는 그대로 재사용하고, nonce는 이전에 사용하던 LCG(libCRand)를 이어서 활용하여 랜덤 생성한다.

// main.(*EncSocket).Write
// local variable allocation has failed, the output may be wrong!
retval_506540 __golang main__ptr_EncSocket_Write(_ptr_main_EncSocket a1, _slice_uint8 a2)
{
...
  for ( i = 0LL; i < 8; ++i )
  {
    rand_func = a1->rand_func;
    v3 = 1103515245 * a1->rand_func->seed + 12345;
    v4 = i + 1;
    a1->rand_func->seed = v3;
    *((_BYTE *)&v17 + i) = v3;
  }
  key2 = *(_OWORD *)a1->key;
  v19 = *(_OWORD *)&a1->key[16];
  ptr = a2.ptr;
  len = a2.len;
  v10 = main_salsa20Encrypt((__int64)ptr, a2.len, a2.cap, a2.cap, (__int64)rand_func, v3, v4, v5, v6, key2, v19, v17);
  v12 = v11;
  v13 = len;
  v14 = (void *)v10;
  v15 = (*((__int64 (__golang **)(void *, __int64, size_t, __int64))a1->conn.tab + 10))(a1->conn.data, v10, v13, v12);
  result.1.tab = v14;
  result.1.data = v16;
  result.0 = v15;
  return result;
}


PoC

처음 main_NewEncSocket에서 사용자의 입력값에 대한 salsa20 암호화를 수행하고 암호문을 사용자에게 돌려준다. 이 때 시드 값이 p_main_libcRand->seed = (int)(v44 >> 33) % 256;로 정의되므로 0부터 255 사이의 값만을 가진다. 때문에 입력 평문과 주어진 암호문 시드 값과 이를 이용하여 생성한 keynonce를 구할 수 있다.

def get_key_nonce(plain,cipher):
    key= bytearray()
    nonce = bytearray()
    for seed in range(256):
        rand_gen = LibcRand(seed)
        for _ in range(32):
            key.append(rand_gen.next() & 0xFF)  # 하위 8비트 사용
        for _ in range(8):  # 8번 반복
            nonce.append(rand_gen.next() & 0xFF)  # 하위 8비트 사용
        salsa = Salsa20.new(key=key, nonce=nonce)
        cipher_test = salsa.encrypt(plain)
        if cipher_test == cipher:
            #print(f"seed found : {seed}")
            break
        else:
            key= bytearray()
            nonce = bytearray()
    return rand_gen,key,nonce


keyrand_genmain_NewEncSocket에서 재활용하는 것을 확인하였으므로 이를 이용하여 암호화시 exit이 되는 사용자 입력값을 찾을 수 있고, 그 값을 송신하면 무한루프를 탈출하고 flag를 수신할 수 있다.

def make_exit(rand_gen,key,nonce):
    nonce2 = bytearray()
    for _ in range(8):  # 8번 반복
        nonce2.append(rand_gen.next() & 0xFF)  # 하위 8비트 사용
    salsa = Salsa20.new(key=key, nonce=nonce2)
    #print(nonce2)
    exit_command = salsa.decrypt(b"exit")
    return exit_command
...
exit_command = make_exit(rand_gen,key,nonce)
conn.send(exit_command)
encrypted_flag = conn.recv(1024)


flag 또한 동일한 방식으로 salsa20 복호화하면 평문 값을 얻을 수 있다.

image.png


부록(PoC.py)

from pwn import *
from Crypto.Cipher import Salsa20

class LibcRand:
    def __init__(self, seed):
        self.seed = seed & 0xFFFFFFFF

    def next(self): #LCG
        self.seed = (1103515245 * self.seed + 12345) & 0xFFFFFFFF
        return self.seed

def make_exit(rand_gen,key,nonce):
    nonce2 = bytearray()
    for _ in range(8):  # 8번 반복
        nonce2.append(rand_gen.next() & 0xFF)  # 하위 8비트 사용
    salsa = Salsa20.new(key=key, nonce=nonce2)
    #print(nonce2)
    exit_command = salsa.decrypt(b"exit")
    return exit_command

def get_key_nonce(plain,cipher):
    key= bytearray()
    nonce = bytearray()
    for seed in range(256):
        rand_gen = LibcRand(seed)
        for _ in range(32):
            key.append(rand_gen.next() & 0xFF)  # 하위 8비트 사용
        for _ in range(8):  # 8번 반복
            nonce.append(rand_gen.next() & 0xFF)  # 하위 8비트 사용
        salsa = Salsa20.new(key=key, nonce=nonce)
        cipher_test = salsa.encrypt(plain)
        if cipher_test == cipher:
            #print(f"seed found : {seed}")
            break
        else:
            key= bytearray()
            nonce = bytearray()
    return rand_gen,key,nonce

def main_NewEncSocket(r,user_input):
    r.send(user_input)
    response = r.recv(1024)
    return response
    
if __name__=='__main__':
    host = localhost
    port = 7777 #임의 포트
    conn = remote(host,port)

    initial_input = b"01234567890123456789012345678901" # length:32
    initial_response = main_NewEncSocket(conn,initial_input)
    rand_gen,key,nonce = get_key_nonce(initial_input,initial_response)
    
    exit_command = make_exit(rand_gen,key,nonce)
    conn.send(exit_command)
    encrypted_flag = conn.recv(1024)
    
    nonce3 = bytearray()
    for _ in range(8):  # 8번 반복
        nonce3.append(rand_gen.next() & 0xFF)  # 하위 8비트 사용
    salsa = Salsa20.new(key=key, nonce=nonce3)
    flag = salsa.decrypt(encrypted_flag)
    print(flag)