read()로 정확히 72바이트만 보내 canary·saved rbp를 non-null로 덮되 return address는 건드리지 않으면, 뒤이은 printf("%s", buf)가 retaddr의 첫 null 바이트에서 멈추며 libc 주소 일부가 그대로 새어나온다. 여기에 문제가 기본 제공하는 임의주소 write/free 프리미티브를 얹어 __free_hook을 system으로 덮고 free("/bin/sh")를 부르면 그대로 쉘이 뜬다.
부분 오버플로 libc leak + 임의주소 write/free → __free_hook 하이재킹
소스가 통째로 주어지는데, 스택 오버플로 한 줄과 함께 임의 주소에 쓰기 / 임의 주소를 free 하기라는 강력한 프리미티브 두 개가 그냥 나열돼 있다. 리크만 만들어내면 나머지는 사실상 정해진 길이다.
🧩 배경 — malloc hook과 프리미티브 세 개
glibc 2.27 이하에는 malloc/가 실제 작업을 하기 전에 사용자가 등록할 수 있는 함수 포인터 , 을 확인하는 코드가 남아있었다. 디버깅·프로파일링 용도였는데, 힙 익스플로잇 관점에서는 "함수 포인터를 하나 덮으면 그 즉시 실행 흐름을 뺏는" 지름길이었다. 남용 사례가 너무 많아 glibc 2.34부터 아예 들어냈다.
free(addr) — 우리가 정한 임의 주소를 그대로 free에 넘기는 임의주소 free.
__free_hook에 system을 심고 free("/bin/sh")를 부르면 system("/bin/sh")가 되는 건 이미 잘 알려진 체인이다. 문제는 libc 베이스를 어떻게 아느냐다 — 그리고 그 답이 1번 프리미티브에 숨어 있다.
🔬 코드 정찰
바이너리부터 훑는다.
file fho && checksec --file=fho
file · checksec — PIE 64bit, Full RELRO, Canary, NX
Full RELRO라 GOT를 덮는 루트는 막혀 있다. Canary도 있으니 순진하게 return address를 덮는 것도 안 된다. 그런데 이 두 보호 기법 다 "애초에 스택을 안 건드리는" 임의주소 write/free 앞에서는 의미가 없다. 필요한 건 하나, libc 주소를 아는 것뿐이다.
read(0, buf, 0x100)는 최대 256바이트를 받지만, 우리가 실제로 보내는 바이트 수만큼만 덮인다. 정확히 72바이트를 보내면 buf+canary+saved rbp까지만 채워지고 return address는 원래 값 그대로 남는다. 그 상태에서 바로 다음 줄의 printf("Buf: %s\n", buf)가 buf를 문자열로 찍는데, %s는 처음 만나는 0x00에서 멈춘다.
canary의 최하위 바이트는 스택 보호 설계상 항상 0x00이다(문자열 함수로 카나리 자체가 새지 못하게 하려는 장치). 우리가 canary·saved rbp 자리를 non-null 바이트로 덮어버리면, %s는 그 두 구간을 그대로 통과해 이어지는 return address로 들어간다. return address는 우리가 안 건드렸으니 원래 값 그대로고, 그 값의 상위 2바이트는 커널 주소 규칙상 항상 0x0000이라 6바이트를 흘린 다음 거기서 멈춘다. 결과: retaddr 하위 6바이트가 그대로 응답에 찍힌다.
main() 스택 레이아웃과 leak이 멈추는 지점
main은 __libc_start_main에서 call rax로 호출되므로, 이 return address는 libc 안의 고정 오프셋이다. libc-2.27.so를 까 보면 그 지점을 바로 찾을 수 있다.
call rax(0x21bf5) 바로 다음 명령 주소 0x21bf7이 main() 호출이 리턴하는 자리다. 즉 libc_base = leak - 0x21bf7.
▶🐛 삽질 — 처음엔 그냥 canary 앞까지만 보내면 되는 줄 알았다
처음 시도는 순진하게 56바이트(정확히 canary 앞)만 보내는 거였다. 이러면 canary·saved rbp·retaddr 전부 원본 그대로 남으니 %s가 canary의 첫 바이트(0x00)에서 바로 멈춰서, 우리가 보낸 56바이트 그대로만 되돌아온다 — 새는 정보가 없다.
canary를 완전히 무해하게 우회하려면 결국 canary·saved rbp 구간 자체를 non-null로 밀어서 %s의 정지점을 return address 안쪽까지 옮겨야 한다는 걸 gdb로 오프셋을 직접 찍어보고서야 정리했다(아래 gdb 캡쳐). canary를 이 시점에 덮어도 상관없는 이유는 단순하다 — canary 검증은 main 맨 끝(leave; ret 직전)에서만 일어나는데, 우리는 그 전에 3번 프리미티브(free)로 이미 셸을 따버릴 거라 그 검증 코드에 아예 도달하지 않는다.
이 오프셋이 실제 실행 중에도 맞는지 gdb로 한 번 더 확인했다. read() 직후, printf("%s",buf) 호출 직전에 브레이크를 걸고 canary·saved rbp·retaddr 주소와 내용을 직접 찍었다.
gdb 런타임 확인 — buf/canary/savedrbp/retaddr 오프셋이 56/64/72로 정확히 일치, canary 최하위 바이트=0x00, retaddr이 libc 심볼로 resolve됨
로컬 시스템 glibc(2.42, 이 캡쳐용 검증 환경 — 실제 배포 서버의 2.27과는 다른 빌드)라 심볼 이름은 __libc_start_call_main+117로 나오지만, 확인하려던 세 가지는 정확히 들어맞는다: 오프셋 56/64/72, canary 최하위 바이트가 0x00이라는 점, 그리고 retaddr이 항상 libc 안의 고정 함수로 resolve된다는 점. 정확한 심볼 이름과 오프셋은 glibc 빌드마다 다르지만 leak 메커니즘 자체는 동일하다 — 실제 익스플로잇에서 쓰는 0x21bf7은 문제가 제공한 libc-2.27.so 기준이다.
glibc 2.27의 _int_free는 실제로 청크를 만지기 전에 __free_hook이 설정돼 있으면 (*__free_hook)(mem, caller)를 그대로 호출한다. 여기서 mem은 우리가 free()에 넘긴 포인터 그 자체다. __free_hook을 system으로 바꿔두면 mem이 곧 system의 첫 인자가 되므로, free(&"/bin/sh")가 system("/bin/sh")와 같아진다.
로컬 사전검증 — leak 공식이 실제로 맞는지
실서버를 매번 두드리기 전에, 로컬에서 leak 계산이 /proc/[pid]/maps가 말하는 진짜 libc 베이스와 일치하는지부터 확인했다. pwntools의 p.libc.address를 정답지로 두고 우리 leak 공식으로 복원한 값을 대조하는 스크립트다.
local_solve.py 실행 — 우리 계산이 /proc/maps 기준 진짜 libc 베이스와 정확히 일치
leak으로 복원한 주소에서 페이지 오프셋(0x2a575)을 빼면 /proc/maps가 말하는 실제 libc 베이스와 정확히 맞아떨어진다 — 위 gdb 캡쳐에서 본 __libc_start_call_main+117과도 같은 오프셋이다. 리크 공식 자체는 검증됐다.
다만 이 로컬 스크립트는 그 뒤 __free_hook write에서 SIGABRT로 죽는다. 이 시스템의 glibc(2.35+)는 hook 메커니즘 자체를 들어냈기 때문에 — 심볼은 ABI 호환으로 남아있지만 free()가 더는 그 값을 확인하지 않는다 — 우리가 쓴 값은 무시되고 free()가 진짜로 그 "포인터"를 청크로 취급하려다 힙 무결성 검사에 걸려 죽는다. fho가 정확히 glibc 2.27을 못박아 배포하는 이유가 이거다. leak 메커니즘은 리크 대상 코드 자체가 항상 실행되니 glibc 버전과 무관하게 통하지만, hook 하이재킹은 2.33 이하에서만 산다.
실행하면 매번 ASLR로 libc 베이스가 바뀌는데도 leak 공식이 그대로 맞아 들어가고, 곧바로 플래그가 떨어진다.
solve.py 실행 — libc leak부터 free_hook write, free 호출까지 이어져 flag가 그대로 출력
[*] leaked return address = 0x7fb434addbf7[*] libc base = 0x7fb434abc000[*] __free_hook = 0x7fb434ea98e8[*] system = 0x7fb434b0b550[*] /bin/sh = 0x7fb434c6fe1aDH{a8529ace5e50480658a645aa1a1c88291784335c1c54c5b89d0f43ad1893730c}
✅ 제출 확인
자동화 도구(dreamhack.py)로 플래그를 제출해 정답 처리와 VM 크레딧 환급까지 확인했다. (드림핵은 플래그 제출 직후 한 번씩 일시적으로 "오답"으로 잘못 표시했다가 재제출하면 바로 "정답"이 뜨는 경우가 종종 있는데, 이번에도 그랬다 — 실제 채점은 서버 쪽에서 이미 정답으로 기록돼 있었다.)
python3 dreamhack.py wg 355 --flag 'DH{a8529ace5e50480658a645aa1a1c88291784335c1c54c5b89d0f43ad1893730c}'
dreamhack.py로 플래그 제출 — 정답 처리 및 VM 크레딧 환급
📝 정리
임의주소 write와 임의주소 free는 그 자체로 이미 끝난 게임이다.
이 문제는 스택 오버플로가 메인이 아니다. 오히려 오버플로는 리크용 조연이고, *addr = value와 free(addr)를 사용자 입력으로 그대로 노출한 두 줄이 진짜 취약점이다. Full RELRO도 canary도 GOT와 리턴 주소를 지킬 뿐, 프로그램이 스스로 내어준 "아무 주소에나 쓰고 아무 주소나 free하는" 기능 앞에서는 할 수 있는 게 없다.
canary의 null 바이트 설계는 완벽하지 않다.
canary 최하위 바이트를 0x00으로 고정해두는 건 문자열 함수로 canary가 새는 걸 막기 위한 장치인데, 그 앞뒤로 우리가 다른 non-null 바이트를 채워 넣을 수 있으면 %s의 정지 지점 자체를 뒤로 밀어버릴 수 있다. 이번처럼 canary 검증이 함수 맨 끝에서만 일어나고 그 전에 다른 임의 프리미티브로 흐름을 가로챌 수 있다면, canary를 건드려도 아무 페널티가 없다.
malloc hook은 이래서 없어졌다.
__free_hook/__malloc_hook처럼 사용자가 함부로 덮을 수 있는 함수 포인터를 힙 구현 안에 남겨두면, 임의주소 write 단 한 번이 곧바로 임의 코드 실행으로 이어진다. glibc 2.34에서 이 훅들을 완전히 제거한 이유가 정확히 이 문제가 보여주는 체인이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…