How can you not be romantic about UNIX domain sockets?
UNIX 도메인 소켓에 어찌 낭만을 느끼지 않을 수 있겠는가 — 40년 묵은 BSD 커널 버그를 무대 위에서 만나다
DEFCON 무대에서 시연이 부팅 직후 한 번만 크래시한 원인을 추적하던 저자는 XNU 커널의 UNIX 도메인 소켓 inode 할당 로직에서 1985년 4.3BSD 시절부터 이어져 온 논리 버그를 발견합니다. post-increment(unp_ino++)로 인해 시스템 최초의 fstat() 호출 소켓이 inode 0을 받고, 두 번째 호출에서 inode가 바뀌어 사용자 공간이 깨지는 문제로, 모든 macOS·iOS 버전에 영향을 미칩니다.
- 주제
본문
저자는 DEFCON 34 무대에서 iOS 기술 시연 중 시작 직후 데모가 크래시하는 사고를 겪었습니다. 당시 성공률 36%의 1-day LPE 익스플로잇(DarkSword) 시연에 신경이 쏠려 대수롭지 않게 넘겼지만, 귀국 후 코드 공개를 앞두고 원인을 파헤치기 시작합니다.
크래시의 재현 조건과 배경
크래시는 iOS 기기를 막 부팅한 직후 첫 실행에서만 발생하고 두 번째부터는 절대 재현되지 않았습니다. 이는 ASLR이나 멀티스레드 경합 같은 비결정적 요인이 아니라는 뜻이었습니다. 프로젝트는 iOS 앱 안에서 SSH 서버를 돌리는 VM인데, iOS는 앱의 자식 프로세스 생성을 허용하지 않으므로 프로세스 생성 함수를 오버라이드해 같은 프로세스 내 스레드로 다중 프로세스 의미를 흉내 냅니다. TTY 스펙상 프로세스당 제어 단말이 하나뿐이라 여러 SSH 세션을 처리할 수 없기에, VM은 서로 연결된 UNIX 도메인 소켓 쌍을 만들고 각 측 데이터를 전처리(master가 Ctrl+C를내면 slave가 SIGINT를 받는 식)하는 방식으로 TTY를 사용자 공간에 구현했습니다. 크래시는 SSH 연결 초기화 중 TTY의 master/slave 단을 설정하는 지점에서 발생했습니다.
inode로 master/slave를 구분하다 생긴 의문
파일 디스크립터는 dup()으로 복제될 수 있으므로, 저자는 소켓 양단의 원래 inode 번호를 보관해 파일의 TTY 모드(master/slave)를 구분했습니다. 크래시 지점은 fstat()으로 얻은 inode가 저장된 값과 다르다는 sanity check, 즉 VERIFY(tt->t_sfd_ino == st.st_ino)였습니다. 처음엔 메모리 오염을 의심했지만, 디버깅 결과 같은 소켓 끝단에 대한 두 번째 fstat() 호출이 첫 호출과 다른 inode를 반환한다는 사실이 드러났습니다. 코드는 멀쩡하고 크래시는 재부팅 후 첫 실행에서만 결정적으로 발생했기에, 저자는 미뤄왔던 커널 소스(uipc_usrreq.c의 uipc_sense())를 직접 열어봅니다.
버그의 정체: unp_ino++
uipc_sense()는 소켓의 inode를 struct stat의 st_ino로 꺼내는 로직으로, unp->unp_ino가 0이면 전역 변수 unp_ino에서 값을 하나 할당합니다. 문제는 전역 변수 사용 자체에 있었습니다. unp_ino가 0으로 초기화된 상태에서 unp->unp_ino = unp_ino++는 post-increment라서 첫 할당에 0을 돌려줍니다(++unp_ino였다면 1). 그런데 초기화 여부 검사가 unp_ino == 0을 '미할당'으로 간주하므로, 시스템에서 최초로 fstat()을 받은 소켓만 두 번째 stat 호출 시 inode가 바뀌게 됩니다. 경합 문제가 아닌 순수 논리 버그라 결정적으로 재현된 것입니다. 커널 쪽 수정은 ++로 바꾸는 한 글자지만, 저자의 코드는 하위 버전을 지원해야 하므로 inode 0이 반환되면 fstat()을 재호출하는 Apple 전용 우회 로직을 추가해야 했습니다.
1985년까지 거슬러 올라가는 버그의 역사
버그의 나이를 확인하기 위해 GitHub의 가장 이른 XNU 커밋(2001년 3월 Mac OS X 10.0과 함께 출시된 XNU 123.5)을 확인하니 같은 코드가 있었습니다. 즉 현대 macOS와 iOS 소프트웨어 전부가 영향을 받습니다. XNU는 NeXT 인수 후 가져온 NeXTSTEP 커널(Mach + 4.3BSD 기반, 1989년 첫 출시)의 계보이므로 4.3BSD-Tahoe 소스를 확인하니, uipc_sense()가 대형 switch-case 안의 case PRU_SENSE 조각이었을 뿐 로직은 동일했습니다. 더 거슬러 올라가 unp_ino++ 라인이 마지막으로 바뀐 시점은 1985년 12월 20일이었고, 그전 코드는 매 fstat()마다 다른 inode를 내놓는 더 근본적으로 깨진 상태였습니다. 그 이전인 1985년 5월 28일에는 st_dev와 st_ino가 아예 없어 fstat()이 0만 반환했는데, 이 기능을 추가한 커밋 메시지가 'fake up inode numbers and dev for the naive'였습니다. 저자는 41년 된 커밋 메시지에게 naive라는 말을 들은 셈이라고 농담합니다. 추정되는 경위는 1985년 5월경 처음으로 fstat()에 의존하는 사용자 공간 프로그램이 생겨 구현이 요청됐고, 같은 해 12월 같은 소켓에 일관된 inode를 요구하는 수정이 이뤄졌지만 그 과정에서 실험적으로 프로토타입된 코드가 제대로 재검토되지 않은 채 남았다는 것입니다.
교훈
버클리 연구실의 작은 기능이 베이 브리지를 건너 쿠퍼티노까지 이어져 최신 iPhone에 흔적을 남긴 사례입니다. 저자의 교훈은 간단합니다. DEFCON 메인 스테이지에 오르기 전 데모를 더 철저히 검증할 것.
Lobsters 반응
- @fanf — 재미있는 버그입니다. 같은 버그가 OpenBSD(uipc_usrreq.c 666행)와 NetBSD(888행 부근)에도 아직 남아 있다고 지적하며, FreeBSD는 2002년에 예상보다 영리한 방식으로 수정했다고 밝힙니다. FreeBSD의 현재 코드는 데이터 경합을 피하기 위해 inode 번호를 소켓 생성 시점에 미리 할당(eager allocation)하며 더 단순하다고 설명합니다.
원문: Lobsters / 번역·요약: Trawling