- 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 Transit과 Magic 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에 취약한 단일 인스트럭션 윈도우가 생김
-
언와인더가 스택 포인터의 정확성을 절대적으로 필요로 하는데, 인스트럭션 중간에서 멈추면 잘못된 값 해석 및 치명적 실패 초래
- 실제 크래시 플로우는 다음과 같이 구성:
- 두 ADD 명령어 사이에서 비동기 preemption 발생
- GC 또는 기타 원인으로 스택 언와인딩 루틴 동작
- 특이한 스택 포인터 위치 탐색 및 잘못된 함수 주소 해석
- 런타임 크래시
버그 수정 및 근본적 개선
- 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 버전으로 업그레이드가 중요함