CTF 리버싱 챌린지에서 `rand()`와 `srand()` 의사 난수 생성기 분석

CTF(Capture The Flag) 리버싱 챌린지에서 C/C++ 기반 프로그램의 동작을 분석할 때, 표준 라이브러리의 의사 난수 생성기(PRNG)인 rand()srand() 함수의 작동 방식을 이해하는 것은 매우 중요합니다. 특히 난수가 암호화 로직에 사용될 경우, 정확한 난수 시퀀스를 예측하거나 추출해야만 데이터를 복호화할 수 있습니다. 이 글에서는 특정 CTF 챌린지 바이너리를 분석하여 난수 생성 로직을 역추적하고, 이를 통해 암호화된 데이터를 복호화하는 두 가지 방법을 소개합니다.

챌린지 개요 및 바이너리 분석 (IDA Pro)

분석 대상 바이너리의 main 함수는 사용자 입력 처리, 특정 조건 확인, 그리고 암호화 로직을 포함합니다. 핵심 암호화 부분은 미리 정의된 key(암호문) 배열의 각 바이트를 동적으로 생성된 난수와 XOR 연산하는 방식입니다. 성공적인 플래그 획득을 위해서는 이 암호화 과정을 역으로 수행하여 원본 평문을 찾아야 합니다.

주요 루프는 dword_1B336C와 같은 카운터 변수에 의해 제어되며, 특정 조건(예: dword_1B336C == 43)이 충족될 때까지 실행됩니다. 이는 총 44바이트의 데이터가 처리됨을 의미합니다.

암호문 역할을 하는 key 배열은 다음과 같습니다:

0x3E, 0xCD, 0xAA, 0x8E, 0x96, 0x1F, 0x89, 0xCD, 0xDB, 0xF1,
0x70, 0xF2, 0xA9, 0x9C, 0xC2, 0x8B, 0xF2, 0xFE, 0xAD, 0x8B,
0x58, 0x7C, 0x2F, 0x03, 0x4A, 0x65, 0x31, 0x89, 0x76, 0x57,
0x88, 0xDF, 0xB8, 0xE9, 0x01, 0xE9, 0xDE, 0xE5, 0x86, 0x68,
0x8F, 0x24, 0xD3, 0x5A

방법 1: 난수 생성 로직 재구성 (C 언어)

바이너리를 동적으로 디버깅하여 rand()srand() 함수의 호출 패턴을 분석하면 특정 시퀀스가 반복됨을 발견할 수 있습니다. 이 챌린지에서는 각 XOR 연산 직전에 다음과 같은 독특한 난수 생성 패턴이 나타납니다.

  1. 첫 번째 rand() 호출로 난수 R1 생성.
  2. srand(R1) 호출로 시드 설정.
  3. 두 번째 rand() 호출로 난수 R2 생성.
  4. srand(R2) 호출로 시드 설정.
  5. 세 번째 rand() 호출로 난수 R3 생성. 이 R3의 하위 1바이트가 암호문 바이트와 XOR 연산에 사용됩니다.

즉, 각 암호문 바이트를 복호화하기 위해 새로운 난수가 필요할 때마다 위 5단계 과정이 반복됩니다. 이 패턴을 정확히 재현하는 C 코드를 작성하여 암호문을 복호화할 수 있습니다.

예제 코드: solve_prng_logic.c

#include <stdio.h>
#include <stdlib.h> // rand(), srand() 함수를 위해 필요

// XOR 연산에 사용될 특정 패턴의 난수 바이트를 생성하는 함수
unsigned char generate_xor_byte() {
    // 분석된 바이너리의 rand/srand 호출 시퀀스를 모방합니다.
    unsigned int random_val_1 = rand(); // 첫 번째 난수 생성
    srand(random_val_1);                // 첫 번째 난수로 시드 설정
    unsigned int random_val_2 = rand(); // 두 번째 난수 생성
    srand(random_val_2);                // 두 번째 난수로 시드 설정
    unsigned int random_val_3 = rand(); // 세 번째 난수 생성 (XOR에 사용될 난수)
    return (unsigned char)random_val_3; // 하위 바이트만 관련
}

int main() {
    unsigned char encrypted_bytes[] = {
        0x3E, 0xCD, 0xAA, 0x8E, 0x96, 0x1F, 0x89, 0xCD, 0xDB, 0xF1,
        0x70, 0xF2, 0xA9, 0x9C, 0xC2, 0x8B, 0xF2, 0xFE, 0xAD, 0x8B,
        0x58, 0x7C, 0x2F, 0x03, 0x4A, 0x65, 0x31, 0x89, 0x76, 0x57,
        0x88, 0xDF, 0xB8, 0xE9, 0x01, 0xE9, 0xDE, 0xE5, 0x86, 0x68,
        0x8F, 0x24, 0xD3, 0x5A
    };

    int data_length = sizeof(encrypted_bytes) / sizeof(encrypted_bytes[0]);

    for (int i = 0; i < data_length; ++i) {
        unsigned char current_xor_value = generate_xor_byte();
        unsigned char decrypted_char = encrypted_bytes[i] ^ current_xor_value;
        printf("%c", decrypted_char);
    }
    printf("\n"); // 출력의 가독성을 위해 개행 추가
    return 0;
}

방법 2: 동적 분석을 통한 난수 값 직접 추출 (Python)

때로는 난수 생성 로직이 너무 복잡하거나 명확하게 파악하기 어려울 수 있습니다. 이 경우, 디버거를 사용하여 각 XOR 연산 직전에 사용되는 실제 난수 값들을 직접 추출하는 것이 더 효율적일 수 있습니다. 바이너리 실행 중 XOR 연산이 발생하는 지점에 브레이크포인트를 설정하고, 해당 순간 레지스터(예: EAX의 하위 1바이트)에 저장된 난수 값을 기록합니다. 이 과정을 모든 암호문 바이트에 대해 반복하여 필요한 난수 시퀀스를 얻습니다.

다음은 디버깅을 통해 추출된 44개의 난수 값 시퀀스입니다:

0x58, 0xA1, 0xCB, 0xE9, 0xED, 0x2C, 0xEC, 0xFB, 0xE9, 0xC4,
0x16, 0x97, 0x99, 0xB1, 0xA4, 0xE9, 0xC3, 0xC6, 0x80, 0xBF,
0x3E, 0x44, 0x18, 0x2E, 0x73, 0x56, 0x52, 0xB8, 0x5B, 0x66,
0xED, 0xBC, 0x8A, 0xD8, 0x36, 0x8F, 0xE6, 0xD3, 0xB1, 0x51,
0xB9, 0x59, 0xD3, 0x5A

이 추출된 난수 시퀀스를 사용하여 Python 스크립트로 암호문을 쉽게 복호화할 수 있습니다.

예제 코드: solve_captured_rand.py

# 동적 분석을 통해 추출된 난수 값 시퀀스
captured_random_sequence = [
    0x58, 0xA1, 0xCB, 0xE9, 0xED, 0x2C, 0xEC, 0xFB, 0xE9, 0xC4,
    0x16, 0x97, 0x99, 0xB1, 0xA4, 0xE9, 0xC3, 0xC6, 0x80, 0xBF,
    0x3E, 0x44, 0x18, 0x2E, 0x73, 0x56, 0x52, 0xB8, 0x5B, 0x66,
    0xED, 0xBC, 0x8A, 0xD8, 0x36, 0x8F, 0xE6, 0xD3, 0xB1, 0x51,
    0xB9, 0x59, 0xD3, 0x5A
]

# 암호문 (key 배열)
cipher_text = [
    0x3E, 0xCD, 0xAA, 0x8E, 0x96, 0x1F, 0x89, 0xCD, 0xDB, 0xF1,
    0x70, 0xF2, 0xA9, 0x9C, 0xC2, 0x8B, 0xF2, 0xFE, 0xAD, 0x8B,
    0x58, 0x7C, 0x2F, 0x03, 0x4A, 0x65, 0x31, 0x89, 0x76, 0x57,
    0x88, 0xDF, 0xB8, 0xE9, 0x01, 0xE9, 0xDE, 0xE5, 0x86, 0x68,
    0x8F, 0x24, 0xD3, 0x5A
]

decrypted_message = []
for i in range(len(cipher_text)):
    decrypted_byte = cipher_text[i] ^ captured_random_sequence[i]
    decrypted_message.append(chr(decrypted_byte))

print("".join(decrypted_message))

결과

위 두 가지 방법 중 하나를 실행하면 다음 플래그를 얻을 수 있습니다:

flag{3e625fe0-fb18-4f87-93c1-1ec217f86796}

태그: CTF Reversing rand srand PRNG

7월 22일 18:00에 게시됨