Skip to content

Split View: GhostLock: 리눅스 rtmutex에 15년을 숨어 있던 스택 use-after-free (CVE-2026-43499)

|

GhostLock: 리눅스 rtmutex에 15년을 숨어 있던 스택 use-after-free (CVE-2026-43499)

들어가며 — 원인은 놀랄 만큼 작았다

2026년 7월 7일, nebusec.ai에 "IonStack part II"라는 제목의 분석 글이 올라와 해커뉴스에서 화제가 됐다. 이 글이 다루는 것이 GhostLock, 즉 CVE-2026-43499다. 리눅스 커널의 실시간 뮤텍스(rtmutex) 코드(kernel/locking/rtmutex.c)에 있던 스택 use-after-free이며, 연구진의 설명으로는 2011년 Linux 2.6.39-rc1부터 메인라인에 있었고 2026년 4월 7.1-rc1에서야 고쳐졌다. 대략 15년이다.

흥미로운 지점은 그 15년이라는 숫자가 아니다. 원인이 민망할 만큼 작다는 것이다. 자신을 호출하는 스레드가 누구인지 잘못 가정한 헬퍼 함수 하나가, 15년의 리뷰와 퍼징과 lockdep을 견디고 살아남았다. 이 글은 세 가지를 짚는다. 스택 use-after-free가 무엇이고 왜 미묘한지, 이렇게 작은 버그가 어떻게 그렇게 오래 숨는지, 그리고 의도적으로 과장을 걷어내고 GhostLock을 실제로 익스플로잇하려면 무엇이 필요하며 우리는 무엇을 해야 하는지다.

먼저 단서 하나. 이 글은 시리즈의 2부이고, 1부를 요약해 주지는 않는다. 나는 2부가 다룬 내용만 서술하며, 어떤 수치가 독립적으로 확인된 것이 아니라 이 한 편의 글에 근거하는지도 함께 밝힌다.

스택 use-after-free란 무엇인가

use-after-free는 말 그대로다. 메모리가 해제됐는데도 그 포인터를 계속 쓰는 것이다. 힙에서의 사례는 익숙하다. 스택 버전은 더 낯설다. 지역 변수는 함수의 스택 프레임에 산다. 함수가 반환되면 그 프레임은 "해제"되는데, 그 공간이 다음 호출에 곧바로 재사용될 수 있다는 의미에서다. 지역 변수를 가리키는 포인터가 그 변수를 소유한 함수보다 오래 살아남으면, 이후 코드는 이제 다른 무언가의 것이 된 메모리를 읽거나 쓸 수 있다.

커널에서는 이게 유저 공간보다 더 고약하다. 오염시킬 할당자 메타데이터도 없고 눈에 띄는 크래시도 없다. 스택은 그냥 RAM이고, 그 공간을 재사용하는 것이 정상적인 빠른 경로이기 때문이다. 해제된 스택 프레임을 가리키는 대롱거리는 포인터는, 누군가의 데이터가 마침 그 자리에 떨어지기 전까지는 아무 일도 하지 않는다.

그 침묵이 문제의 전부다. 이 버그가 평범한 테스트에서 보이지 않는 이유는, 대부분의 경우 해제된 프레임을 아직 아무도 덮어쓰지 않았기 때문이다. 공격자가 자신이 통제하는 바이트로 그 공간을 의도적으로 되찾아 채울 때 비로소 무기가 된다 — GhostLock 익스플로잇이 하는 일이 정확히 이것이다.

GhostLock의 실체

문제의 코드는 실시간 뮤텍스(rtmutex) 서브시스템이고, PI-futex 시스템콜을 통해 도달한다. 락을 쥔 저우선순위 스레드가 고우선순위 스레드를 굶기지 못하게 막는 우선순위 상속(priority inheritance) 기계다. 문제의 헬퍼는 remove_waiter()다. 원래 한 가지 상황을 위해 쓰였다 — 스레드가 락에서 블록됐다가 스스로 뒤처리를 하는 상황. 그래서 실행 중인 스레드(current)가 언제나 제거 대상 waiter라고 가정하고 current->pi_blocked_on을 지웠다.

requeue-PI 기능이 그 가정을 조용히 깨뜨렸다. FUTEX_WAIT_REQUEUE_PIFUTEX_CMP_REQUEUE_PI를 쓰면, 한 스레드의 rt_mutex_waiter(그 스레드의 스택에 산다)를 다른 스레드가 rt_mutex_start_proxy_lock()으로 또 다른 futex에 대리 등록한다. 이 경로가 데드락 사이클을 감지해 -EDEADLK로 롤백하면, 엉뚱한 스레드에서 remove_waiter()를 호출해 실제 waiter가 아니라 대리 등록자의 상태를 지운다. 그 결과 진짜 waiter는 이제 재사용된 자기 스택 프레임을 가리키는 포인터로 여전히 PI 체인에 묶인 채 유저 공간으로 돌아가고, 다음 체인 워크가 해제된 메모리를 역참조한다.

/* rtmutex.c: 헬퍼는 실행 중인 스레드가 곧 waiter라고 가정했다 */
static void remove_waiter(struct rt_mutex *lock, struct rt_mutex_waiter *w)
{
        /* ... 락의 waiter 트리에서 w를 떼어낸다 ... */
        current->pi_blocked_on = NULL;   /* 버그: w가 아니라 current를 지운다 */
}

/* 수정, 커밋 3bfdc63936dd: waiter 자신의 task를 대상으로 */
raw_spin_lock(&w->task->pi_lock);
w->task->pi_blocked_on = NULL;

수정, 커밋 3bfdc63936dd("rtmutex: Use waiter::task instead of current in remove_waiter()")는 본질적으로 한 줄이다. current 대신 waiter 자신의 task를 잡아, waiter->task->pi_lock을 걸고 waiter->task->pi_blocked_on을 지운다. 15년의 노출이 올바른 구조를 가리키는 것만으로 닫혔다 — 안심되면서도 조금 서늘한 사실이다.

15년을 버젓이 숨은 이유 — 그리고 익스플로잇의 실제 난이도

이 오래 산 이유에 대한 글의 설명은 평범하지만 설득력 있다. 가정의 경계를 넘은 함수 재사용이다. remove_waiter()는 원래 호출자에게는 옳았고 거기서는 계속 옳았다. requeue-PI가 current가 누구인지에 대한 관념이 다른 두 번째 호출자를 추가했는데, 아무도 그 가정을 다시 확인하지 않았다. 게다가 버그에 도달하려면 특정한, 무작위가 아닌 배치가 필요하다 — -EDEADLK 롤백을 강제하기 위해 세 개의 futex와 세 개의 스레드에 걸친 우선순위 상속 의존성 사이클을 만들어야 한다. 퍼저나 일상적인 워크로드가 좀처럼 만들지 않는 상태다.

이제 영향에 대한 정직한 부분이다. GhostLock은 로컬 권한 상승이지 원격 코드 실행이 아니다. 이 모든 것이 성립하려면 이미 그 머신에서 코드를 실행할 수 있어야 한다 — 셸이든, 컨테이너 안의 프로세스든. 익스플로잇도 한 줄짜리가 아니다. 공개된 체인은 prctl(PR_SET_MM_MAP)으로 해제된 프레임을 손질해 가짜 waiter를 위조하고, 제약된 rb-트리 쓰기로 CPU entry area에 있는 inet6_protos[IPPROTO_UDP]를 덮어쓴 뒤, 마지막에 /proc/sys/kernel/core_pattern의 권한 비트를 뒤집어 루트를 얻는다.

이건 연구급 작업이다. 저자들은 하드닝 옵션을 끈 kernelCTF 조건에서 97% 안정성을 보고했고, 구글 kernelCTF는 여기에 92,337달러를 지급했다. 공정하게 읽자면, 신뢰할 만하고 실재하지만 한계가 뚜렷하다 — 로컬 접근과 정교한 체인이 필요하다. 로컬 권한 상승이 하나의 침해된 워크로드를 노드 전체 장악으로 바꾸는 컨테이너 탈출이, 가장 걱정해야 할 시나리오다.

공개 과정도 빨랐고, 그 점은 수정 자체만큼 중요하다. 글에 따르면 버그는 2026년 4월 18일 security@kernel.org에 보고됐고, 4월 20일 메인라인에서 고쳐졌으며, 5월 4일 stable로 백포트됐다. 구글은 6월 30일 kernelCTF 제출을 인정했고, 공개 분석은 7월 7일에 나왔다. 조용히 고쳐진 시점과 공개적으로 상세히 밝혀진 시점 사이의 간격은 약 두 달이었다 — stable 사용자가 그 메커니즘이 1면에 오르기 전에 업데이트할 시간이 있었다는 뜻이다.

마무리 — 패치, 그리고 노출 여부 확인법

실무적 대응은 지루하지만 정확하다. 패치다. 수정은 메인라인에 커밋 3bfdc63936dd("rtmutex: Use waiter::task instead of current in remove_waiter()")로 들어갔고 2026년 5월 4일 stable 트리로 백포트됐으니, 최신으로 업데이트된 배포판 커널이라면 이미 담고 있다. 노출 범위는 원칙적으로 넓다 — CONFIG_FUTEX_PI=y가 켜진 2.6.39-rc1 이후 모든 커널, 사실상 모든 배포판 빌드에 도달한다. 그러니 진짜 질문은 "영향받는가"가 아니라 "패치됐는가"다.

uname -r                      # 실행 중인 커널
# 그다음 배포판의 CVE-2026-43499 권고를 확인한다.
# 2026-05-04 이후 업데이트된 stable 커널이면 패치된 것이다.

글에서 언급한 심층 방어 두 가지 — 어느 것도 패치를 대신하지 못한다. RANDOMIZE_KSTACK_OFFSET은 시스템콜마다 스택 오프셋을 무작위화해 익스플로잇이 의존하는 결정론적 프레임 겹침을 깬다. 약 5비트, 대략 32분의 1 확률에 불과하지만 없는 것보다는 낫다. STATIC_USERMODE_HELPER는 여기서 쓰인 특정 /proc/sys 경로를 막지만, 근본 버그 자체를 막지는 못한다. 그리고 수치에 대한 정직한 단서. 15년이라는 수명과 버전 세부 정보는 시리즈 2부에 해당하는 한 편의 벤더 글에서 나온다. 메커니즘은 구체적이고 커널 트리와 대조해 확인할 수 있어 나는 신뢰하지만, 주변 수치들은 독립 확인이 아니라 그 출처의 서술로 받아들이는 편이 낫다.

참고 자료

GhostLock: a stack use-after-free that hid in Linux rtmutex for 15 years (CVE-2026-43499)

Introduction — the cause was surprisingly small

On 2026-07-07 a writeup titled "IonStack part II" appeared on nebusec.ai and made the rounds on Hacker News. It describes GhostLock, CVE-2026-43499: a stack use-after-free in the Linux kernel's real-time mutex code (kernel/locking/rtmutex.c). By the researchers' account the flaw had been in mainline since Linux 2.6.39-rc1 in 2011 and was only fixed in 7.1-rc1 in April 2026 — roughly fifteen years.

The interesting part is not the headline number. It is that the cause is almost embarrassingly small — a helper function that assumed the wrong thing about which thread was calling it — and yet it survived a decade and a half of review, fuzzing, and lockdep. This post covers three things: what a stack use-after-free is and why it is subtle, how a bug this small hides that long, and, kept deliberately measured, what GhostLock actually takes to exploit and what you should do about it.

One caveat up front: this is part II of a series, and it does not summarize part I. I only describe what part II covers, and I flag which figures rest on that single writeup rather than independent confirmation.

What a stack use-after-free is

A use-after-free is what it sounds like: memory is freed, and something keeps using a pointer to it. The heap version is familiar. The stack version is stranger. Local variables live in a function's stack frame; when the function returns, that frame is "freed" only in the sense that the space is immediately reusable by the next call. If a pointer to a local outlives the function that owns it, later code can read or write memory that now belongs to something else.

In the kernel this is nastier than in userspace. There is no allocator metadata to corrupt and no obvious crash — the stack is just RAM, and reusing it is the normal, fast path. A dangling pointer into a freed stack frame does nothing at all until someone else's data happens to land there.

That silence is the whole problem. The bug is invisible in ordinary testing precisely because, most of the time, nothing has overwritten the freed frame yet. It only becomes a weapon when an attacker deliberately arranges to reclaim that space with bytes they control — which is exactly what the GhostLock exploit does.

What GhostLock actually is

The affected code is the real-time mutex (rtmutex) subsystem, reached through PI-futex syscalls — the priority-inheritance machinery that keeps a low-priority thread holding a lock from starving a high-priority one. The specific helper is remove_waiter(). It was written for one situation: a thread blocks on a lock, then cleans up after itself. So it assumed the running thread (current) was always the waiter being removed, and cleared current->pi_blocked_on.

The requeue-PI feature quietly broke that assumption. With FUTEX_WAIT_REQUEUE_PI and FUTEX_CMP_REQUEUE_PI, one thread's rt_mutex_waiter — which lives on that thread's stack — gets proxied onto another futex by a different thread via rt_mutex_start_proxy_lock(). If that path detects a deadlock cycle and rolls back with -EDEADLK, it calls remove_waiter() from the wrong thread, clearing the requeuer's state instead of the real waiter's. The waiter returns to userspace still linked into a PI chain through a pointer into its own, now-reused stack frame, and the next chain walk dereferences freed memory.

/* rtmutex.c: the helper assumed the running thread WAS the waiter */
static void remove_waiter(struct rt_mutex *lock, struct rt_mutex_waiter *w)
{
        /* ... unlink w from the lock's waiter tree ... */
        current->pi_blocked_on = NULL;   /* BUG: clears current, not w */
}

/* Fix, commit 3bfdc63936dd: operate on the waiter's own task */
raw_spin_lock(&w->task->pi_lock);
w->task->pi_blocked_on = NULL;

The fix, commit 3bfdc63936dd ("rtmutex: Use waiter::task instead of current in remove_waiter()"), is a one-liner in spirit: take the waiter's own task rather than current, lock waiter->task->pi_lock, and clear waiter->task->pi_blocked_on. Fifteen years of exposure closed by pointing at the right structure — reassuring and a little unsettling at once.

Fifteen years in plain sight — and what exploiting it takes

The writeup's explanation for the longevity is mundane and convincing: function reuse across an assumption boundary. remove_waiter() was correct for its original caller and stayed correct there. Requeue-PI added a second caller with a different notion of who current is, and nobody re-checked the assumption. Reaching the bug also needs a specific, non-random setup — a priority-inheritance dependency cycle across three futexes and three threads to force the -EDEADLK rollback — the kind of state fuzzers and everyday workloads rarely stage.

Now the honest part about impact. GhostLock is local privilege escalation, not remote code execution. You already need to run code on the machine — a shell, or a process inside a container — before any of this applies. The exploit is also not a one-liner: the public chain grooms the freed frame with prctl(PR_SET_MM_MAP) to forge a fake waiter, uses a constrained rb-tree write to overwrite inet6_protos[IPPROTO_UDP] in the CPU entry area, and finally flips permission bits on /proc/sys/kernel/core_pattern to gain root.

That is research-grade work. The authors report 97% stability under kernelCTF conditions, which left hardening options off, and Google's kernelCTF paid 92,337 USD for it. The fair reading: reliable and real, but bounded — it needs local access and an elaborate chain. Container escape, where a local privesc turns one compromised workload into control of the whole node, is the scenario worth worrying about most.

The disclosure was fast, which matters as much as the fix. Per the writeup the bug was reported to security@kernel.org on 2026-04-18, patched in mainline on 2026-04-20, and backported to stable on 2026-05-04; Google acknowledged the kernelCTF submission on 2026-06-30, and the public writeup followed on 2026-07-07. The gap between quietly fixed and publicly detailed was about two months — enough time for stable users to update before the mechanics were on the front page.

Closing — patch, and how to check exposure

The practical response is boring and correct: patch. The fix landed in mainline as commit 3bfdc63936dd ("rtmutex: Use waiter::task instead of current in remove_waiter()") and was backported to stable trees on 2026-05-04, so a current, updated distribution kernel already carries it. Exposure is broad in principle — the bug reaches any kernel from 2.6.39-rc1 onward with CONFIG_FUTEX_PI=y, effectively every distribution build — so the real question is not "am I affected" but "am I patched."

uname -r                      # your running kernel
# then check your distro advisory for CVE-2026-43499;
# patched if you are on a stable kernel updated after 2026-05-04

Two defense-in-depth notes from the writeup, neither a substitute for patching. RANDOMIZE_KSTACK_OFFSET randomizes the stack offset per syscall and breaks the deterministic frame overlap the exploit relies on — only about five bits, roughly a 1-in-32 chance, but not nothing. STATIC_USERMODE_HELPER closes the specific /proc/sys route to root used here, though not the underlying bug. And the honest caveat on the numbers: the fifteen-year lifetime and the version details come from a single vendor writeup that is part II of a series. The mechanism is specific and checkable against the kernel tree, so I trust it; treat the surrounding figures as that source's account rather than independently confirmed.

References