GhostLock, 15년간 모든 Linux 배포판에 존재한 스택 UAF
3 hours ago
2
- GhostLock(CVE-2026-43499) 은 Linux 2.6.39에 도입돼 7.1에서 수정된 커널 취약점으로, 비특권 로컬 공격자가 일반적인 스레딩 시스템 호출만으로 스택 UAF를 일으켜 루트 권한 획득과 컨테이너 탈출에 이용할 수 있음
- Requeue-PI 프록시 경로의 remove_waiter()가 실제 대기 태스크 대신 current의 pi_blocked_on을 지워, 사용자 공간으로 복귀한 태스크에 해제된 스택 프레임을 가리키는 포인터가 남음
- 세 futex와 세 스레드로 PI 의존성 순환을 만들어 -EDEADLK 롤백을 유도하고, PR_SET_MM_MAP의 제어 가능한 스택 버퍼에 가짜 rt_mutex_waiter를 구성해 제약된 포인터 쓰기를 확보함
- 익스플로잇은 prefetch로 KASLR·physmap 기준 주소를 찾고 CPU entry area(CEA)에 가짜 구조체와 ROP 스택을 배치한 뒤, inet6_protos[IPPROTO_UDP]를 덮어 IPv6 UDP 루프백 패킷으로 제어 흐름을 탈취함
- 연구진은 97% 안정적인 권한 상승·컨테이너 탈출 익스플로잇으로 Google kernelCTF에서 $92,337을 받았으며, 패치되지 않은 모든 Linux 배포판은 최신 LTS로 업그레이드해야 함
영향 범위와 취약점 개요
- GhostLock은 VEGA가 발견한 Linux 커널 취약점으로, 별도 권한이나 사용자 네임스페이스 없이 로컬 비특권 사용자가 트리거할 수 있음
- 8161239a8bcc의 rtmutex 재작업으로 도입됐으며, 영향 범위는 v2.6.39-rc1부터 v7.1-rc1까지임
- 2026년 4월 3bfdc63936dd에서 수정됐고, 필요한 커널 설정은 CONFIG_FUTEX_PI=y뿐임
- 공격자는 다음 과정을 거쳐 권한을 상승시킬 수 있음
- 일반적인 스레딩 시스템 호출만으로 커널 스택 메모리를 가리키는 댕글링 커널 포인터를 얻음
- 거의 임의의 주소에 포인터 또는 8바이트 0을 쓸 수 있는 제약된 프리미티브를 만듦
- 함수 테이블을 탈취해 제어 흐름을 장악하고 루트 권한을 획득함
- 패치되지 않은 모든 Linux 배포판이 영향을 받으므로 최신 LTS 버전으로 업그레이드해야 함
remove_waiter()가 잘못된 태스크를 정리하는 원인
- kernel/locking/rtmutex.c의 remove_waiter()는 원래 블록된 스레드가 자신의 대기 상태를 직접 정리하는 경로를 위해 작성됨
- 정상적인 느린 경로에서는 실행 중인 current가 waiter의 소유 태스크이므로 current->pi_blocked_on을 지우는 동작이 올바름
- Requeue-PI 프록시 경로에서는 rt_mutex_start_proxy_lock()이 다른 수면 중인 태스크를 대신해 rt_mutex_waiter를 큐에 넣고, 오류가 발생하면 이를 롤백함
- 이때 current는 FUTEX_CMP_REQUEUE_PI를 호출한 requeuer임
- 실제 waiter는 FUTEX_WAIT_REQUEUE_PI에서 잠든 별도의 태스크임
- __rt_mutex_start_proxy_lock()이 -EDEADLK를 반환하면 remove_waiter()는 waiter를 lock에서 제거하면서도 current->pi_blocked_on만 NULL로 만듦
- 실제 waiter의 pi_blocked_on은 자신의 커널 스택에 있는 rt_mutex_waiter를 계속 가리키며, waiter가 사용자 공간으로 복귀하면 해당 스택 프레임은 해제된 것으로 간주됨
- 이후 PI 체인 탐색이 해당 태스크를 통과하는 순간 해제된 스택 객체를 역참조함
- lockdep은 어떤 pi_lock이 잡혔는지만 확인하고 누구의 lock인지는 검사하지 않아 이 오류를 잡지 못함
-EDEADLK 롤백을 만드는 세 futex 순환
- 오류 경로에 도달하려면 세 futex와 세 스레드로 PI 의존성 순환을 구성함
- f_pi_chain: waiter가 먼저 잠그는 PI futex
- f_pi_target: owner가 먼저 잠그고 requeue 대상이 되는 PI futex
- f_wait: waiter가 FUTEX_WAIT_REQUEUE_PI로 대기하는 일반 futex
- 트리거 순서는 다음과 같음
- waiter가 f_pi_chain을 잠근 뒤 FUTEX_WAIT_REQUEUE_PI(f_wait -> f_pi_target)에서 블록되고, rt_mutex_waiter가 자신의 커널 스택에 놓임
- owner가 f_pi_target을 잠근 뒤 waiter가 보유한 f_pi_chain에서 블록됨
- main 스레드가 FUTEX_CMP_REQUEUE_PI(f_wait -> f_pi_target)를 호출함
- 프록시 requeue가 waiter를 f_pi_target에 연결하려 하면 waiter → f_pi_target → owner → f_pi_chain → waiter 순환이 닫힘
- PI 체인 탐색이 -EDEADLK 를 반환하고 잘못된 롤백을 실행하면서, waiter는 댕글링 pi_blocked_on을 가진 채 깨어남
- 중요한 조건은 waiter가 아직 스택 객체를 보유할 때 requeuer가 롤백하는 것이며, 순환이 완성된 뒤에는 과정이 자체적으로 진행됨
- waiter가 사용자 공간으로 돌아오면 더는 시간 압박이 없고, 나중에 언제든 sched_setattr()로 체인 탐색을 일으킬 수 있음
- 구성에는 세 스레드를 사용하지만 UAF 경쟁 자체는 CPU 코어 하나에서도 트리거 가능함
스택 UAF가 제공하는 초기 프리미티브
- 댕글링 포인터는 이전 FUTEX_WAIT_REQUEUE_PI 프레임에 있던 rt_mutex_waiter를 가리킴
- 같은 태스크의 동일한 스택 깊이에 제어 가능한 바이트를 다시 배치하면, 커널이 이를 가짜 rt_mutex_waiter로 역참조하게 만들 수 있음
- 가짜 구조체를 어떻게 배치하느냐에 따라 한 번의 접근으로 두 가지 주요 프리미티브를 얻음
- 제약 조건이 있는 거의 임의의 주소에 포인터를 쓸 수 있음
- 제약 조건이 있는 거의 임의의 주소에 8바이트 0을 쓸 수 있음
- 쓰기 전에 여러 포인터 역참조와 무결성 검사가 수행되지만, 조건을 충족하면 쓰기 이후에도 커널이 충돌하지 않고 정상 복귀함
- 익스플로잇을 완성하려면 스택 프레임 재사용, 가짜 waiter의 구조 검사 통과, 쓰기 제약을 만족하는 대상 선정이 모두 필요함
PR_SET_MM_MAP으로 해제된 스택 프레임 재사용
- waiter는 futex 시스템 호출에서 돌아오자마자 prctl(PR_SET_MM, PR_SET_MM_MAP, ...)을 호출함
- prctl_set_mm_map()은 사용자가 제공한 auxv를 고정 크기 스택 버퍼 unsigned long user_auxv[AT_VECTOR_SIZE]에 복사함
- 이 버퍼는 해제된 waiter와 비슷한 스택 깊이에 놓이므로, 크고 정렬된 제어 가능 qword 블록이 이전 rt_mutex_waiter 위에 겹침
- auxv의 중첩 영역은 다음과 같이 구성함
- tree: 삭제할 때 선택한 자식 포인터 W0_BASE를 트리 루트로 올리는 rb 노드로 만듦
- task: 체인 탐색의 역참조를 안전하게 통과하도록 &init_task로 설정함
- lock: 쓰기 대상을 맞추기 위해 &inet6_protos[IPPROTO_UDP] - 8로 지정함
- wake_state: 0으로 설정함
- auxv를 memfd에 두고 복사가 페이지 경계를 넘도록 배치한 다음, 형제 스레드가 prctl 실행 중 뒤쪽 페이지에 fallocate(PUNCH_HOLE) 경쟁을 걸어 copy_from_user 시간을 늘림
- 다른 CPU의 consumer 스레드는 가짜 waiter가 스택에 남아 있는 동안 waiter에 sched_setattr()를 호출해 PI 체인을 탐색함
- clone, setsockopt, pselect, keyctl처럼 제어 가능한 큰 스택 지역 변수를 사용하는 다른 시스템 호출도 같은 역할을 할 수 있음
- prctl은 버퍼가 크고 정렬돼 있으며 네임스페이스가 필요하지 않아 선택됐고, 추가 후보는 공개 PoC 코드에 포함돼 있음
rb-tree 삭제로 제약된 포인터 쓰기 만들기
- 가짜 waiter를 제어해도 즉시 완전한 임의 쓰기가 생기지는 않으며, 체인 탐색은 다음 경로를 실행함
- task->pi_blocked_on에서 가짜 waiter를 찾음
- fake waiter->lock에서 가짜 rt_mutex_base를 찾음
- rt_mutex_dequeue(lock, waiter)가 lock->waiters에서 rb-tree 삭제를 수행함
- 자식이 하나뿐인 루트 노드를 삭제하면 해당 자식이 루트 슬롯에 기록되는 성질을 이용함
- lock을 target - 8로 지정하면 주변 데이터가 다음 rt_mutex_base 필드로 해석됨
- target - 8: 잠기지 않은 상태로 읽혀야 하는 wait_lock
- target: 덮어쓸 waiters.rb_root.rb_node
- target + 8: waiters.rb_leftmost
- target + 16: owner
- 결과적으로 실행되는 단일 쓰기는 *(uint64_t *)target = W0_BASE 임
- 대상 주소는 대략 다음 조건을 만족해야 함
- target - 0x08의 하위 32비트가 0이어야 함
- target + 0x08의 64비트 값이 0이어야 함
- target + 0x10의 owner 포인터에서 하위 플래그를 제외한 값이 0이어야 함
- 앞쪽 qword가 잠긴 spinlock처럼 보이면 trylock이 실패해 아무것도 쓰지 않고 종료됨
- 뒤쪽 값이 제어되지 않은 top waiter나 owner를 가리키거나 매핑되지 않은 값이면 커널 패닉이 발생할 수 있음
- W0_BASE는 비교, 재큐잉, 우선순위 갱신, owner 없는 wakeup이 끝날 때까지 유효해야 하므로 CEA의 direct-map 별칭을 사용함
prefetch 누출과 CPU entry area
-
KASLR·physmap 기준 주소 찾기
- 특정 주소에 대한 prefetch 실행 시간은 현재 페이지 테이블에 그 주소가 매핑됐는지에 따라 달라짐
- 비특권 프로세스가 커널 주소 범위의 실행 시간을 측정하면 매핑 위치를 추정할 수 있으며, 세부 원리는 prefetch 논문에 정리돼 있음
- 기본 Linux 커널 이미지 기준 주소의 엔트로피가 약 9비트여서 반복 측정으로 KASLR 기준 주소를 거의 100% 신뢰도로 복구함
- 이론상 prefetch가 있고 적절한 KPTI가 없는 CPU가 영향을 받지만, 실질적으로는 KPTI가 꺼진 x86에서 주로 쓰는 기법임
- kernelCTF 이미지는 KPTI가 꺼져 있으며, KPTI가 켜져 있어도 prefetch와 EntryBleed를 결합하면 trampoline을 통해 커널 이미지 기준 주소를 복구할 수 있음
-
CEA 주소 무작위화 우회
- CPU entry area(CEA) 는 진입·예외 처리용 스택과 레지스터 문맥을 보관하는 x86 CPU별 구조체임
- 비특권 프로그램이 소프트웨어 예외를 일으키면 자체 레지스터 문맥을 CEA 예외 스택의 pt_regs에 기록해 약 120바이트의 연속된 제어 가능 메모리를 만들 수 있음
- Linux 6.2 이전에는 CEA 가상 주소가 완전히 고정돼 가짜 구조체, 포인터 역참조의 부작용 흡수, ROP 스택 구성에 바로 사용할 수 있었음
- Project Zero의 Bringing back the stack attack 공개 이후 Linux 6.2부터 CEA 가상 주소가 강하게 무작위화됨
- 각 CPU의 CEA 가상 주소는 서로 다르게 무작위화되지만 물리 주소는 고정돼 있으므로, physmap 기준 주소를 알면 direct-map 별칭을 계산할 수 있음
- prefetch, 후보 경계 정규화, 예상 CEA 페이지 검사를 결합해 주변 별칭을 배제하고 cea_direct = physmap_base + CPU1_CEA_BASE를 구함
- kernelCTF LTS 6.12.80의 3.5GB 부팅 환경에서 관련 오프셋은 0x11c517000(+0x1f58)임
CEA를 가짜 waiter와 후속 객체로 재사용
- 첫 번째 쓰기 전에 CEA의 W0에 자기 일관성을 갖춘 가짜 waiter와 lock을 배치함
- task는 &init_task로 설정함
- prio에는 유효한 값을 넣음
- lock의 wait_lock은 잠기지 않은 상태로 보이게 만듦
- owner는 dequeue, 재큐잉, 우선순위 갱신, wakeup을 안전하게 통과하도록 구성함
- rb-tree 쓰기가 끝나면 W0는 더 이상 waiter일 필요가 없으므로, 덮어쓴 대상이 요구하는 구조체로 CEA를 다시 채울 수 있음
- CEA는 약 120바이트로 작지만, 계산 가능한 고정 커널 주소에 데이터를 배치할 수 있어 효율적임
- NPerm과 kernelsnitch 등은 더 넓은 공간에서 같은 역할을 수행할 수 있음
- 익스플로잇은 하나의 CEA 영역을 가짜 rt_mutex_waiter, 가짜 lock, inet6_protocol, JOP·스택 피벗 슬롯, 최종 ROP 스택으로 순차 또는 동시에 활용함
inet6_protos[IPPROTO_UDP]로 제어 흐름 탈취
- 일반적인 x86_64 Linux에서는 KASLR 기준 주소를 확보한 뒤, 조건에 맞는 함수 테이블이나 이를 포함한 객체를 덮는 짧은 경로를 선택할 수 있음
- 쓰기 가능한 데이터 영역의 inet6_protos[IPPROTO_UDP] 주변은 필요한 제약을 자연스럽게 만족함
- inet6_protos[16] == NULL은 가짜 wait_lock의 잠기지 않은 상태가 됨
- inet6_protos[17] == &udpv6_protocol은 실제 덮어쓸 대상임
- inet6_protos[18] == NULL은 가짜 rb_leftmost가 됨
- inet6_protos[19] == NULL은 가짜 owner가 됨
- 쓰기가 끝나면 inet6_protos[IPPROTO_UDP]는 CEA 페이지 안의 가짜 inet6_protocol을 가리킴
- CEA를 다시 분사해 구조체를 다음과 같이 구성함
- handler: 첫 번째 피벗 가젯으로 지정함
- err_handler: 사용하지 않음
- flags: INET6_PROTO_NOPOLICY | INET6_PROTO_FINAL로 설정함
- ::1에 connect한 뒤 데이터를 쓰는 IPv6 UDP 루프백 패킷을 보내면 커널이 가짜 handler를 호출해 프로그램 카운터를 제어할 수 있음
짧은 피벗과 DirtyMode 권한 상승
- Google kernelCTF의 lts-6.12.80 대상에서는 적합한 단일 스택 피벗 가젯을 찾지 못해, 추가 load/call로 CEA 주소를 rbp에 넣은 뒤 mov rsp, rbp; pop rbp; ret로 피벗함
- ret2usr나 /proc/%P/fd/x 전체 덮어쓰기는 약 10개 가젯 qword가 필요해 제한된 CEA 공간에 비해 너무 큼
- 최종 단계에는 한 번의 쓰기로 권한 비트를 바꾸고 나머지 과정을 사용자 공간에서 수행하는 DirtyMode를 사용함
- 쓰기 대상은 커널 데이터의 coredump_sysctls[1].mode, 즉 core_pattern sysctl의 접근 모드임
- 커널 이미지와 같은 KASLR 슬라이드를 공유하므로 주소를 계산할 수 있으며, 쓰기 비트인 두 번째 최하위 비트가 설정된 값이면 충분함
- 짧은 pop reg; mov [reg], reg; ret 체인으로 모드 값을 바꾸고 msleep으로 탈취한 스레드를 안전하게 정지시킴
- /proc/sys/kernel/core_pattern을 모든 사용자가 쓸 수 있게 되면 비특권 프로세스가 |/proc/%P/fd/666 %P를 기록하고 helper를 충돌시켜, 커널이 공격자의 바이너리를 루트 권한으로 실행하게 함
- 초기 rb-tree 쓰기는 배치 제약 때문에 coredump_sysctls[1].mode에 직접 도달할 수 없어, 모드 변경은 짧은 ROP 단계에서 수행함
전체 익스플로잇 흐름과 결과
- 공격은 다음 순서로 진행됨
- prefetch로 커널 이미지 슬라이드와 physmap 기준 주소를 누출함
- GhostLock으로 waiter의 pi_blocked_on에 댕글링 rt_mutex_waiter를 남김
- PR_SET_MM_MAP으로 같은 커널 스택 프레임을 재사용해 가짜 waiter를 만듦
- rtmutex rb-tree 삭제를 이용해 inet6_protos[IPPROTO_UDP]에 CEA 포인터를 기록함
- CEA에 가짜 inet6_protocol, 피벗 슬롯, ROP 스택을 배치함
- IPv6 UDP 루프백 패킷으로 덮어쓴 handler를 호출함
- DirtyMode로 core_pattern의 모드 비트를 바꾸고 사용자 공간에서 권한 상승을 마침
- kernelCTF 원격 환경에서 CEA와 DirtyMode를 조합한 경로는 약 5초 만에 플래그를 획득함
- 전체 익스플로잇은 CyberMeowfia 프로젝트에 공개돼 있음
- Android에서는 스택 프레임 재사용과 ASLR·CFI 우회 방식이 달라지며, 별도의 후속 글에서 다룰 예정임
대안 경로와 완화책
-
더 큰 ROP 공간
- NPerm 기반 메모리는 제어 흐름을 탈취한 뒤 큰 가짜 스택으로 사용할 수 있음
- Lukas Maar의 heap-KASLR 누출처럼 더 무거운 경로도 가능하지만, 단계가 추가돼 실행 시간이 늘어남
- kernelCTF에서는 가장 짧고 신뢰성 높은 체인이 유리하므로 CEA와 DirtyMode 조합을 사용함
-
커널 패치
- 최종 패치는 current 대신 waiter->task 를 기준으로 pi_lock을 잡고 pi_blocked_on을 지움
- remove_waiter()는 waiter_task = waiter->task를 저장한 뒤 다음 순서로 처리함
- waiter_task->pi_lock을 잠금
- waiter를 rtmutex 큐에서 제거함
- waiter_task->pi_blocked_on = NULL로 설정함
- 후속 rt_mutex_adjust_prio_chain()에도 current 대신 waiter_task를 전달함
- 연구진이 v1 이전에 보낸 별도 수정안은 호출자가 소유 태스크를 명시적으로 전달하도록 구성함
- 자기 자신이 블록되는 경로에서는 current를 전달함
- 프록시 롤백에서는 프록시 대상 task를 넘김
- pi_blocked_on이 여전히 해당 waiter를 가리킬 때만 지우고 태스크의 pi_lock으로 보호함
-
RANDOMIZE_KSTACK_OFFSET
- 익스플로잇은 해제된 waiter 프레임과 후속 user_auxv 프레임이 결정적으로 겹치는 데 의존함
- RANDOMIZE_KSTACK_OFFSET를 켜면 스택 오프셋이 달라져 이 단계가 약 1/32 확률의 5비트 추측으로 바뀜
- 제출된 두 일반 대상에서는 이 설정이 기본적으로 꺼져 있었고, 완화 대상에서는 켜져 있어 해당 익스플로잇 경로를 사용하지 않음
-
STATIC_USERMODE_HELPER
- STATIC_USERMODE_HELPER는 이 특정 DirtyMode 경로를 차단함
- 다만 접근 권한을 ctl_table::mode로 제어하고 테이블이 예측 가능한 쓰기 가능 커널 데이터에 있는 다른 /proc/sys 설정으로 같은 방식을 일반화할 수 있음
공개 일정
- 2026년 4월 18일: 취약점과 초안 패치를 security@kernel.org에 전달함
- 2026년 4월 20일: 다른 패치로 취약점이 수정됨
- 2026년 5월 4일: 수정안 v1이 백포트됨
- 2026년 6월 30일: Google이 kernelCTF 제출을 확인함
- 2026년 7월 7일: 기술 분석을 공개함
- VEGA가 발견한 취약점에는 표준 90+30일 공개 정책을 적용함
-
Homepage
-
Tech blog
- GhostLock, 15년간 모든 Linux 배포판에 존재한 스택 UAF