Link
Notice
Recent Posts
Recent Comments
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Archives
Today
Total
관리 메뉴

hiariz 님의 블로그

[Dice CTF 2026] bytecrusher 본문

CTF, Wargame

[Dice CTF 2026] bytecrusher

hiariz 2026. 3. 11. 10:49
 

https://ctf.dicega.ng/challenges?challenge=pwn_bytecrusher

 

ctf.dicega.ng

 

이 문제는 두 개의 버그가 자연스럽게 이어지는 전형적인 pwn 문제였다.

  • free_trial()에서 crush_string()를 잘못 사용해서 스택 바깥 읽기(OOB read)가 가능하다.
  • get_feedback()에서 gets()를 사용해서 스택 오버플로우가 가능하다.

문제 시작

문제 설명은 "문자열을 crush 해주는 서비스" 정도의 컨셉이고, 서버에 접속하면 16번의 무료 trial을 준다.

실제로 소스를 보면 구조는 꽤 단순하다.

void free_trial() {
    char input_buf[32];
    char crushed[32];

    for (int i=0; i<16; i++) {
        printf("Trial %d/16:\n", i+1);
        printf("Enter a string to crush:\n");
        fgets(input_buf, sizeof(input_buf), stdin);

        printf("Enter crush rate:\n");
        int rate;
        scanf("%d", &rate);

        printf("Enter output length:\n");
        int output_len;
        scanf("%d", &output_len);

        int c;
        while ((c = getchar()) != '\n' && c != EOF);

        crush_string(input_buf, crushed, rate, output_len);

        printf("Crushed string:\n");
        puts(crushed);
    }
}

void get_feedback() {
    char buf[16];
    printf("Enter some text:\n");
    gets(buf);
}

이 정도만 봐도 gets()가 바로 눈에 들어온다. 다만 바이너리에는 canary, PIE, NX가 걸려 있어서 그냥 덮는다고 끝나지는 않는다. 결국 먼저 leak가 필요하다.


보호 기법

checksec 결과는 다음과 같았다.

  • Full RELRO
  • Canary
  • NX
  • PIE

여기서 필요한 건 명확하다.

  1. gets()를 쓰기 전에 canary를 알아내야 한다.
  2. PIE가 켜져 있으니 admin_portal() 절대주소도 leak가 필요하다.

즉, 이 문제는 "gets()가 있으니 BOF"로 끝나는 문제가 아니라, "앞단에서 leak를 만들 수 있는가"가 핵심이다.


crush_string()가 왜 위험한가

문제의 핵심은 이 함수다.

void crush_string(char *input, char *output, int rate, int output_max_len) {
    if (rate < 1) rate = 1;
    int out_idx = 0;
    for (int i = 0; input[i] != '\0' && out_idx < output_max_len - 1; i += rate) {
        output[out_idx++] = input[i];
    }
    output[out_idx] = '\0';
}

표면적으로는 입력 문자열에서 일정 간격으로 문자를 뽑아 새 문자열을 만드는 함수다. 문제는 i += rate로 이동하면서도 input의 범위를 전혀 검사하지 않는다는 점이다.

inputinput_buf를 가리키고 있고, input_buf 크기는 32바이트다. 그런데 rate를 73, 88 같은 큰 값으로 주면 input[i]는 바로 input_buf 밖의 스택 메모리를 읽게 된다.

결국 이 함수는 "문자열 crusher"가 아니라, "스택의 특정 오프셋을 하나씩 뽑아주는 oracle"이 된다.


스택 레이아웃 확인

이제 디스어셈블로 실제 스택 배치를 확인하면 된다.

free_trial() 기준으로 중요한 값은 다음과 같았다.

  • input_buf : rbp-0x50
  • crushed : rbp-0x30
  • canary : rbp-0x8
  • saved RIP : rbp+0x8

input_buf 시작점 기준 상대 오프셋으로 바꾸면:

  • canary 시작: 0x48 = 72
  • saved RIP 시작: 0x58 = 88

이 숫자가 바로 exploit의 중심이 된다.

예를 들어 rate=73이면:

  • 첫 번째 출력 바이트는 input[0]
  • 두 번째 출력 바이트는 input[73]

즉, 두 번째 출력 바이트가 canary의 두 번째 바이트가 된다.

같은 방식으로 rate=88이면 saved RIP의 첫 바이트를 읽을 수 있다.


16번 trial이 왜 중요한가

현재 배포된 바이너리는 16번의 trial을 준다.

이걸로 필요한 바이트를 전부 독립적으로 읽어낼 수 있다.

우리가 필요한 leak는:

  • canary 7바이트: 73, 74, 75, 76, 77, 78, 79
  • saved RIP 6바이트: 88, 89, 90, 91, 92, 93

합치면 총 13번이다. 16번 중 13번만 쓰면 충분하다.

여기서 canary가 8바이트인데 왜 7바이트만 leak하느냐가 포인트다. x86_64 stack canary의 첫 바이트는 항상 0x00이기 때문이다. 문자열 함수 관점에서는 이 바이트를 직접 출력하기 어렵다. 대신 나머지 7바이트를 leak한 뒤 앞에 0x00을 붙여 복원하면 된다.

남는 3번의 trial은 그냥 filler로 소비하면 된다.


PIE base 계산

이제 canary만이 아니라 PIE base도 알아내야 한다.

saved RIP는 free_trial()이 끝난 뒤 main()으로 돌아갈 주소다. 디스어셈블을 보면 free_trial() 호출 다음 위치가 main+0x15ec였다.

즉 leak한 RIP는 항상:

pie_base + 0x15ec

가 된다.

따라서 PIE base는 바로 계산된다.

pie_base = leaked_rip - 0x15ec

이제 심볼 오프셋만 더하면 원하는 함수 주소를 만들 수 있다.

중요한 오프셋은:

  • admin_portal = 0x12a9
  • 정렬용 ret = 0x101a

그래서 최종적으로:

admin_portal = pie_base + 0x12a9
ret = pie_base + 0x101a

를 구할 수 있다.


get_feedback() 오버플로우

앞부분에서 leak를 끝냈다면 이제 마무리는 쉽다.

void get_feedback() {
    char buf[16];
    printf("Enter some text:\n");
    gets(buf);
}

get_feedback()의 스택 레이아웃을 보면:

  • buf : rbp-0x20
  • canary : rbp-0x8

즉 페이로드 구조는 다음과 같다.

24 bytes padding
+ leaked canary
+ 8 bytes saved rbp filler
+ ret
+ admin_portal

정렬용 ret이 들어가는 이유도 중요하다. 로컬에서 바로 admin_portal()로 복귀시키면 문자열 출력이 불안정하게 동작했고, ret -> admin_portal 형태로 한 번 정렬을 맞춰주면 안정적으로 플래그가 출력됐다.

결국 이 문제의 마지막 단계는 ret2win에 가깝지만, 그 전에 leak를 얼마나 정확하게 만드느냐가 실질적인 난이도였다.


구현할 때 걸렸던 포인트

이 문제는 분석보다 파싱에서 한 번 더 신경을 써야 했다.

1. leak 바이트 안에 개행이 들어갈 수 있다

puts()로 출력하기 때문에 leak한 바이트가 0x0a면 중간에 줄이 끊긴다. 그래서 단순히 recvline()으로 받으면 leak가 잘려버릴 수 있다.

안전한 방법은 다음과 같다.

  1. Crushed string:\n까지 읽는다.
  2. 그 다음 trial 프롬프트 전체를 기준으로 읽는다.
  3. 프롬프트 직전까지를 leak 데이터로 간주한다.
  4. 마지막 puts() 개행 1바이트만 제거한다.

이렇게 하면 leak 바이트 자체가 줄바꿈이어도 파싱이 망가지지 않는다.

2. 0x000x0a 때문에 실패하는 연결이 있다

문자열 기반 leak와 gets() 기반 BOF를 같이 쓰다 보면 특정 연결은 그냥 버려야 한다.

  • 필요한 leak 바이트가 0x00이면 문자열이 중간에서 끊긴다.
  • 최종 payload 안에 0x0a가 들어가면 gets()가 거기서 입력을 끝내버린다.

그래서 스크립트는 실패 조건이면 재접속하도록 만들었다. 이건 구조적인 문제라서 회피가 아니라 재시도가 맞다.


익스플로잇 흐름 정리

실제 exploit의 전체 흐름은 아래와 같다.

  1. 16개 trial 중 13개를 사용해 canary 7바이트와 saved RIP 6바이트를 leak한다.
  2. leak한 RIP에서 0x15ec를 빼서 PIE base를 구한다.
  3. PIE base에 심볼 오프셋을 더해 ret, admin_portal 주소를 만든다.
  4. get_feedback()gets()에 canary를 포함한 ret2win payload를 넣는다.
  5. bad byte 때문에 실패하면 재접속한다.

문제 구조가 깔끔해서, leak만 정확히 잡히면 exploitation 자체는 거의 기계적으로 이어진다.


마무리

이 문제는 취약점 자체는 어렵지 않았지만, 두 버그를 연결하는 흐름이 예쁘게 설계되어 있었다.

  • 앞단에서는 OOB read로 정보 유출
  • 뒷단에서는 gets()로 제어권 획득

보호 기법이 다 켜져 있어서 "그냥 오버플로우"로는 풀리지 않고, stack frame을 정확히 읽어내는 과정이 필요했다는 점이 인상적이었다.

개인적으로는 현재 배포본 기준 16번 trial이 주어지는 덕분에, 브루트포스가 아니라 정석적인 leak + ret2win으로 깔끔하게 풀리는 점이 좋았다.

실제 플래그는 다음과 같다.

dice{pwn3d_4nd_coRuSh3d}

'CTF, Wargame' 카테고리의 다른 글

[Plaid CTF 2024] maglev  (0) 2026.03.16
[Dice CTF 2026] garden  (0) 2026.03.11
[Plaid CTF 2025] zerodeo (CVE-2025-32023)  (0) 2026.02.26
[Plaid CTF 2025] ocalc  (1) 2026.02.21
[Plaid CTF 2025] Bounty Board  (0) 2026.02.13