Go의 ARM64 컴파일러에서 버그를 발견한 과정

8 hours ago 1

  • Cloudflare는 arm64 플랫폼에서 동작하는 Go 컴파일러에서 발생하는 드문 경쟁 조건(레이스 컨디션) 버그를 대규모 트래픽 감시 중에 발견함
  • 이 버그는 스택 언와인딩(stack unwinding) 과정에서 서비스가 예기치 않게 패닉 상태가 되거나 메모리 접근 오류가 발생하는 방식으로 나타남
  • 원인 추적 과정에서 Go 런타임의 비동기 preemption(강제 선점)컴파일러가 생성한 두 개의 스택 포인터 조정 명령어 사이에서 문제가 발생함을 확인함
  • 최소 재현 코드를 통해 이 버그가 Go 런타임 자체 문제임을 입증하였고, 이로 인해 스택 포인터가 불완전하게 변경되는 한 명령어 크기의 경쟁 상태가 존재함을 밝혀냄
  • 해당 이슈는 go1.23.12, go1.24.6, go1.25.0 버전에서 패치되었고, 새 방식에서는 즉시 변경이 불가능한 스택 포인터 조작을 회피하여 레이스 컨디션이 근본적으로 차단됨

Cloudflare에서 찾은 Go ARM64 컴파일러 버그 분석

Cloudflare의 데이터센터는 전 세계 330여 도시에서 매초 8,400만 건의 HTTP 요청을 처리하는데, 이와 같은 대규모 트래픽 환경은 희귀한 버그조차 자주 노출되는 특징이 있음. 이 글은 arm64 플랫폼의 Go 컴파일러가 생성한 코드에서 발생한 경쟁 조건 문제를 실제 사례와 함께 자세히 분석하고 있음.

이상한 패닉 현상 조사

  • Cloudflare 네트워크 내에서는 Magic TransitMagic WAN 같은 상품 트래픽 처리를 Kernel에 설정하는 서비스가 동작 중임
  • arm64 머신에서 드물지만 반복적으로 fatal panic(치명적 패닉) 메시지가 모니터링 시스템에 감지됨
  • 초기 분석 결과, 스택 언와인딩 과정에서 무결성 위반이 감지됨(panic/recover 패턴을 사용하던 오래된 코드에서 패닉이 빈번하게 발생)
  • 일시적으로 panic/recover 구조를 제거하여 패닉 빈도를 줄였으나, 추후 의심스러운 치명적 패닉이 더 자주 발생하게 됨
  • 이에 따라 단순 패턴 추적 이상의 심층적인 원인 분석이 필요하다고 판단함

Go 런타임 및 스케줄러 자료구조 개요

  • Go는 경량 사용자 공간 스케줄러로 M:N 스케줄링 구조를 채택하고 있음(여러 고루틴을 소수 커널 스레드에 매핑)
  • 스케줄러의 핵심 구조체는 g(고루틴), m(머신/커널 스레드), p(프로세서) 중심으로 이뤄짐
  • 스택 언와인딩 실패나 메모리 접근 오류는 스택 포인터 혹은 리턴 주소가 비정상적으로 변화했을 때 발생함

스택 언와인딩 중 오류의 구조적 원인

  • 여러 백트레이스 분석 결과, 모두 (*unwinder).next 함수의 스택 언와인딩 과정에서 발생함
  • 한 경우는 return address가 null이라 비정상 스택으로 인식하여 치명적 오류로 종료, 다른 경우는 스택 프레임 내 go 스케줄러 구조체 m의 필드(incgo)에 접근하다 세그멘테이션 오류 발생
  • 크래시가 실제 버그 발생 지점에서 상당히 떨어진 위치에서 발생, 원인 추적이 까다로움

관찰된 패턴과 Go Netlink 라이브러리 연관성

  • Stack trace를 검토한 결과, 모두 Go Netlink 라이브러리의 NetlinkSocket.Receive 함수에서 preemption이 발생한 시점에 크래시가 집중적으로 발생함을 확인
  • 이후 두 가지 가설을 세움
    • Go Netlink의 unsafe.Pointer 사용에서 기인한 버그일 가능성
    • Go 런타임의 비동기 preemption 및 스택 언와인딩 자체에서 발생하는 버그일 가능성
  • 코드 감사를 진행했으나 직접적인 메모리 손상 패턴 등은 발견되지 않아, 문제의 핵심이 런타임과 스택 운용 전략에 있을 것으로 추정

비동기 Preemption과 경쟁 조건

  • Go 1.14부터 도입된 비동기 preemption 기능은 장시간 실행되는 고루틴에 대해 OS 스레드에 시그널(SIGURG)을 보내 강제로 스케줄링 포인트를 생성
  • 이 preemption이 스택 프레임 포인터를 조정하는 두 어셈블리 명령어 사이에서 발생하면, 스택 포인터가 중간 상태에 머무르게 됨
  • 가비지 컬렉션, 패닉 핸들링, 스택 트레이스 생성을 위해 스택을 언와인딩할 때 잘못된 위치를 읽어 잘못된 함수 주소나 데이터 해석이 일어남

최소 재현 코드 제작

  • 스택 프레임 할당 크기를 조절하고, 명시적으로 스택이 조정되는 함수(big_stack)와 상시 가비지 컬렉션 호출 코드를 작성하여 경쟁 조건이 재현됨
  • 실제로 어셈블리 코드에서 두 개의 ADD 명령어로 스택 포인터가 조정되고, 이 사이에서 비동기 preemption이 발생할 경우 스택 언와인딩 과정에서 크래시가 일어남
  • 이 결함은 순수 표준 라이브러리 코드만으로도 재현 가능하였으며, Go 컴파일러가 생성하는 코드에 내재된 횟수 단위(1 인스트럭션 크기)의 취약점임을 증명

ARM64 컴파일러 레벨 경쟁 윈도우의 원인

  • ARM64 아키텍처의 고정 길이 명령어 및 즉시값 제한 때문에 스택 포인터 조정에 두 개 이상의 명령어가 필요할 수 있음
  • Go의 내부 중간 표현(IR)에서는 이러한 즉시값 길이를 인지하지 않고, 실제 머신 코드 변환 시에만 분할 명령어가 삽입됨
  • 이 때문에 스택 프레임 반환(ADD RSP, RSP)에 두 개 명령어가 사용되고, preemption에 취약한 단일 인스트럭션 윈도우가 생김
  • 언와인더가 스택 포인터의 정확성을 절대적으로 필요로 하는데, 인스트럭션 중간에서 멈추면 잘못된 값 해석 및 치명적 실패 초래
  • 실제 크래시 플로우는 다음과 같이 구성:
    1. 두 ADD 명령어 사이에서 비동기 preemption 발생
    2. GC 또는 기타 원인으로 스택 언와인딩 루틴 동작
    3. 특이한 스택 포인터 위치 탐색 및 잘못된 함수 주소 해석
    4. 런타임 크래시

버그 수정 및 근본적 개선

  • Cloudflare 팀은 최소 재현 코드와 상세 분석 내용을 기반으로 Go 공식 저장소에 보고하였으며, 이슈는 신속하게 패치 및 릴리즈됨
  • go1.23.12, go1.24.6, go1.25.0 이후 버전에서는 임시 레지스터에 전체 오프셋을 먼저 계산한 후 단일 명령어로 스택 포인터를 변경, preemption 취약성을 제거
  • 이제 스택 포인터는 항상 유효한 상태로 보장되어, 경쟁 조건이 구조적으로 차단
LDP -8(RSP), (R29, R30) MOVD $32, R27 MOVK $(1<<16), R27 ADD R27, RSP, RSP RET

결론 및 시사점

  • 이 버그는 특정 아키텍처의 컴파일러 코드 생성동시성 관리(비동기 preemption) 가 예상하지 못한 방식으로 충돌한 사례임
  • 대규모 환경에서만 발현되는 매우 희귀한 인스트럭션 레벨 경쟁 조건을 실전 데이터 및 과학적 추론으로 추적해낸 점이 매력적인 사례임
  • 최신 Go 환경 및 ARM64 아키텍처 기반 서비스를 운영한다면, 관련 Go 버전으로 업그레이드가 중요함

Read Entire Article