[🥈 Silver 4] free() 이전에 이미 끝난 취약점 — DreamHack hook 풀이 — ZINO
2026-07-06·1분 읽기·
[🥈 Silver 4] free() 이전에 이미 끝난 취약점 — DreamHack hook 풀이
malloc으로 받은 청크의 첫 16바이트를 그대로 주소·값으로 써버리는 한 줄이 문제의 전부다. free()가 두 번 불리지만 실제로는 그 이전 write-what-where 한 방으로 __free_hook을 one_gadget으로 덮고, 첫 free() 호출만으로 셸이 뜬다. double free도, fastbin dup도 필요 없다.
42줄짜리 C 파일인데 free(ptr)가 나란히 두 번 적혀 있다. 이름부터 "hook"이니 __free_hook을 노리는 문제라는 건 짐작이 가는데, 막상 소스를 읽어보면 double free 트릭을 쓸 필요조차 없다. 그 위에 있는 한 줄이 이미 임의 주소에 임의 값을 쓰는 문장이기 때문이다.
free() 이전에 실행되는 write-what-where로 __free_hook을 one_gadget으로 덮기
Full RELRO에 카나리까지 붙어 있지만 둘 다 이번 공격과는 상관없다. RELRO는 바이너리 자체의 GOT를 잠그는 거지 libc 내부 전역 변수까지 건드리진 못하고, 카나리는 스택 버퍼를 안 건드리는 이상 볼 일이 없다. No PIE라 바이너리 주소는 항상 고정이고, libc 주소는 프로그램이 알아서 유출해준다.
🧩 배경 — __free_hook과 one_gadget
glibc(2.29 이전 버전들)는 malloc()/free() 직전에 호출할 함수 포인터를 __malloc_hook, __free_hook이라는 전역 변수로 열어뒀다. 원래 용도는 디버깅용 커스텀 알로케이터를 끼워 넣는 것이었는데, 둘 다 그냥 쓰기 가능한 데이터 영역이라 이 포인터를 공격자가 원하는 주소로 덮어버리면 free(ptr) 한 번이 곧 (*__free_hook)(ptr, 리턴주소) 호출이 된다.
문제는 거기에 뭘 넣느냐다. system 주소를 넣으면 system(ptr)이 호출되는데, 인자로 넘어가는 ptr이 우리가 원하는 "/bin/sh" 문자열이어야 한다는 제약이 붙는다. 이 제약을 통째로 우회하는 게 one_gadget이다. libc 안에서 인자 없이 그 자리만 밟으면 execve("/bin/sh", ...)으로 바로 넘어가는 지점들을 미리 스캔해둔 도구인데, 대신 레지스터나 스택 몇 바이트가 특정 조건(대개 NULL)이어야 한다는 제약이 붙는다.
정리하면 필요한 건 두 가지뿐이다: __free_hook의 주소, 그리고 거기 넣을 one_gadget 주소. 둘 다 libc 베이스만 알면 오프셋 더하기로 끝난다.
stdout을 그대로 %p로 찍어주는 건 oneshot 같은 다른 문제에서도 본 익숙한 패턴이다. stdout은 libc 내부의 _IO_2_1_stdout_ 구조체를 가리키는 포인터라서, 이 값 하나로 ASLR이 가려둔 libc 베이스 전체가 뚫린다.
진짜 취약점은 그 아래, free(ptr)을 부르기 직전 한 줄이다.
*(long *)*ptr = *(ptr+1);
ptr은 방금 우리가 채운 malloc 청크다. 이 문장을 풀어 쓰면 "청크의 첫 8바이트를 주소로 읽고, 그 주소에 청크의 다음 8바이트를 값으로 써라"가 된다. 주소도 값도 전부 read(0, ptr, size)로 우리가 방금 넣은 데이터다 — 이미 그 자체로 임의 주소 쓰기(write-what-where) 다.
그 아래 free(ptr)이 두 번 이어지는 걸 보면 처음엔 double free / fastbin dup을 유도하는 문제로 보인다. 하지만 실제로 실행 순서를 따라가 보면 저 write 한 줄이 첫 free() 호출보다 먼저 끝나 있다. free가 뭘 하기도 전에 이미 원하는 주소에 원하는 값을 심을 수 있다는 뜻이다.
로컬에서 아무 값이나 넣어 확인해보면 바로 죽는다 — 통제되지 않은 쓰기라는 걸 실감할 수 있다.
세 후보 모두 "스택의 특정 위치가 NULL"이라는 조건이다. 어느 게 맞을지는 실제로 free()가 hook을 호출하는 시점의 스택 상태에 달려 있어서 미리 확신하긴 어렵다. 이번엔 첫 번째 후보(0x4527a)로 바로 맞았다 — 다행히 free()가 내부적으로 몇 단계 콜을 거치며 밀어낸 스택 공간이 이미 0으로 비어 있었던 셈이다. 안 맞았으면 나머지 두 후보를 순서대로 시도하면 된다.
페이로드는 단 16바이트다.
오프셋
길이
값
0~7
8B
&__free_hook
8~15
8B
one_gadget 주소
malloc(16)으로 청크를 받고 딱 그 16바이트만 채우면, *(long*)*ptr = *(ptr+1) 한 줄이 __free_hook = one_gadget을 그대로 실행해준다. 뒤이은 첫 free(ptr)이 곧 one_gadget() 호출이고, 두 번째 free(ptr)은 execve가 프로세스 이미지를 통째로 갈아치운 뒤라 아예 실행되지 않는다.
이 소스만 보면 반사적으로 fastbin dup이나 tcache poisoning부터 떠올리게 되는데, 정작 진짜 취약점은 그보다 앞에 있는 한 줄짜리 write-what-where였다. 코드에 double free가 보인다고 반드시 그 기법을 써야 하는 건 아니고, 실행 순서를 끝까지 따라가 보는 게 먼저다.
libc 내부 전역 변수는 RELRO의 보호 범위 밖이다.
Full RELRO는 바이너리 자신의 GOT를 읽기 전용으로 만들 뿐, __free_hook처럼 libc가 들고 있는 함수 포인터에는 아무 영향이 없다. glibc 2.34부터 __malloc_hook/__free_hook 자체가 삭제된 것도 이런 공격이 너무 흔했기 때문이다.
포인터를 그대로 찍어주는 습관이 ASLR을 무력화한다.
printf("stdout: %p\n", stdout) 한 줄이 libc 베이스 전체를 넘겨준다. 디버깅용으로 남겨둔 출력이 실전에서 가장 비싼 정보 유출이 되는 경우다.
힙에 쓰는 값의 첫 두 워드를 검증 없이 신뢰하면 안 된다.
ptr[0]을 주소로, ptr[1]을 값으로 그대로 믿고 역참조 쓰기를 하는 코드는 그 자체로 임의 쓰기 프리미티브다. 힙 청크 안의 데이터라고 해서 신뢰할 이유는 없다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…