다음에 올 것을 만들어가는 기술에 대한 심층 기사.

Switch 2 탈옥: 시큐어 부트를 뚫은 익스플로잇 체인

Switch 2 탈옥은 BASIC 인터프리터 오버플로, fs 경로 버그, 로더 레이스를 연결했습니다. 익스플로잇 체인과 시큐어 부트가 주는 교훈을 정리합니다.

원격 제어 하드웨어에 연결된 휴대용 콘솔과 작업대 위에서 전원 버튼을 누르는 로봇 레버.
최신 콘솔 해킹 장비는 KVM-over-IP와 블루투스 레버를 조합해서, 크래시가 날 때마다 레버가 물리적으로 전원 버튼을 눌러 줍니다.

새 콘솔에는 강화된 마이크로커널, 모든 리턴 주소에 대한 포인터 인증, 실행 전용 메모리, 제어 흐름 무결성(CFI), 서명된 부트 체인이 들어 있습니다. 그런데 2주 뒤, 한 취미 해커가 파일시스템 서비스, 즉 이 기기에서 가장 신뢰받는 프로세스 권한으로 임의의 네이티브 코드를 실행하고 있었습니다. 최초의 공개 Switch 2 탈옥 이야기이고, 보안 시스템을 만드는 엔지니어라면 누구라도 조금 불편해질 만한 사건입니다. 보호 장치가 실패해서가 아닙니다. 대부분은 설계대로 잘 동작했습니다. 문제는 익스플로잇이 그 보호 장치를 정면으로 깰 필요가 없었다는 점입니다. 로직 버그 하나하나를 이용해 그냥 돌아서 갔습니다.

저는 인프라 보안의 방어 쪽에서 여러 해를 일해 왔는데, 여기서 보이는 패턴은 운영 환경 장애 회고에서 너무 자주 본 것입니다. 개별로는 대단해 보이지 않는 버그들이 사슬처럼 이어지고, 각 버그는 다음 단계에 닿을 만큼의 권한만 얻어 줍니다. 첫 발판은 Switch 2 자체가 아니었습니다. 호환성 모드로 돌아가던 Switch 1 게임, 즉 SmileBASIC 인터프리터의 메모리 손상 버그였습니다. 거기서부터 체인은 파일시스템 서비스로 올라갔고, 프로그램 로더의 레이스 컨디션을 악용한 뒤, 어떤 sysmodule의 신원으로든 네이티브 코드를 실행할 수 있는 상태로 끝났습니다. 서버 측 시스템에서도 팀들이 똑같이 저지르는 실수와 맞닿아 있기 때문에 체인의 모든 고리를 한 번씩 짚어볼 가치가 있습니다.

브라우저 문은 닫혀 있었고, 게임 문은 열려 있었던 이유

WebKit은 콘솔 탈옥의 전통적인 첫 번째 표적이고, 연구자도 캡티브 포털과 n-day 취약점을 통해 여기서 발판을 얻긴 했습니다. 하지만 금방 막다른 길에 부딪혔습니다. Switch 2의 브라우저 스택은 아주 잘 맞물리는 세 가지 보호 장치로 감싸져 있습니다. 실행 전용 메모리(XOM) 덕분에 임의 읽기/쓰기 권한이 있어도 쓸 만한 코드 가젯을 찾기 어렵고, 포인터 인증(PAC) 덕분에 서명 오라클 없이는 코드 포인터를 위조할 수 없으며, CFI 덕분에 올바르게 서명된 포인터라도 대부분의 호출 지점에서 타입 검사를 통과하지 못합니다. 이 중 하나만 깨도 연구 과제가 되고, 세 가지를 체인으로 엮어 JavaScript 샌드박스에서 모두 깨는 것은 경력을 걸어야 하는 일입니다.

그래서 공격은 기기에서 가장 약한 테넌트, 즉 서드파티 Switch 1 게임으로 방향을 틀었습니다. 판단은 냉정했고 옳았습니다. 게임 바이너리는 닌텐도 보안팀이 아니라 스튜디오가 빌드하므로 전체 보호 장치 스택으로 컴파일될 가능성이 훨씬 낮습니다. 게다가 Switch 1에는 PAC 자체가 없었기 때문에, 그 호환성 환경에서 도는 코드는 전부 그 취약함을 물려받습니다. 더 좋은 점은 Switch 1의 신뢰 루트가 몇 년 전에 이미 뚫렸다는 것입니다. 즉 게임 코드를 오프라인에서 복호화하고 감사할 수 있습니다. 3DS에서 이미 익스플로잇된 적이 있는 거대한 C++ BASIC 인터프리터 SmileBASIC이 당연한 약한 표적이었습니다.

이 이야기는 콘솔을 넘어서도 일반화되는 교훈을 줍니다. 여러분의 보안 수준은 여러분이 실행하기로 동의한 가장 덜 강화된 코드의 보안 수준입니다. 하위 호환성은 여러분이 고르는 공격 표면이 아니라, 물려받는 공격 표면입니다. 2000년대 Xbox 보안 모델도 이 점을 힘들게 배웠습니다. 강화되지 않은 타이틀의 세이브 데이터 익스플로잇이 훨씬 단단한 커널을 여는 쐐기가 되었고, 이 이야기는 오리지널 Xbox 해킹이 하드웨어 보안 모델에 주는 교훈에서 다룬 바 있습니다. 20년이 지난 지금도 같은 수법이 여전히 통합니다.

첫 번째 문을 연 정수 오버플로

SmileBASIC 버그는 경계 검사의 전형적인 정수 오버플로입니다. 모든 시큐어 코딩 리뷰 체크리스트에 나오는 종류이고, 그럼에도 계속 배포되는 종류이기도 합니다. 이 인터프리터의 배열 제거 명령은 인자를 다음과 같이 검증했습니다.

// intent: ensure the removed range is inside the array
if (position + count <= array.length) {
memmove(backing + position * 8,          // start of the gap
backing + (position + count) * 8, // elements after the gap
(length - position) * 8);          // bytes to copy
}

원소가 4개인 배열에 REMOVE R, &H60000000, &H3FFFFFFF를 호출하면 position + count가 0x9FFFFFFF로 래핑되는데, 이는 부호 있는 32비트 값으로 보면 크게 음수가 되어 길이보다 “작다”고 판단됩니다. 8을 곱하는 연산도 래핑되어 결국 memmove(backing, backing - 8, 32)가 실행됩니다. 명령은 그 뒤에 메모리 부족 오류를 출력하지만, 복사는 이미 일어난 뒤입니다. 여기서 재미있는 부분이 나옵니다. SmileBASIC 배열 바로 앞의 8바이트에는 배열 자신을 가리키는 포인터가 들어 있습니다. 제거가 끝나면 R[0]이 문자열로 해석되는데, 길이 필드가 바로 그 포인터 값이라 사실상 천문학적으로 긴 문자열이 되고, 그걸로 읽기와 쓰기가 모두 가능해집니다. 인터프리터 자신의 메모리 레이아웃이 임의 읽기/쓰기를 선물로 주는 셈입니다.

C나 C++로 밥벌이를 하는 분이라면 그 검사를 잠깐 들여다보세요. position + count <= length는 시스템 코드에서 가장 흔한 경계 검사 관용구인데, 합이 오버플로할 수 있을 때는 틀립니다. 올바른 형태는 position <= length && count <= length - position이거나 체크된 산술 연산입니다. 저도 이전 직장의 패킷 파서와 오픈소스 스토리지 엔진에서 이런 변종 버그를 본 적이 있습니다. 이 버그가 코드 리뷰를 통과하는 이유는 겉보기에 명백히 맞아 보이기 때문입니다. 이것은 명세와 구현 사이 간극을 축소판으로 보여줍니다. 명세는 “범위를 검증하라”고 말하는데, 구현은 범위 옆의 무언가를 검증하고, 그 차이가 익스플로잇이 됩니다. 명세와 구현의 간극에 버그가 숨어 있는 이유에 대해서도 글을 쓴 적이 있습니다. 콘솔 인터프리터는 그런 버그가 퍼저 대신 의욕 넘치는 10대에게 발견되는 곳일 뿐입니다.

경로 처리 오버플로로 파일시스템 서비스까지 올라가기

게임 프로세스 안에서 읽기/쓰기 권한을 얻는 것만으로는 별 의미가 없습니다. Switch의 마이크로커널 설계상 모든 서비스는 자체 IPC 표면을 가진 별도의 프로세스이기 때문입니다. 진짜 가치 있는 표적은 내장 파일시스템 sysmodule인 fs였고, 이유는 꽤 재귀적입니다. fs는 로더에게 복호화된 코드를 제공하는 구성 요소입니다. fs를 장악하면 다른 모든 sysmodule의 코드를 오프라인 분석용으로 덤프할 수 있습니다. 요새의 보급 담당자가 설계도를 직접 건네주는 셈입니다.

버그는 경로 정규화 과정의 힙 오버플로였습니다. GetEntryType IPC 명령은 경로 인자를 정규화하는데, 백슬래시가 붙은 상위 디렉터리 요소 /..\를 포함한 경로는 고정 크기 0x301바이트 버퍼를 할당하는 헬퍼로 흘러 들어가고, 충분한 길이 검사 없이 경로를 그 버퍼에 복사합니다. 충분히 긴 경로는 버퍼를 넘쳐 인접한 힙 객체를 덮어씁니다. 연구자가 이 버그를 발견했을 때 남긴 코멘트는 “lol” 한 단어였습니다.

이 “lol”은 뜨끔해야 합니다. 경로 정규화는 암호학 코드가 받는 보안 검토를 전혀 받지 못하는, 화려하지 않은 배관 코드의 전형이기 때문입니다. 익스플로잇은 오버플로를 인접한 캐시 객체의 dirty 비트를 망가뜨리는 방식으로 임의 읽기/쓰기로 바꿨습니다. 이 dirty 비트는 파일 I/O를 공격자가 제어하는 포인터로 우회시킵니다. 깔끔한 힙 그루밍 프리미티브입니다. 이후 메모리 스캔으로 fs 테이블에서 자기 프로세스의 연결 객체를 찾아 저장된 프로세스 ID를 1로 덮어썼고, 그 결과 게임 프로세스가 파일시스템에 대해 “FullPermission”을 얻었습니다. 여기서 중요한 점을 보세요. 암호학적 검사는 단 하나도 깨지지 않았습니다. 권한 모델은 메모리 속 데이터로 강제되고 있었고, 공격자는 메모리에 쓸 수 있었습니다.

여러 층으로 된 요새의 등각 단면도, 작은 인물이 좁은 서비스 통로를 통해 잠긴 문을 우회하는 모습.
익스플로잇 체인의 각 단계는 아무도 지키지 않던 통로를 따라 강화된 문들을 우회했습니다.

파일시스템을 비행 중에 바꿔치기해 서명 검사를 뚫기

체인에서 가장 영리한 고리는 키를 전혀 건드리지 않고 코드 서명을 우회하는 부분입니다. 프로그램 로더(ldr)는 무엇이든 실행하기 전에 두 개의 서명을 검증합니다. 하나는 프로세스 권한 메타데이터와 개발자 키에 대한 닌텐도의 서명이고, 다른 하나는 콘텐츠 아카이브 헤더 해시에 대한 개발자의 서명입니다. 이론적으로 이 해시는 실행 파일의 모든 바이트를 간접적으로 고정합니다. 실제로는 빈틈이 있습니다. fs가 제공하는 파일시스템이 이미 보고된 해시와 실제로 일치하는지 보장하는 역할은 fs의 몫인데, 공격자가 바로 fs 내부에서 임의 읽기/쓰기 권한을 가지고 있었습니다.

그래서 익스플로잇은 로더가 실행 파일 파일시스템을 요청하고 fs가 이미 유효한 해시와 서명을 넘겨준 뒤까지 기다립니다. 그 다음 로더의 IPC 핸들 뒤에 있는 파일시스템 객체를, 정상적으로 서명된 메타데이터 파일은 그대로 제공하면서 실행 파일만 임의의 다른 바이너리로 바꾼 조작된 객체로 교체합니다. 서명 검사는 공격자가 수정할 필요가 없었던 바이트에 대한 검사였기 때문에 그대로 통과합니다. 타이밍은 fs가 로더 연결을 기다리는 핸들을 게임 프로세스에서 공격자가 제어하는 이벤트로 다시 써서 고정합니다. 레이스 컨디션이 결정론적인 끼어들기로 바뀌는 것입니다.

해시 헤더에서 실행 파일 내용까지 이어지는 암호학적 체인은 종이 위에서는 건전했습니다. 그런데 “파일을 제공하는 프로세스는 정직하다”라는 런타임 가정으로만 존재하던 한 고리가 결국 끊어졌습니다.

이것은 옷을 갈아입은 TOCTOU(검사 시점과 사용 시점의 불일치) 버그이며, 이 글 전체에서 가장 잘 전이되는 교훈입니다. 컴포넌트 A가 데이터를 검증하고 컴포넌트 B가 나중에 가변 참조를 통해 그 데이터에 따라 행동하는 순간마다 교체 창이 생깁니다. 예전에 컨테이너 이미지 다이제스트를 검증한 뒤 롤아웃 시점에 레지스트리에서 태그로 다시 pull하는 내부 배포 시스템을 리뷰한 적이 있습니다. 같은 버그가 다른 모자를 쓴 것입니다. 두 경우 모두 해결책은 같습니다. 사용 시점에 검증하거나, 소비자에게 제3자가 정직하게 유지해 주기를 믿어야 하는 핸들이 아니라 불변의 검증된 객체를 넘기는 것입니다. 모든 중간자가 침해되었다고 가정하는 방어 설계는 편집증이 아닙니다. 공격자가 이미 부분적으로 쓸 수 있는 기기에서는 그저 정확한 모델링입니다. 이 점은 재앙에 저항하는 시스템을 위한 방어 엔지니어링의 핵심 논지이고, 이번 익스플로잇은 그 좋은 사례 연구입니다.

자기 프로세스의 커널 핸들을 빌려 fs 내부를 패치하기

마지막 트릭은 작지만 아름답습니다. fs 프로세스 자체의 코드를 패치하려면 그 프로세스에 대한 커널 핸들이 필요했습니다. 익스플로잇은 메모리의 4바이트 핸들 필드를 매직 값 0xffff8001로 덮어썼습니다. 커널은 이 값을 “현재 프로세스의 핸들”로 해석합니다. 게임 프로세스가 무해해 보이는 API를 호출하자 자기 프로세스 핸들을 돌려받았고, 이를 코드 로딩 sysmodule 권한으로 실행 중인 악성 프로세스에 넘겼습니다. 그 프로세스는 정상적인 커널 호출을 사용해 fs의 코드를 매핑하고 패치했습니다. 각 연산은 개별적으로 모두 허가된 것이었습니다. 그 조합이 기기에서 가장 신뢰받는 프로세스에 대한 완전한 장악이 된 것입니다.

이 이야기가 익숙하게 들린다면, 이 패턴을 찾기 시작하면 어디에나 있기 때문입니다. 쓰기 가능한 설정 파일을 가진 무해한 바이너리를 허용하는 sudo 규칙, 더 강력한 서비스로 자신을 넘겨줄 수 있는 클라우드 IAM 역할, CI 작업이 건네는 무엇이든 서명하는 빌드 시스템 등이 그렇습니다. 이 패턴의 권한 상승 측면은 유닉스 권한 상승의 역사에서 다룬 적이 있습니다. 메커니즘은 바뀌어도 버그의 모양은 바뀌지 않습니다. 핸들, 권한(capability), 토큰은 그것을 저장하는 메모리의 무결성만큼만 강합니다.

시큐어 부트 설계자들이 계속 틀리는 부분

한 발 물러서서 보면 체인 전체에 걸친 패턴은 일관됩니다. 제가 리뷰한 굵직한 기업 침해 사고 대부분의 배후에 있던 패턴과 같습니다. 검증된 핵심은 버텼고, 그 주변은 버티지 못했습니다. 닌텐도의 부트 ROM, 서명 방식, 커널 보호 장치는 문제가 없었습니다. 무너진 것은 인터프리터의 경계 검사, 경로 정규화 버퍼, 쓰기 가능한 메모리에 저장된 권한 필드, 두 신뢰 서비스 사이의 TOCTOU 창, 그리고 사용자 공간에서 쓸 수 있는 구조체에서 커널 포인터를 가져간 핸들 테이블이었습니다. 어느 것도 이국적이지 않습니다. 모두 각각이 지역적으로는 합리적으로 보이기 때문에 리뷰를 통과하는 종류의 것들입니다.

  • 지루한 코드를 감사하세요. 경로 정규화기, 길이 검사, 핸들 테이블이 실제로 익스플로잇 체인이 시작되는 곳입니다. 암호학이 아닙니다. 리뷰 시간은 알고리즘의 정교함이 아니라 공격자의 도달 가능성에 비례해 배정하세요.
  • 모든 보호 장치의 빈틈을 로드맵으로 취급하세요. 시스템의 한 프로세스라도 PAC/CFI가 없다면, 즉 호환성 계층, 서드파티 바이너리, 레거시 인터프리터라면 그것을 진입점으로 가정하고 도달 가능한 범위를 모델링하세요.
  • 위치에 기반한 신뢰를 없애세요. 데이터를 검증한 뒤 가변 핸들을 하류로 넘기는 컴포넌트는 교체 창을 만든 것입니다. 불변의 자체 검증 객체를 넘기거나 사용 시점에 다시 검증하세요.
  • 공격자가 닿을 수 있는 곳의 쓰기 가능 메모리는 어디서든 쓰기 가능하다고 가정하세요. 공격자가 도달 가능한 메모리에 저장된 권한 플래그, PID 필드, 핸들 값은 강제 메커니즘이 아니라 공격 표면의 일부입니다.

또 하나의 불편한 교훈은 도구입니다. 이 익스플로잇은 KVM-over-IP 장치를 통해 하드웨어에 물리적으로 접근할 수 있게 된 AI 에이전트가 함께 개발했습니다. 영상 입력, USB를 통한 컨트롤러 입력, 크래시 후 전원 버튼을 누르는 블루투스 레버 장치가 연결되어 있었습니다. 여러 에이전트 인스턴스가 2주 동안 밤낮없이 서로 다른 공격 표면을 병렬로 파고들었습니다. AI 기반 보안 연구를 어떻게 평가하든, 폐쇄형 하드웨어에서 취약점을 찾는 경제성은 방금 바뀌었습니다. 예전에는 끈기 있는 전문가가 필요했던 버그를 이제는 지치지 않는 조수 세 명을 감독할 수 있는 끈기 있는 전문가가 찾을 수 있습니다. 여러분의 위협 모델이 “이 sysmodule을 굳이 역공학하려는 사람은 없다”고 가정하고 있었다면, 그 가정은 이번 달에 유효기간이 끝났습니다.

체인 자체가 결과물이다

탈옥 분석글을 닌텐도의 실수 목록으로 읽고 싶어지는 것은 자연스럽습니다. 하지만 이것을 거울로 읽어 보세요. 제가 방어를 도왔던 모든 시스템에는 SmileBASIC에 해당하는 것(아무도 강화하지 않은 레거시 컴포넌트), 경로 버퍼에 해당하는 것(가정을 넘어 자라 버린 배관 코드), 로더 레이스에 해당하는 것(가변 중간자를 신뢰한 검증)이 있었습니다. 공격자의 우위는 어느 한 단계의 천재성에 있지 않습니다. 그들은 체인으로 생각하고, 방어자는 컴포넌트로 생각한다는 데 있습니다.

그러니 이번 주 팀을 위한 연습 과제는 “누군가 우리 서명을 위조할 수 있을까?”가 아닙니다. 가장 신뢰받는 내부 서비스를 하나 고르고, 공격자가 그것과 통신하는 가장 덜 강화된 프로세스에 이미 임의 쓰기 권한을 가지고 있다고 가정한 다음, 체인을 앞으로 따라가 보세요. 첫 번째 권한 상승은 어디에 떨어질까요? 그 신원으로 무엇에 닿을 수 있을까요? 머지않아 여러분만의 fs sysmodule을 발견하게 될 것입니다. KVM과 블루투스 레버와 2주의 인내심을 가진 누군가보다는 여러분이 먼저 찾는 편이 낫습니다.