처리중입니다. 잠시만 기다려주세요.
TTJ 코딩클래스
정규반 단과 자료실 테크 뉴스 코딩 퀴즈
테크 뉴스
Hacker News 2026.09.07 28

ARM에서 NX 비트는 보안이 아니라 동작의 문제였다

Hacker News 원문 보기

저수준 프로그래밍은 x86과 ARM에서 겉보기엔 비슷해 보이지만, 실제로는 미묘한 아키텍처 차이가 재현하기 힘든 버그로 이어지곤 한다. 이번에 소개하는 사례는 개발자 Sonya가 postmarketOS를 위한 베어메탈 하이퍼바이저를 ARM64에서 개발하다 겪은 디버깅 여정이다. 반년 가까이 사람을 괴롭힌 이 버그의 정체는 뜻밖에도 우리가 흔히 '보안 기능'으로만 알고 있던 NX(실행 방지) 비트와 맞닿아 있었다.

재현되지만 설명되지 않는 멈춤

문제의 출발점은 CTR_EL0 레지스터 접근을 가로채는 인터셉트였다. 이 인터셉트는 하이퍼바이저의 존재 이유 자체였기에 끌 수 없는 기능이었는데, 이를 활성화하면 휴대폰이 무작위로 멈춰버렸다. 몇 초 뒤 워치독이 개입해 시스템을 리셋했다. 처음에는 부팅이 너무 느려져 시간 안에 올라오지 못하는 것으로 의심했지만, 워치독을 꺼도 상황은 나아지지 않았다. AArch64에서 CTR_EL0 같은 특수 레지스터는 MRS/MSR 명령으로 접근하며, 이 명령은 지정한 레지스터 사이에서만 값을 옮기고 다른 레지스터는 건드리지 않는다. 따라서 문제는 보존해야 할 레지스터를 망가뜨렸거나 출력 레지스터를 제대로 쓰지 못한 데 있어야 했다.

예외 핸들러 트램펄린을 검토하고 QEMU에서 한 명령씩 단계 실행해봐도 동작은 정확했다. 그런데 실제 하드웨어에 디버그 출력을 넣어보니, 핸들러가 의도한 출력 레지스터조차 전혀 수정하지 않고 있었다. 중간에는 ARM이 명령 캐시와 데이터 캐시 간 일관성을 보장하지 않는다는 점 때문에, 실행 가능한 기계어가 담긴 버퍼를 정렬한 뒤 실행하려다 실패하는 별도의 문제도 있었다. 정렬을 빌드 단계로 옮겨 그 부분은 해결했지만 시스템은 여전히 부팅되지 않았다.

하드웨어 탓인가, 코드 탓인가

x86 계열은 인텔과 AMD 두 벤더만이 만들기에 동작이 일관적이지만, ARM은 참조 구현을 벤더가 자유롭게 변형하거나 독자 구현할 수 있어 미묘한 버그가 흔하다. 그래서 CPU가 규격을 벗어난 게 아닌지 의심했다. 모든 예외를 출력하도록 핸들러를 추가했지만 어떤 예외도 발생하지 않았다. /proc/last_kmsg로 커널 로그를 읽어보니 커널이 깨끗하게 죽은 것도 아니었다. /dev/urandom을 의심해 커널에서 제거하자 부팅이 조금 더 진행됐지만 거기까지였다.

objdump로 의심스러운 명령을 걸러내니 22곳이 나왔고, 이를 수동으로 패치해 올바른 값을 반환하게 하자 안드로이드까지는 못 가도 adb 셸은 동작했다. 이후 문제를 몇 개의 '핫한' 명령으로 좁힐 수 있었다. 결정적으로, 메모리를 전혀 쓰지 않는 순수 어셈블리로 CTR_EL0 처리를 다시 작성하자 정상 동작했다. 로직 자체는 옳고 C 핸들러가 문제라는 뜻이었다. 여기서부터는 의미상 완전히 동일한데 하나는 되고 하나는 안 되는 두 버전을 놓고 바이너리 이분 탐색이 이어졌다. 함수 크기를 의심해 앞에 NOP을 잔뜩 넣어봐도, 재배치 코드를 손봐도 소용없었다.

정적 분기와 동적 분기의 차이

남은 가능성은 두 가지였다. 정확히 그 순간 누군가 코드를 가로채고 레지스터를 복원하지 못하거나, 혹은 마이크로아키텍처 버그거나. ARM64에서 하이퍼바이저보다 높은 권한은 트러스트존뿐이고 인터럽트를 받을 이유가 없었기에 후자로 가정했다. 문제의 SoC는 Cortex-A53 코어를 쓰는 미디어텍 MT6735였는데, 동료 Alisa와 Cortex-A53 정오표를 훑어도 비슷한 항목은 없었다.

실마리는 리눅스 소스의 한 주석에서 왔다. 일부 x86 시스템에서는 특정 MMIO 레지스터에 대한 투기적(speculative) 접근이 시스템을 꺼버린다는 내용이었다. 두 버전의 차이는 단순했다. bl get_ctr_el0은 정적 분기라 목적지가 정해져 있어 오예측이 불가능하지만, blr x0은 동적 디스패치라 분기 예측을 사용하고 오예측할 수 있다. 그렇다면 어디로 잘못 예측할까. 허공에서 주소를 만들어낼 수는 없으니 십중팔구 널 포인터 0x0이다. 그리고 1:1 매핑된 0x0에는 부트로더 초기화 중 잠기는 부트롬이 자리하고 있었다.

핵심은 이 투기적 접근이 데이터 접근이 아니라 명령 인출(instruction fetch)이었다는 점이다. 즉 해당 페이지를 실행 불가능(non-executable)으로 표시하기만 하면 됐다. 그렇게 하자 커널에 어떤 패치도 없이 시스템이 반년 만에 처음으로 안드로이드까지 완전히 부팅됐다. 나중에 'arm64 speculative instruction fetch mmio'로 검색하니 ARM 공식 문서가 이를 명확히 설명하고 있었다.

실무적 교훈

ARM 문서의 요지는 놓치기 쉬운 구분에 있다. 영역을 Device 메모리로 표시하면 투기적 '데이터' 접근만 막힌다. 명령 인출은 실행 가능한 메모리를 Normal 메모리처럼 취급하기 때문에, 투기적 접근을 완전히 막으려면 해당 영역을 Device인 동시에 non-executable로 표시해야 한다. 스택 오버플로 공격을 막는 보안 장치로만 여겨지던 DEP/NX 비트가, ARM에서는 실은 하드웨어 동작을 규정하는 속성 비트에 가깝다는 뜻이다. 그동안 프로덕션용이 아니라며 방어 계층을 과설계로 치부하고 구현하지 않았던 것이 오히려 발목을 잡은 셈이다.

이 사례가 주는 교훈은 분명하다. MMIO를 1:1로 매핑하는 저수준 코드에서는 실제로 실행할 페이로드를 제외한 모든 영역을 실행 불가능으로 설정하는 것이 안전하다. 벤더마다 구현이 갈리는 ARM 환경에서는 '설마 하드웨어가 그럴까' 하는 지점이 실제 원인일 때가 많고, 재현되지만 설명되지 않는 멈춤 앞에서는 투기적 실행과 명령 인출의 경계를 의심해볼 만하다. 결과적으로 이 개발자는 실행 불가능 메모리 매핑을 구현하는 김에 페이로드 외 전 영역을 non-executable로 만들어, 애초에 필요 없다고 봤던 방어 계층까지 덤으로 얻게 됐다.

이 뉴스가 유용했나요?

TTJ 코딩클래스 정규반

월급 외 수입,
코딩으로 만들 수 있습니다

17가지 수익 모델을 직접 실습하고, 1,300만원 상당의 자동화 도구와 소스코드를 받아가세요.

144+실전 강의
17개수익 모델
4.9수강생 평점
정규반 자세히 보기

"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"

실제 수강생 후기
  • 비전공자도 6개월이면 첫 수익
  • 20년 경력 개발자 직강
  • 자동화 프로그램 + 소스코드 제공

매일 AI·개발 뉴스를 받아보세요

주요 테크 뉴스를 매일 아침 이메일로 전해드립니다.

스팸 없이, 언제든 구독 취소 가능합니다.