
비특권 개념 증명 및 Linux 커널 Binder use-after-free(CVE-2026-64468)에 대한 x86_64 로컬 권한 상승, KASAN 실습 환경 및 취약/수정 버전 차이 분석 포함.
binder_free_transaction() 프로세스 수명 use-after-free
이 저장소는 업스트림 커밋
f223d27a546c1e1f48d38fd67760e78f068fe8c4으로
수정된 Linux 커널 Binder use-after-free에 대해 다음을 포함합니다:
binder_chain_64468.c — 버그에 도달하여 커널이 이를 입증하도록 하는 권한 없는 개념 증명으로, KASAN 실험실과 취약/수정 차등 테스트(lab/, run.sh, verify.sh)를 포함합니다.exploit.c — x86_64용 독립형 로컬 권한 상승 코드입니다. gcc -O2 -pthread -o exploit exploit.c로 컴파일되며, 일반 사용자로 실행되어 루트 셸로 끝납니다.demo/ — 패치되지 않은 커널에서 실제 Debian 13 사용자 영역을 부팅하는 실험실로, 익스플로잇이 대상 시스템의 자체 gcc로 컴파일되고, 이후 장악하는 그 머신에서 실행될 수 있습니다.모든 것은 어떤 종류의 패치도 없는 스톡 업스트림 커널을 대상으로 일반 사용자(uid/gid 1000, capabilities 없음, namespaces 없음)로 실행됩니다.
경고
이 코드는 의도적으로 커널 객체 수명을 경쟁(race)시킨 후 커널 제어 흐름을 탈취합니다. 경쟁에서 패배하면 커널 힙 상태가 손상되어 머신이 패닉하거나 멈출 수 있습니다. 소유한 격리된 일회용 VM에서만 실행하십시오. 호스트에서는 실행하지 마십시오.
| 상태 | 주장 |
|---|---|
| 확인됨 | 취약점은 실제이며, 권한 없는 프로세스에서 도달 가능하고, 업스트림 수정이 이를 제거합니다. |
| 입증됨 | 소멸 중인 binder_proc의 취약한 역참조가 패치되지 않은 커널에서 자연스럽고 반복적으로 발생하며, 패치된 커널에서는 절대 발생하지 않습니다. |
| 입증됨 | KASAN이 보고한 전체 use-after-free가 패치되지 않은 커널에서 발생합니다. |
| 입증됨 | 공격자가 제어하는 바이트로 해제된 binder_proc을 재확보하고, 커널 제어 흐름을 탈취하며, x86_64에서 권한 없는 사용자로부터 uid 0으로의 권한 상승이 발생합니다. |
| 주장하지 않음 | 특정 벤더 또는 Android 기기에 대한 어떤 결과도 없습니다. 아래 나열된 업스트림 커널만 x86_64에서 테스트되었습니다. |
| 주장하지 않음 | 제공된 익스플로잇이 수정 없이 배포판 커널에서 동작한다는 것. 영향을 받는 시스템을 참조하십시오: 커널별 오프셋이 필요하며, 조사된 모든 범용 배포판에서 binder 장치는 애초에 권한 없는 사용자가 접근할 수 없습니다. |
binder_free_transaction()은 t->lock 하에서 트랜잭션에서 대상 프로세스를 읽고, 해당 잠금을 해제한 다음, 대상의 내부 잠금을 획득합니다:```c
spin_lock(&t->lock);
target_proc = t->to_proc;
spin_unlock(&t->lock);
if (target_proc) {
binder_inner_proc_lock(target_proc); /* use after free */
Nothing keeps `target_proc` alive across that gap. A process that is being torn
down in parallel can reach `binder_proc_dec_tmpref() -> kfree()` in between, so
the lock is taken on freed memory. The upstream fix pins `t->to_thread` while
`t->lock` is still held, which keeps the owning process alive until the inner
lock has been used and released.
The vulnerability was reported by **Alice Ryhl** and fixed by **Carlos
Llamas**, both of Google. The
[original report](https://lore.kernel.org/all/[email protected]/)
carries the reference KASAN trace.
### 취약한 접근에 도달하기
*외부* `to_proc`에 도달할 수 있는 유일한 호출자는
`binder_send_failed_reply()`이며, `t->from`이 `NULL`일 때만
`t->from_parent`로 이동합니다:```c
target_thread = binder_get_txn_from_and_acq_inner(t);
if (target_thread) { ...; binder_free_transaction(t); return; }
next = t->from_parent;
binder_free_transaction(t);
t = next;
from_parent는 정확히 한 곳에서 할당되며, 그 할당은 몇 줄 앞서 binder의 잘못된 트랜잭션 스택 검사에 의해 보호됩니다. 즉, 스레드는 자신의 스택 최상단이 수신 중인 트랜잭션일 때만 동기 트랜잭션을 보낼 수 있습니다. 따라서 해당 체인의 모든 링크에 대해 자식의 송신자와 부모의 수신자는 동일한 스레드입니다.
이는 뚜렷한 결과를 낳습니다. binder_thread_release()는 proc->inner_lock을 보유한 채 종료되는 스레드의 스택을 순회하며, 다음 두 가지를 모두 기록합니다.```
iteration j : [holds child->lock ] child->from = NULL ; unlock child->lock
spin_lock(parent->lock)
iteration j+1 : [holds parent->lock] parent->to_proc = NULL
in **인접한 단일 워크의 반복**에서, 각각은 해당
`t->lock` 하에 있습니다. 워커는 워크가 `child->lock`을 해제한 후에만
`child->from == NULL`을 알게 되며, 자체 스냅샷을 위해 `parent->lock`이 필요합니다.
따라서 전체 기회는 해당 워크의 `spin_unlock(&child->lock)`과
`spin_lock(&parent->lock)` 사이의 간격 — 몇 개의 명령어에 불과합니다. 해제 워크는
스핀락을 보유하고 있어 그 지점에서 선점될 수 없습니다. 오직 인터럽트만이 이를 지연시킬 수 있습니다.
이것이 레이스가 좁은 이유이며, 개념 증명과 익스플로잇이 모두
확률적(probabilistic)인 이유입니다.
### 개념 증명이 구축하는 것
`binder_chain_64468.c`는 취약한 접근에 도달하는 가장 짧은 체인을 구성하므로,
워커가 그 간격 내에서 수행하는 작업은 binder가 허용하는 한 최소화됩니다 —
두 번의 `t->lock` 획득과 한 번의 `kfree()`, 외부 `inner_proc_lock` 없이,
`wake_up` 없이, 그리고 응답 전달 없이:```
B thread i --e2 (sync, code 0x4442414b)--> P thread Y_i
P thread Y_i --e1 (sync, code 0x54414c4c)--> B thread i (nested target)
B thread i stack: [ e2 outgoing , e1 incoming (top) ]
P thread Y_i stack: [ e2 incoming , e1 outgoing (top) ]
P가 바인더 fd를 닫으면 binder_deferred_release()가
Y_1..Y_K를 해제하고 마지막으로 binder_proc를 해제하는 동안, 모든 B 스레드가
동시에 BINDER_THREAD_EXIT를 실행합니다:```
binder_thread_release(B_i) nulls e1->to_proc (own proc) and e2->from
binder_send_failed_reply(e1) e1->from == NULL once Y_i was released
-> binder_free_transaction(e1) target_proc already NULL, kfree(e1)
-> binder_free_transaction(e2) target_proc == P <-- vulnerable access
세 번째 프로세스는 바인더 컨텍스트 매니저로, 다른 두 프로세스가 필요로 하는 핸들을 나눠주는 용도로만 사용된다. `exploit.c`는 바로 이 구성을 그대로 재사용한다.
### 두 개의 독립적인 조건
KASAN 리포트가 발생하려면 다음 두 가지가 모두 필요하다:
* **취약한 접근** — 워커가 위의 좁은 창 안에서 `parent->lock`을 획득하여 아직 살아있는 `to_proc`의 스냅샷을 찍어야 한다. 그리고
* **창 안에서의 해제(free) 도달** — 워커가 `t->lock`을 놓은 뒤 피해자의 내부 잠금을 획득하기 전에 CPU를 빼앗겨야 하며, 지연 해제(deferred release)가 완료되어 `binder_proc`가 해제될 때까지 CPU를 되찾지 못해야 한다.
첫 번째 조건에는 KASAN이 필요 없는 자체 오라클(oracle)이 있다. 바인더는 다음을 출력한다:```
binder: binder_free_proc: Unexpected outstanding_txns -1
whenever it happens, because the walker and binder_thread_release() then both
decrement the same counter for one transaction. Note this is not a
vulnerable/fixed differential by itself — the fix stops the free, not the second
decrement — so it appears on both kernels. It is used here only to show the
vulnerable code path being exercised.
The proof of concept stops at a 4-byte decrement of freed memory. Turning that into uid 0 needs four things, and none of them come from the bug itself: the bug leaks nothing.
struct binder_proc is 648 bytes and is allocated with a plain GFP_KERNEL
kzalloc — not __GFP_ACCOUNT. It therefore lands in kmalloc-1k,
together with every other unaccounted allocation of that size, and is not
isolated behind kmalloc-cg-*. That single fact is what makes the object
reclaimable at all.
The moment of the kfree() is not observable from userspace, and neither is the
moment the walker touches the object again, so there is nothing to time against.
The spray therefore runs as a pump: it allocates and frees kmalloc-1k
objects continuously, from the CPU that ran the deferred release, for as long as
the walkers are running.
System V messages are used for it. alloc_msg() is a plain unaccounted
kmalloc of a 48-byte header plus payload, so a 976-byte message is a 1024-byte
allocation; the quota is per queue rather than per uid; and msgrcv() frees
synchronously. Measured throughput: ~198,000 allocations per second, zero
failures.
add_key/user_key_payload was tried first and is a trap. Its payload is
charged against a per-uid byte quota (kernel.keys.maxbytes, 20000 by default)
which is released only when the key garbage collector destroys the key, so a
tight allocate/free loop exhausts it in milliseconds: measured 27,151
successful allocations against 2,121,009 failures — 98.7% of the spray silently
doing nothing, which looks exactly like a spray that never wins the slot.
KEYCTL_INVALIDATE made it worse (4,775 successes), because it queues GC work.
The walker touches four fields of the freed binder_proc (offsets measured with
pahole on the target build):
| Offset | Field | What the walker does |
|---|---|---|
| 108 | int outstanding_txns | decrements it |
| 113 | bool is_frozen | reads it |
| 120 | wait_queue_head_t freeze_wait | walks it if outstanding_txns == 0 && is_frozen |
| 624 | spinlock_t inner_lock | takes and releases it |