My Second CVE on Linux Kernel - Futex and mempolicy race condition to UAF
Author: 堇姬 Naup (naup96321)
前言
第二個洞好耶
https://www.tenable.com/plugins/nessus/304783
https://ubuntu.com/security/CVE-2026-23415
futex
Fast Userspace muTexes
傳統的 System V IPC 同步機制每次都必須透過系統呼叫(system call)進入核心態,由核心來仲裁資源的競爭與同步。
https://www.kernel.org/doc/ols/2002/ols2002-pages-479-495.pdf
https://hackmd.io/@sysprog/concurrency-thread-package
然而,實際情況下大多數時候根本沒有資源競爭,在這種情況下仍強制陷入核心態,白白增加了系統開銷,造成不必要的效能損耗
Futex 在使用者態表現為一個 atomic variable (CAS)
- 加鎖時:若鎖處於空閒狀態,直接以原子操作修改該變數的值,即視為持有該鎖,完全不需要陷入核心
- 解鎖時:若沒有其他執行緒在等待該鎖,直接將變數重置為空閒狀態,同樣無需陷入核心
當出現鎖競爭時,才會透過 Futex 系統呼叫進入核心
- 等待鎖時:若執行緒搶鎖失敗,透過 futex() 系統呼叫陷入核心,將自己掛入 wait queue 中休眠
- 喚醒執行緒時:釋放鎖的執行緒發現有人在等待,透過 futex() 系統呼叫陷入核心,喚醒等待佇列中的執行緒
syscall
1 | SYSCALL_DEFINE6(futex, |
解出真正的 cmd(去掉 flag bits),之後看是否有 timeout time,有的話就將 time 轉換成 kernel 內部格式 (複製到 ts 上)
之後 call do_futex
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/syscalls.c#L188
1 | SYSCALL_DEFINE6(futex, u32 __user *, uaddr, int, op, u32, val, |
futex_to_flags(op) 把 op 中的旗標部分轉成核心內部使用的 flags
之後將 cmd 分發到不同的實作
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/syscalls.c#L112
1 | long do_futex(u32 __user *uaddr, int op, u32 val, ktime_t *timeout, |
- WAIT 系列是讓執行緒休眠等待,WAKE 系列是喚醒休眠中的執行緒。兩者都有帶 BITSET 的變體,差別在於可以用一個遮罩來篩選「只接受特定來源的喚醒」,讓喚醒更有選擇性
- REQUEUE 系列是把正在等待的執行緒從一個 futex 的等待佇列移到另一個,主要目的是避免一次喚醒太多執行緒造成大家同時搶鎖的浪費。CMP_REQUEUE 版本會在搬移前先確認 futex 的值是否符合預期,多一層安全保護
- WAKE_OP 比較特殊,它在一次系統呼叫裡同時做兩件事:修改某個 futex 的值,並根據修改前後的結果決定要不要喚醒兩邊的等待者,主要用來實作條件變數
- PI 系列是帶優先權繼承的鎖操作。當高優先權執行緒在等待低優先權執行緒持有的鎖時,核心會暫時把低優先權執行緒的優先權拉高,避免它被中間優先權的執行緒一直搶佔而遲遲無法釋放鎖
futex_wait
- 如果呼叫者有指定逾時時間,就先用 futex_setup_timer() 建立一個高精度計時器,之後會用這個計時器來限制最長等待時間。如果沒有逾時時間就跳過這步。
- 呼叫
__futex_wait()進入真正的等待邏輯,執行緒在這裡會被掛起睡眠,直到被喚醒或計時器到期。 - 從等待返回後做收尾。如果當初有建立計時器,不管結果如何都要先把它取消並清理掉,避免資源洩漏
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/waitwake.c#L706
1 | int futex_wait(u32 __user *uaddr, unsigned int flags, u32 val, ktime_t *abs_time, u32 bitset) |
首先檢查 bitset 是否為零,為零的話直接回錯誤,因為 bitset 全為零代表不接受任何喚醒,等了也沒有意義
接著進入一個可重試的流程。先呼叫 futex_wait_setup() 做進入等待前的準備,它會去確認使用者態的 futex 變數值是否還等於預期的 val,並把這個執行緒的等待節點 q 初始化好、鎖住對應的 hash bucket,這個檢查非常重要,因為從使用者態決定要等待、到真正進核心這段時間內,鎖的狀態可能已經改變了,如果值已經不同就不需要等了,直接返回
準備好之後呼叫 futex_do_wait(),把 q 加入等待佇列,然後讓執行緒睡眠,在這裡等待三種可能發生的事:被其他執行緒喚醒、計時器逾時、或收到信號
從睡眠返回後,先嘗試把自己從等待佇列移除。如果移除失敗,代表別人已經先把我們從佇列中取出並喚醒了,這是正常成功的情況,直接回傳 0
如果還在佇列裡,就要判斷是什麼原因醒來的。如果計時器存在但它的 task 欄位已經是空的,代表是計時器到期把我們喚醒的,回傳 -ETIMEDOUT
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/waitwake.c#L666
1 | int __futex_wait(u32 __user *uaddr, unsigned int flags, u32 val, |
futex_q 是每個掛在 kernel wait 的 thread 一個代表,這樣的結構會掛在等待佇列
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/futex.h#L192
1 | /** |
futex_wait_setup() 的工作是在執行緒真正睡著之前,把所有準備工作做好,並確保「確認值相符」和「進入佇列」這兩件事之間不會出現競爭窗口
首先用 get_futex_key() 把使用者傳入的位址轉換成核心內部用來識別這個 futex 的 key,這個 key 會決定它要被掛進哪個 hash bucket
接著鎖住對應的 hash bucket,然後才去讀取使用者態的 futex 變數值。這個順序非常關鍵,注釋裡特別解釋了原因:必須先鎖 bucket、再讀值,順序不能反過來。如果先讀值再鎖 bucket,就會有一個空窗期,在這個空窗期裡喚醒方可能已經改了值並呼叫了 wake,而等待方還沒進佇列,結果把這次喚醒整個錯過,執行緒就會永遠睡下去
讀取使用者態記憶體時用的是 futex_get_value_locked(),這個版本可以在持有鎖的情況下安全存取使用者態記憶體。如果讀取失敗,通常是因為對應的記憶體頁面不在實體記憶體中需要觸發 page fault,這種情況下會先解鎖,用 get_user() 正常觸發一次 page fault 把頁面載入,然後再重試整個流程
讀到值之後跟期望的 val 比較,如果不相符代表鎖的狀態已經改變,不需要等待,直接解鎖並回傳 -EWOULDBLOCK
如果有傳入 key2,還會檢查兩個 key 是否相同,相同的話代表等待和目標是同一個 futex,這在 requeue_pi 的情境下是不合法的,回傳 -EINVAL
通過所有檢查之後,把執行緒狀態設為 TASK_INTERRUPTIBLE|TASK_FREEZABLE,代表可以被信號或系統凍結喚醒,然後呼叫 futex_queue() 把 q 正式掛進等待佇列,同時釋放 hash bucket 的鎖。這個函式返回時,執行緒已經在佇列裡了,接下來只要呼叫排程器就會真正睡著
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/waitwake.c#L591
1 | /** |
futex_hash_bucket 是 futex 等待佇列的基本單位,所有雜湊到同一個位置的 futex 共用同一個 bucket
- waiters 是一個原子計數器,記錄這個 bucket 裡目前有多少個執行緒在等待。這個欄位的主要用途是讓喚醒方在不需要加鎖的情況下快速判斷有沒有人在等,如果是零就可以直接跳過,省去加鎖的開銷
- lock 是保護這個 bucket 的自旋鎖,對 chain 的任何操作都需要先持有這把鎖
- chain 是實際存放等待節點的優先權排序鏈結串列,所有掛在這個 bucket 上的 futex_q 都串在這裡
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/futex.h#L134
1 | /* |
在往上一層,這些 bucket 被該 queue 串起來
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/core.c#L60
1 | /* |
既然他是一個 hash table,就應該有 hash key
https://elixir.bootlin.com/linux/v7.0-rc3/source/include/linux/futex.h#L32
1 | union futex_key { |
__futex_data 持有全域 bucket 陣列,每個 futex_hash_bucket 的 chain 串起一條按優先權排序的 futex_q 鏈表
futex_key 是 union,跨進程用 shared(檔案 inode + pgoff),同進程用 private(mm + address),經 jhash 算出 bucket 索引,喚醒時也靠它過濾出真正等這把鎖的人
(圖是 claude 畫的)
接下來執行 futex_do_wait 實際的等待 function
首先如果有設定逾時計時器,就在這裡把它啟動,讓它在到期時來打斷睡眠
接著檢查自己的 q->list 是否還在 plist 上。如果已經不在了,代表在這個函式被呼叫之前、甚至在 futex_wait_setup() 剛解鎖的瞬間,喚醒方就已經把我們從佇列裡移走了,這種情況下根本不需要睡覺,直接跳過 schedule()
如果還在佇列裡,才考慮要不要呼叫 schedule(),他會去任務切換,保存當前 CPU 任務的狀態,並載入新任務的狀態
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/waitwake.c#L341
1 | void futex_do_wait(struct futex_q *q, struct hrtimer_sleeper *timeout) |
當等待結束就會去 unqueue
有幾種狀況會在同一個 bucket:
- 第一種:等待同一把鎖。 這是設計上刻意的。同一個 futex 變數的所有等待者,uaddr 相同,計算出來的 futex_key 也相同,jhash 結果自然相同,所以全部落在同一個 bucket 的 chain 上。喚醒時就在這條 chain 上逐一找 key 相符的節點來喚醒
- 第二種:雜湊碰撞。 bucket 總數固定(預設 256 個),不同的 futex_key 經過 jhash 後可能算出相同的索引,就會落在同一個 bucket,但它們在 chain 上的 key 值不同。這就是為什麼喚醒時不能直接叫醒 chain 上的所有人,必須逐一比對 futex_key 確認是否真的在等同一把鎖

futex_wake
首先一樣先檢查 bitset 不為零,然後用 uaddr 計算出 futex_key,找到對應的 bucket
確認有人在等之後鎖住 bucket,開始遍歷 chain。對每個節點做三層過濾:
- 第一層是 futex_match() 比對 key,確認是在等同一個 futex 變數的人,排除雜湊碰撞進來的無關節點。
- 第二層是檢查 pi_state 和 rt_waiter,如果有這兩個欄位代表這個節點是 PI futex 的等待者,不應該用普通的 wake 路徑喚醒,直接回傳 -EINVAL 表示用錯了操作。
- 第三層是 bitset 過濾,只有等待者的 bitset 和喚醒方的 bitset 有交集,這個等待者才會被喚醒,實現選擇性喚醒
通過三層過濾的節點呼叫 this->wake() 把它加入 wake_q,這裡只是登記,還沒有真正喚醒
最後 call wake_up_q,來喚醒目前在 wake_q 內的 thread
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/waitwake.c#L155
1 | int futex_wake(u32 __user *uaddr, unsigned int flags, int nr_wake, u32 bitset) |
wake 實際上是 call 到這個
- 首先用 get_task_struct(p) 對目標執行緒的引用計數加一,防止在後續操作過程中這個 task_struct 被釋放掉
- 接著呼叫
__futex_wake_mark(q)做兩件事:把 q 從 bucket 的 chain 上移除,並把 q->lock_ptr 設為 NULL。這兩個動作合起來就是前面提過的已喚醒判斷條件,等待方的 futex_do_wait() 和 futex_unqueue() 都是靠這兩個條件來判斷自己是否已經被喚醒。如果這步失敗(回傳 false),代表這個節點已經被其他路徑處理掉了,把剛才加的引用計數還回去直接返回 - 最後呼叫 wake_q_add_safe() 把執行緒加入 wake_q,這個佇列會在外層 futex_wake() 解鎖之後才統一被 wake_up_q() 消費,真正把所有執行緒喚醒
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/waitwake.c#L134
1 | void futex_wake_mark(struct wake_q_head *wake_q, struct futex_q *q) |
上述提及的可以參考這段 code,上面的註釋說明的相當清楚
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/waitwake.c#L110
最後 wakeup_q 可以去喚醒
簡單來說就是 traverse 然後 wakeup_process()
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/sched/core.c#L1083
1 | void wake_up_q(struct wake_q_head *head) |
futex_requeue
futex_requeue() 解決的問題是,有一批執行緒在等 uaddr1,現在想把其中一部分直接搬到 uaddr2 的等待佇列
futex_requeue 解決的是驚群問題,當很多執行緒在等同一個條件變數,條件滿足時不應該把所有人全部喚醒,因為最終只有一個人能拿到鎖,其他人醒來搶輸又回去睡,白白浪費大量上下文切換
因為要同時操作兩個 bucket,加鎖順序必須固定按照記憶體位址由小到大,否則兩邊互等會死鎖。搬移之前也要先把目標 bucket 的 waiters 計數器加一,確保喚醒方不會因為看到計數為零而錯過這些被搬過來的等待者
更詳細的就直接看 code 吧,有點多XD
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/requeue.c#L379
mempolicy
https://richardweiyang-2.gitbook.io/kernel-exploring/nei-cun-guan-li/00-index/07-mempolicy
https://www.kernel.org/doc/Documentation/vm/numa_memory_policy.txt
analyze
接下來專注在 wake 內的 get_futex_key
get_futex_key() 的工作是把使用者傳進來的 uaddr 轉換成核心內部用來識別這個 futex 的 key,後續的雜湊計算和 bucket 定位都靠這個 key
首先做對齊檢查。futex 變數必須自然對齊,也就是說 4 byte 的 futex 位址必須是 4 的倍數。把位址對 PAGE_SIZE 取餘數得到頁內偏移量存進 key->both.offset,然後把位址對齊到頁邊界,這樣後面可以統一用頁為單位來處理
接著用 access_ok() 確認這個使用者態位址是合法可存取的,避免後續操作踩到非法記憶體
然後處理 NUMA 相關的邏輯。如果有 FLAGS_NUMA 旗標,代表這是一個 NUMA-aware 的 futex,futex 變數後面緊跟著一個 node 編號,從使用者態讀出這個值來決定要用哪個 NUMA 節點的 bucket。這樣同一個 futex 在不同 NUMA 節點上的執行緒可以各自用本地的 bucket,減少跨節點的記憶體存取
如果沒有明確指定 node,但有 FLAGS_MPOL 旗標,就呼叫 futex_mpol() 根據這塊記憶體的 memory policy 自動推算出對應的 NUMA 節點
1 | int get_futex_key(u32 __user *uaddr, unsigned int flags, union futex_key *key, |
futex_mpol() 的工作是根據記憶體位址的 NUMA policy 算出這個 futex (uaddr) 應該屬於哪個 NUMA 節點
首先嘗試 futex_key_to_node_opt(),這是一條快速路徑
如果快速路徑沒有結果,就用 guard(mmap_read_lock)(mm) 持有 mmap 讀鎖,再呼叫 __futex_key_to_node() 走完整路徑。加讀鎖是因為需要查詢這個位址對應的 VMA(虛擬記憶體區域),而 VMA 的結構在沒有鎖的情況下可能被其他執行緒修改,必須保護
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/core.c#L382
1 | static int futex_mpol(struct mm_struct *mm, unsigned long addr) |
mmap_lock_speculate_try_begin() 讀取目前 mmap 鎖的序號存進 seq,如果 mmap 鎖當前正被寫者持有就直接回 -EBUSY,因為這時候投機讀取沒有意義。
接著在 RCU 保護下直接呼叫 __futex_key_to_node() 讀取 VMA 資訊,這整個過程完全沒有加任何寫鎖,純粹是樂觀地讀取
RCU 保護的是指標的有效性,在 RCU 讀鎖期間,你可以安全地追蹤指標、存取指標指向的物件,不用擔心物件在你讀取的過程中被釋放
讀完之後呼叫 mmap_lock_speculate_retry() 檢查序號是否還和 seq 相同。如果不同代表在我們讀取的期間有其他執行緒修改了 mmap,讀到的結果可能是不一致的,回傳 -EAGAIN 讓上層走慢速路徑。如果序號沒變就代表讀取期間沒有人動過 mmap,結果是可信的,直接返回
mm->mm_lock_seq 是一個序號計數器,每次有人要寫鎖 mmap(例如 mmap_write_lock())時這個序號就會遞增,寫鎖釋放時再遞增一次。所以序號是奇數代表寫鎖正在被持有,偶數代表目前沒有人持有寫鎖
raw_seqcount_try_begin() 做兩件事:讀取目前的序號存進 seq,並判斷這個序號是否為奇數。如果是奇數(有人持有寫鎖)就回傳 false,告訴上層不要走投機路徑。如果是偶數就回傳 true,同時把當前序號存起來,之後 mmap_lock_speculate_retry() 會拿這個序號來比對,確認讀取過程中序號沒有改變
https://elixir.bootlin.com/linux/v7.0-rc3/source/kernel/futex/core.c#L365
1 | static int futex_key_to_node_opt(struct mm_struct *mm, unsigned long addr) |
首先用 vma_lookup() 在 mm 裡找出 addr 對應的 VMA(虛擬記憶體區域)。如果找不到代表這個位址沒有對應的合法映射,直接回傳 FUTEX_NO_NODE 表示沒有節點資訊
找到 VMA 之後,用 vma_policy() 取出這段記憶體的 memory policy,memory policy 是 NUMA 系統上用來控制記憶體分配策略的機制,可以透過 mbind() 系統呼叫設定,指定這段記憶體應該優先從哪個節點分配,或是輪流在各節點間分配等
這段程式碼是函式的前半部,拿到 mpol 之後後面還會解析這個 policy 的類型,從裡面算出具體的節點編號。不同的 policy 類型(BIND、PREFERRED、INTERLEAVE 等)算出節點的方式都不同,需要進一步判斷
1 | static int __futex_key_to_node(struct mm_struct *mm, unsigned long addr) |
上述說的不去持 mmap lock 的優化是在以下被引入的 (該 patch 不是在做這部分優化,但是在使用時候卻發生了問題)
https://www.phoronix.com/news/FUTEX2-NUMA-Linux-6.16
https://patchew.org/linux/174670042876.406.4365999547221575535.tip-bot2@tip-bot2/
核心內部使用一個全域雜湊表(global hash table)來追蹤所有等待中的 futex,稱為 futex_hash_bucket
在 NUMA 系統上,記憶體分散在多個節點,跨節點存取的延遲遠高於本地存取。原本 futex 的 bucket 是用位址做 jhash 算出來的,結果在哪個 NUMA 節點上純粹是隨機的,跟 futex 變數本身在哪個節點毫無關係
先前引入的 FUTEX2_NUMA 讓使用者可以明確指定要用哪個 NUMA 節點的 bucket,核心會在 get_futex_key() 裡把這個節點號碼存進 key->both.node,之後 __futex_hash() 根據節點選擇對應的全域節點 hash table
細節這邊不多說,不過其改去持 rcu 來確保 pointer 有效性這點,卻在 mempolicy 那邊沒有修正
這邊來看 vma_replace_policy
vma_replace_policy() 是修改 VMA mempolicy 的函式,開頭的 vma_assert_write_locked(vma) 斷言確認呼叫者一定持有 mmap 寫鎖
但發現問題了吧,走快速路徑並不會去上鎖,不過沒關係,畢竟有 RCU,只要在後續實作好仍不會有問題
https://elixir.bootlin.com/linux/v7.0-rc3/source/mm/mempolicy.c#L1003
1 | /* |
但在 free 之前卻沒有去等待 RCU read 完成,也就造成了 race condition 可以導致 UAF
https://elixir.bootlin.com/linux/v7.0-rc3/source/include/linux/mempolicy.h#L66
https://elixir.bootlin.com/linux/v7.0-rc3/source/mm/mempolicy.c#L486
1 | /* Slow path of a mpol destructor. */ |
這個問題應該有機會導致查找到錯誤的 NUMA 的 hash table,但所有對他的操作都相當受限,我也不知道怎麼樣有機會提權XD
btw,對於這部分使用序列號的方法可以參考這張圖,我覺得蠻像的
除了可以讓 futex 可以並行外,還可以優化持 mmap_lock 所造成的 cacheline bouncing 的開銷
左邊改成 futex,右邊是 writer 來看
不過這邊是先判斷是否存在寫入,然後做並行查詢在哪個 numa node,再次檢查查詢的有沒有在過程中遭到變更 (writer 是否寫入也就是是否去持 mmap_lock),若有則回退,若無則套用
PoC
1 |
|
就可以 race 到 KASAN 檢測 UAF 了
1 | 492.047266] ================================================================== |
report timeline
3/13 號回報給 Linux 官方,並發了 patch
https://lore.kernel.org/stable/20260313124756.52461-1-naup96721@gmail.com/T/#u
3/26 號進到了 locking/urgent branch of tip
https://git.kernel.org/pub/scm/linux/kernel/git/tip/tip.git/commit/?id=190a8c48ff623c3d67cb295b4536a660db2012aa
4/1 號進到 stable queue
4/3 號分配 CVE 編號
https://www.tenable.com/plugins/nessus/304783