스크랩

반년간의 부팅 실패, 범인은 NX 비트였다 — ARM64 하이퍼바이저 디버깅 실화

반년간의 부팅 실패, 범인은 NX 비트였다 — ARM64 하이퍼바이저 디버깅 실화

한 줄 요약

ARM64 베어메탈 하이퍼바이저가 무작위로 멈추던 원인은 분기 예측이 만들어낸 추측적 명령어 인출이 실행 가능하게 매핑된 MMIO 영역을 건드렸기 때문이었습니다. MMIO 페이지에 NX(비실행) 비트를 적용하자, 반년 만에 Android가 완전히 부팅됐습니다.

우리는 보통 NX 비트(DEP)를 "스택 오버플로 공격을 막는 보안 기능"으로만 생각합니다. 하지만 ARM에서 실행 권한은 메모리 접근 동작 자체를 결정하는 기능적 속성이기도 합니다. 이 글은 그 사실을 반년에 걸친 디버깅 끝에 깨달은 한 개발자의 기록입니다.

발단: CTR_EL0 인터셉트를 켜니 폰이 멈춘다

postmarketOS용 ARM64 베어메탈 하이퍼바이저에서 CTR_EL0 인터셉트를 활성화하자 이런 일이 벌어졌습니다.

  • 전화기가 무작위로 멈추고, 몇 초 뒤 watchdog이 시스템을 재시작
  • watchdog을 꺼도 부팅이 안 됨 → 단순한 부팅 지연이 아님
  • 이 인터셉트는 하이퍼바이저의 핵심 목적이라 "그냥 끄기"는 선택지가 아님

첫 번째 가설은 예외 처리기 문제였습니다. AArch64의 특수 기능 레지스터(SFR)는 MRS/MSR로 범용 레지스터와 값을 주고받고, 나머지 레지스터는 그대로 보존해야 합니다. 그래서 "처리기가 보존 대상 레지스터를 망가뜨리거나 결과 레지스터를 갱신하지 못하는 것"을 의심했죠.

트램펄린과 스택 할당을 검토하고 QEMU에서 처리기 전체를 한 단계씩 실행했지만 — 예상대로 잘 동작했습니다. 그런데 실제 하드웨어에서는 의도한 출력 레지스터를 포함해 어떤 레지스터도 변경되지 않았습니다.

첫 번째 버그: 명령어 캐시와 데이터 캐시의 비일관성

진짜 버그를 만나기 전, 곁다리 버그가 하나 나왔습니다.

실행 가능한 기계어가 담긴 msr_accessors 배열을 런타임에 정렬하고 있었던 겁니다. ARM은 I-cache와 D-cache의 일관성을 자동으로 유지해주지 않습니다. 그래서 이런 일이 생깁니다.

  • 데이터로 쓴 변경분이 아직 RAM에 반영되지 않음
  • 명령어 캐시에는 이전 코드가 그대로 남아 있음

정렬을 빌드 단계로 옮기자 실제 하드웨어에서도 처리기가 올바르게 동작했습니다. 하지만 시스템은 여전히 부팅되지 않았습니다. 진짜 범인은 따로 있었죠.

예외도, 커널 패닉도 아니었다

ARM 구현은 공급업체가 참조 아키텍처를 수정하거나 자체 구현할 수 있습니다. 그래서 다음 용의자는 MediaTek CPU의 명세 위반 또는 하드웨어 오류였습니다.

추적 시도 1 — 모든 예외 출력하기

벡터 처리기를 추가해 X30, ELR_EL2를 비롯한 SFR을 전부 기록했습니다. 결과는? unknown_trap이 단 한 번도 실행되지 않았습니다. 예외가 아니라는 뜻입니다.

추적 시도 2 — 커널 패닉 확인

/proc/last_kmsg를 뒤졌더니 마지막 로그는 e2fsck의 /dev/urandom 읽기였습니다. 커널에서 /dev/urandom을 제거하자 부팅이 조금 더 진행된 뒤 또 멈췄고요. 즉, 커널이 정상적인 패닉 경로로 죽은 게 아니었습니다.

추적 시도 3 — 커널 바이너리 직접 패치

objdump로 커널의 CTR_EL0 접근 22곳을 찾아 올바른 값을 반환하도록 바이너리를 직접 고쳤습니다. Android까지 부팅되진 않았지만 adb shell은 작동했습니다. 문제를 소수의 "hot" 명령어로 좁히는 데는 성공했지만, 멈춤의 원인은 여전히 오리무중이었습니다.

결정적 단서: C냐 어셈블리냐에 따라 갈리는 결과

하이퍼바이저와 커널의 메모리 충돌을 의심해, mrs x3, ctr_el0 처리를 메모리를 전혀 쓰지 않는 어셈블리로 다시 작성했습니다. 정상 동작했습니다.

같은 처리를 C 코드로 단계적으로 축소해보니, 호출 방식에 따라 결과가 갈렸습니다.

| 호출 방식 | 결과 | |---|---| | 인라인 mrs | 정상 | | 링커 심볼 get_ctr_el0를 통한 호출 | 정상 | | msr_accessors+5를 함수 포인터로 변환한 호출 | 실패 |

가능성을 하나씩 지워나갔습니다.

  • 링크 기준 주소와 실제 적재 주소를 같게 맞춰도 그대로 → 재배치 오류 아님
  • 문제 코드를 별도의 noinline, optimize(3) 함수로 분리해도 재현 → 단일 함수만 보면 됨
  • 함수 앞에 NOP을 여러 개 넣어도 실패 → 코드 크기 문제도 아님

bl vs blr: 단 하나의 명령어 차이

두 바이너리를 어셈블리 수준에서 나란히 놓고 통합한 결과, 성공과 실패를 가르는 차이는 호출 명령 하나였습니다.

  • bl get_ctr_el0 — 정적 직접 호출 → 정상 동작
  • blr x0 — 레지스터 주소를 쓰는 동적 간접 호출 → 실패

두 코드는 의미상 완전히 동일합니다. 남은 후보는 특정 순간의 레지스터 훼손, 또는 마이크로아키텍처 버그뿐이었습니다.

그런데 레지스터 복원 실패는 배제할 수 있었습니다. ARM64 하이퍼바이저보다 높은 권한은 TrustZone뿐이고, 인터럽트를 받을 이유도 없었으니까요. 대상 SoC는 Cortex-A53 코어를 쓰는 MediaTek MT6735였지만, Cortex-A53 공식 오류 목록에도 비슷한 사례는 없었습니다.

진범: 분기 예측이 가리킨 잠긴 bootrom

실마리는 엉뚱한 곳에서 나왔습니다. Linux 소스의 한 주석 — "일부 x86 시스템에서는 특정 MMIO 레지스터에 대한 추측적 접근만으로 시스템이 종료될 수 있다."

그렇다면 그 주소에 접근하지 못하게 막으면 되지 않을까요? 두 가지 회피책을 검토했지만 모두 막혔습니다.

  • 해당 주소를 아예 매핑하지 않기 → 하이퍼바이저가 전체 주소 공간의 1:1 매핑을 전제로 MMIO를 사용하고 있어 적용 곤란
  • 지연 페이징(실제 접근된 메모리만 폴트 후 매핑) → CPU는 추측적 접근에 페이지 폴트를 발생시키지 않음

여기서 퍼즐이 맞춰집니다.

  1. 직접 분기와 달리 간접 분기는 분기 예측을 사용한다 → 잘못된 대상 주소가 선택될 수 있다
  2. blr x0이 0x0으로 잘못 예측됐을 가능성에 도달
  3. 1:1 주소 매핑의 0x0에는 부트로더 초기화 중 잠긴 bootrom이 있었다

핵심은 이것입니다. 문제가 된 추측적 접근은 데이터 읽기가 아니라 명령어 인출이었습니다.

해결: MMIO 페이지에 NX 비트를 적용하다

MMIO 페이지를 비실행으로 표시하자, 커널을 전혀 수정하지 않고도 Android가 완전히 부팅됐습니다. 반년 만의 첫 성공이었습니다.

ARM의 Device memory 문서는 두 속성의 역할을 명확히 구분합니다.

  • Device 속성 → 추측적 데이터 접근만 방지
  • 비실행(NX) 속성 → 추측적 명령어 접근을 차단
  • 모든 추측적 접근을 막으려면 → Device이면서 동시에 비실행으로 지정해야 함

이유는 명확합니다. ARM에서 실행 가능하게 지정된 메모리는 명령어 인출 시 Normal memory처럼 취급됩니다. 그래서 Device 속성만으로는 추측적 실행을 막을 수 없습니다.

교훈: 실행 권한은 보안 기능만이 아니다

이 개발자가 DEP를 처음에 구현하지 않은 이유는 이랬습니다. "DEP는 스택 오버플로 공격을 막는 보안 기능이고, 프로덕션용도 아닌 하이퍼바이저에는 불필요한 방어 심층화(defense in depth)다."

하지만 진실은 달랐습니다.

ARM의 NX 비트는 보안 장치일 뿐 아니라, 추측적 명령어 접근을 제어하는 메모리 속성으로 작동합니다.

DEP를 순수한 보안 기능으로만 바라보면, 이런 하드웨어 레벨의 기능적 오류를 놓치게 됩니다.

참고로 Device memory에서 반드시 코드를 실행해야 한다면 실행 중 I-cache를 끄는 우회책을 떠올릴 수 있지만, ARM 문서는 이 접근 역시 불법으로 규정하고 사용을 권하지 않습니다.

결말은 깔끔합니다. 비실행 매핑을 구현한 뒤, 이 개발자는 payload를 제외한 모든 메모리를 비실행으로 지정했습니다. 기능적 버그를 잡으려다 하이퍼바이저의 방어 심층화까지 함께 강화한 셈이죠.

정리

  • 간접 분기(blr)는 분기 예측을 타고, 예측이 빗나가면 엉뚱한 주소에서 명령어를 인출할 수 있습니다
  • 그 주소가 민감한 MMIO나 잠긴 영역이라면, 실행되지 않은 추측만으로 시스템이 멈출 수 있습니다
  • ARM에서 이를 막는 방법은 Device 속성 + 비실행 속성의 조합입니다
  • "보안을 위한 기능"이라고 배운 것이, 사실은 정확성을 위한 필수 설정일 수 있습니다

원문: https://news.hada.io/topic?id=33250