드림핵 워게임 중에 hibye라는 문제가 있다. 처음 봤을 땐 간단한 BOF 문제인 줄 알았다.
처음 세운 계획은 이랬다:
교과서 그 자체다. 근데 실제로는 PTY canonical mode라는 놈 때문에 무려 66개가 넘는 exploit 버전을 작성하면서 하루 종일 날렸다. 결론부터 말하면 0x16 하나를 몰라서 벌어진 참사다.
IDA로 디컴파일하면 프로그램 구조가 깔끔하게 나온다. main에서 초기화하고, token 출력하고, 입력 두 번 받는 구조다. 첫 번째 입력은 힙 영역에 크게 받고, 두 번째 입력이 스택 버퍼다.
핵심은 두 번째 입력 함수다. 어셈블리를 보면 sub rsp, 0x20으로 32바이트 버퍼를 잡는데, read에서 0x30(48바이트)을 읽는다. 16바이트 오버플로우가 가능하다.
스택 레이아웃으로 보면:
+-------------------+ <- rbp-0x20 (버퍼 시작)
| buffer (32 bytes) |
+-------------------+ <- rbp
| saved rbp (8) |
+-------------------+ <- rbp+0x08
| return addr (8) | <- 여기를 덮는다
+-------------------+
saved rbp(8바이트) + return address(8바이트)를 정확히 덮을 수 있는 크기다. 여기까지만 보면 전형적인 BOF + ROP 문제다.
근데 Dockerfile을 보니 이런 설정이 있었다:
socat TCP-LISTEN:$PORT,reuseaddr,fork \
EXEC:"/chall",pty,sane,setsid,sigint,raw
여기서 pty,sane 옵션이 문제의 시작이다. PTY는 터미널을 에뮬레이트하는 가상 터미널인데, canonical mode에서는 특수 바이트들이 "데이터"가 아니라 "터미널 제어 신호"로 해석된다.
일반적인 TCP 소켓이면 바이트가 그대로 전달된다. 근데 PTY를 거치면 터미널 드라이버가 중간에서 특수 문자를 가로채서 처리해버린다. 쉽게 말하면 payload에 0x03이 있으면 PTY가 "아 Ctrl+C구나" 하고 프로세스를 죽이는 거다.
처음엔 아무 생각 없이 패딩 채우고 ROP chain 붙여서 보냈다.
예상 결과: 쉘 획득
실제 결과: EOF 발생, 연결 끊김
뭐가 문제인지 몰라서 context.log_level = 'debug' 켰더니 디버그 출력에 ^@가 보였다. null byte가 PTY에서 문제를 일으키고 있었다.
sendlineafter를 쓰니까 개행이 추가로 붙어서 Name 입력에서 48바이트를 넘어가는 문제가 발생했다.
p.send(payload + b'\x04') # EOF로 입력 종료 시도
PTY에서 0x04는 "데이터 전달용 문자"가 아니라 EOF 제어 신호다. 연결 자체가 끊겨버렸다.
이 시점에서 "아 이건 PTY가 문제구나" 라는 걸 확실히 깨달았다.
PTY canonical mode에서는 다음 바이트들이 데이터가 아닌 제어 신호로 해석된다:
| 바이트 | 의미 | 효과 |
|---|---|---|
| 0x03 | Ctrl+C | 프로세스 종료 (SIGINT) |
| 0x04 | Ctrl+D | EOF (입력 종료) |
| 0x0a | \n | 줄바꿈 (라인 입력 완료) |
| 0x0d | \r | 캐리지 리턴 |
| 0x15 | Ctrl+U | 현재 라인 전체 삭제 |
| 0x1a | Ctrl+Z | 프로세스 정지 (SIGTSTP) |
| 0x7f | DEL | 백스페이스 (직전 문자 삭제) |
문제는 ROP chain에 사용하는 주소들에 이런 바이트가 포함된다는 거다.
예를 들어 PIE base가 있는 바이너리에서 gadget 주소에 0x15(Ctrl+U)가 들어가 있으면, PTY가 그 시점까지 입력된 라인을 통째로 삭제해버린다. libc 주소는 거의 항상 0x7f로 시작하는데, 0x7f는 DEL(백스페이스)이라 직전 바이트를 날려먹는다.
payload가 목적지에 도착하기도 전에 터미널 드라이버한테 난도질당하는 상황이었다.
로컬에서 직접 바이너리를 실행하면 표준 입출력이 그냥 파이프로 연결된다. PTY가 끼어있지 않으니까 바이트가 그대로 전달된다. 근데 리모트는 socat이 PTY를 통해 프로그램을 실행하기 때문에 canonical mode가 적용되는 거다.
이게 바로 "로컬에선 되는데 리모트에선 안 되는" 전형적인 원인 중 하나다.
모든 문제의 해결책은 0x16이었다.
PTY canonical mode에서 0x16(Ctrl+V)은 "다음 문자를 literal로 받아라"는 의미다. 터미널에서 실제로 Ctrl+V를 누르면 다음 입력 문자가 제어 신호가 아니라 순수 데이터로 처리되는 것과 같은 원리다.
# 0x0a(개행)를 데이터로 전달하고 싶을 때
\x16\x0a → PTY가 0x0a를 제어 신호가 아닌 순수 데이터로 처리
이걸로 tty_escape 함수를 만들었다:
def tty_escape(data: bytes):
"""모든 제어 문자를 0x16으로 escape"""
res = b""
for b in data:
if b <= 0x1f or b == 0x7f:
res += b"\x16" + bytes([b])
else:
res += bytes([b])
return res
여기서 중요한 게 하나 있다. escape 범위다.
# ❌ 실패한 버전 - 알려진 특수 문자만 escape
if b in [0x0a, 0x0d, 0x7f, 0x03, 0x04, 0x15, 0x1a]:
# ✅ 성공한 버전 - 모든 제어 문자 escape
if b <= 0x1f or b == 0x7f:
처음에는 위 표에 나온 문자 몇 개만 escape했는데, 그걸로는 부족했다. 0x00~0x1f 범위에는 알려진 것 외에도 canonical mode에서 특수 처리될 수 있는 바이트가 더 있다. 그냥 제어 문자 전부를 escape하는 게 안전하다. 이 범위 차이가 성공과 실패를 갈랐다.
PTY 환경에서는 send와 sendline의 구분이 더 중요해진다.
# 첫 번째 입력: tty_escape 적용 후 수동으로 개행 추가
p.sendafter(b'Input:', tty_escape(payload) + b'\n')
# 두 번째 입력: 오버플로우 payload
p.sendlineafter(b'Name:', tty_escape(payload2))
sendline이 자동으로 붙이는 개행까지 감안해서 payload 크기를 계산해야 한다.
puts로 GOT 엔트리를 출력해서 libc 주소를 leak하는 건 일반적인 ROP 기법이다. 근데 PTY 환경에서는 출력 데이터에도 \r\n이 섞이거나 바이트가 변형될 수 있어서 파싱에 주의해야 한다.
libc 주소는 유저 영역 상위에 매핑되기 때문에 특정 바이트로 시작한다는 특성이 있다. 이걸 이용해서 leak 데이터에서 유효한 주소를 필터링할 수 있다.
최종 exploit의 큰 흐름은 이렇다:
Stage 0: PIE base leak
├─ 프로그램이 출력하는 token 값에서 PIE base 계산
Stage 1: libc leak
├─ 첫 번째 입력에 ROP chain 배치
│ └─ puts(GOT entry) → main 복귀
├─ 두 번째 입력으로 stack pivot (saved rbp 조작)
│ └─ leave; ret으로 RSP를 ROP chain이 있는 곳으로 이동
└─ puts 출력에서 libc base 계산
Stage 2: 쉘 획득
├─ 두 번째 라운드 첫 번째 입력에 최종 ROP chain 배치
│ └─ execve("/bin/sh", NULL, NULL)
└─ interactive!
Stack Pivot이 핵심 기법이다. 두 번째 입력에서 16바이트밖에 오버플로우가 안 되니까, saved rbp를 조작해서 leave; ret gadget으로 RSP를 원하는 곳(첫 번째 입력으로 넣어둔 ROP chain이 있는 BSS 영역)으로 옮기는 거다.
leave 명령어의 동작:
mov rsp, rbp ← RSP를 조작된 rbp 값으로 변경
pop rbp ← 새 위치에서 rbp pop
ret:
pop rip ← 새 위치에서 다음 gadget 실행
이렇게 하면 16바이트 오버플로우만으로도 긴 ROP chain을 실행할 수 있다.
최종 쉘 획득에는 system 대신 execve를 사용했다. execve("/bin/sh", NULL, NULL)을 호출하려면 rdi, rsi, rdx 세 개의 레지스터를 세팅해야 한다.
문제는 pop rdx; ret 같은 깨끗한 gadget이 없을 때가 많다는 거다. 이럴 때 쓸 수 있는 우회 기법이 있다:
xor eax, eax ← eax = 0
xchg edx, eax ← edx = 0 (eax와 교환)
이런 식으로 간접적으로 레지스터를 세팅하는 gadget 조합을 찾아야 한다. ROPgadget으로 libc를 뒤져보면 의외로 쓸만한 조합이 나온다.
exploit 작성할 때 미리 각 주소에 bad byte가 포함되어 있는지 확인하는 습관을 들이면 좋다:
bad_bytes = set(range(0x00, 0x20)) | {0x7f}
for name, addr in gadgets.items():
addr_bytes = p64(addr)
needs_escape = any(b in addr_bytes for b in bad_bytes)
print(f"{name}: needs escape = {needs_escape}")
실제로 확인해보면 PIE 바이너리의 gadget 주소에는 0x1a(Ctrl+Z), 0x15(Ctrl+U) 같은 바이트가 꽤 자주 들어가있고, libc 주소는 거의 100% 0x7f(DEL)를 포함한다. PTY 환경에서는 tty_escape 없이 exploit하는 게 사실상 불가능하다.
ROP chain 앞에 ret gadget을 여러 개 넣는 기법도 사용했다. 이건 두 가지 목적이 있다:
payload 보냈는데 EOF 발생
→ 주소에 0x03(SIGINT), 0x04(EOF) 같은 바이트가 포함되어 있을 가능성 높다. tty_escape 적용해야 한다.
payload 일부가 잘리거나 변형됨
→ 0x7f(백스페이스)가 직전 바이트를 삭제하고 있을 수 있다. debug 모드로 실제 전송 바이트 확인하자.
로컬에서 되는데 리모트에서 안 됨
→ Dockerfile에서 socat 옵션 확인. pty,sane이 있으면 PTY canonical mode가 적용되는 거다.
tty_escape 적용했는데도 안 됨
→ escape 범위를 확인하자. 특정 문자 몇 개만 하면 안 되고, 0x00~0x1f 전체 + 0x7f를 해야 한다.
libc leak 값이 이상함
→ PTY가 출력 데이터도 변형할 수 있다. \r\n 변환이 일어나는 경우가 있으니 recv 데이터를 hex로 찍어보고 파싱 로직을 조정해야 한다.
| 항목 | 내용 |
|---|---|
| PTY canonical mode | 특수 바이트가 제어 신호로 해석된다. exploit 전에 반드시 환경 확인 |
| 0x16 (Ctrl+V) | PTY에서 다음 바이트를 literal로 전달하는 escape 문자. 이거 하나가 핵심 |
| escape 범위 | 알려진 특수 문자 몇 개만으로는 부족. 0x00~0x1f 전체 + 0x7f 필요 |
| Stack Pivot | 제한된 오버플로우 크기를 극복하는 핵심 기법. saved rbp + leave;ret 조합 |
| 로컬 vs 리모트 | socat PTY 설정 때문에 동작이 달라질 수 있다. Dockerfile 먼저 확인 |
| 디버깅 | context.log_level = 'debug'로 실제 송수신 바이트를 확인하는 게 제일 빠르다 |
이번 문제에서 가장 크게 배운 건 exploit 환경을 먼저 파악해야 한다는 거다. 취약점 자체는 단순한 BOF인데, PTY 환경이라는 변수 하나 때문에 하루 종일 삽질했다.
앞으로는:
66번의 실패가 있었지만, 덕분에 PTY canonical mode가 exploit에 어떤 영향을 미치는지 확실하게 체득했다. 이론으로 배우면 "아 그렇구나" 하고 넘어갈 내용인데, 직접 하루 종일 삽질하니까 절대 안 잊혀진다.
무엇보다 "왜 안 되지?"라는 질문을 멈추지 않은 게 결국 답을 찾게 해줬다.
출처: DreamHack 워게임 - hibye