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 연산 직전에 다음과 같은 독특한 난수 생성 패턴이 나타납니다.
- 첫 번째
rand()호출로 난수R1생성. srand(R1)호출로 시드 설정.- 두 번째
rand()호출로 난수R2생성. srand(R2)호출로 시드 설정.- 세 번째
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}