
Linuxカーネル6.1から6.4におけるスタック拡張の処理に欠陥が見つかり、「Stack Rot」と呼ばれています。仮想メモリ領域の管理を担当するメイプルツリーは、MM書き込みロックを適切に取得せずにノードの置き換えが行われる可能性があり、use-after-freeの問題を引き起こします。特権のないローカルユーザーがこの欠陥を利用してカーネルを侵害し、権限を昇格させる可能性があります。
StackRotはメモリ管理サブシステムで見つかったLinuxカーネルの脆弱性であるため、ほぼすべてのカーネル設定に影響を与え、トリガーに必要な権限は最小限です。ただし、メイプルノードはRCUコールバックを使用して解放され、実際のメモリ解放はRCU猶予期間まで遅延されることに注意が必要です。結果として、この脆弱性の悪用は困難であると考えられています。
私の知る限り、現在のところ、use-after-free-by-RCU(UAFBR)バグを標的とした公開エクスプロイトは存在しません。これは、CONFIG_PREEMPTやCONFIG_SLAB_MERGE_DEFAULT設定が存在しない場合でも、UAFBRバグが悪用可能であることが証明された初めての事例です。特筆すべきは、このエクスプロイトがGoogle kCTF VRPが提供する環境(bzImage_upstream_6.1.25、config)で正常に実証されたことです。
StackRotの脆弱性は、VMAツリー構造が赤黒木からメイプルツリーに変更されたバージョン6.1以降、Linuxカーネルに存在しています。
mmap()システムコールを使用してメモリマッピングを確立するたびに、カーネルは対応する仮想メモリ領域(VMA)を表すためにvm_area_structと呼ばれる構造体を生成します。この構造体には、フラグ、プロパティ、およびマッピングに関連するその他の関連詳細を含むさまざまな情報が格納されます。```c
struct vm_area_struct {
long unsigned int vm_start; /* 0 8 /
long unsigned int vm_end; / 8 8 /
struct mm_struct * vm_mm; / 16 8 /
pgprot_t vm_page_prot; / 24 8 /
long unsigned int vm_flags; / 32 8 /
union {
struct {
struct rb_node rb attribute((aligned(8))); / 40 24 /
/ --- cacheline 1 boundary (64 bytes) --- /
long unsigned int rb_subtree_last; / 64 8 /
} attribute((aligned(8))) shared attribute((aligned(8))); / 40 32 /
struct anon_vma_name * anon_name; / 40 8 /
} attribute((aligned(8))); / 40 32 /
/ --- cacheline 1 boundary (64 bytes) was 8 bytes ago --- /
struct list_head anon_vma_chain; / 72 16 /
struct anon_vma * anon_vma; / 88 8 /
const struct vm_operations_struct * vm_ops; / 96 8 /
long unsigned int vm_pgoff; / 104 8 /
struct file * vm_file; / 112 8 /
void * vm_private_data; / 120 8 /
/ --- cacheline 2 boundary (128 bytes) --- /
atomic_long_t swap_readahead_info; / 128 8 /
struct vm_userfaultfd_ctx vm_userfaultfd_ctx; / 136 0 */
/* size: 136, cachelines: 3, members: 14 */
/* forced alignments: 1 */
/* last cacheline: 8 bytes */
} attribute((aligned(8)));
その後、カーネルがページフォルトやその他のメモリ関連のシステムコールを処理する際、アドレスのみに基づいてVMAを高速に検索する必要があります。以前は、VMAは赤黒木を使用して管理されていました。しかし、Linuxカーネルバージョン6.1以降、メイプルツリーへの移行が行われました。[メイプルツリー][mt]は、重複しない範囲を格納するために最適化された、RCUセーフなBツリーデータ構造です。それにもかかわらず、その複雑な性質はコードベースに複雑さをもたらし、StackRot脆弱性を引き起こします。
[mt]: https://docs.kernel.org/6.4/core-api/maple_tree.html
その中核として、メイプルツリーはメイプルノードで構成されています。ツリーの構造は複雑かもしれませんが、この複雑さはStackRotバグとは無関係であることに注意することが重要です。したがって、この記事全体を通して、メイプルツリーは単一のノード、つまりルートノードのみで構成されていると仮定します。
このルートノードは最大16個の区間を含むことができます。これらの区間は、ギャップを表すか、VMAを指すかのいずれかです。ギャップも区間としてカウントされるため、すべての区間は連続的に接続され、ノード構造内に15個の端点(ピボットとも呼ばれる)のみが必要となります。なお、左端の端点と右端の端点は、親ノードから取得できるため省略されます。```c
struct maple_range_64 {
struct maple_pnode * parent; /* 0 8 */
long unsigned int pivot[15]; /* 8 120 */
/* --- cacheline 2 boundary (128 bytes) --- */
union {
void * slot[16]; /* 128 128 */
struct {
void * pad[15]; /* 128 120 */
/* --- cacheline 3 boundary (192 bytes) was 56 bytes ago --- */
struct maple_metadata meta; /* 248 2 */
}; /* 128 128 */
}; /* 128 128 */
/* size: 256, cachelines: 4, members: 3 */
};
上記のように、maple_range_64 構造体はメイプルノードを表します。ピボットに加えて、スロットはノードがリーフノードとして機能する場合は VMA 構造体を参照するために、ノードが内部ノードとして機能する場合は他のメイプルノードを参照するために使用されます。間隔がギャップに対応する場合、スロットには単に NULL 値が含まれます。ピボットポイントとスロットの配置は、以下に示すように視覚化できます:```
Slots -> | 0 | 1 | 2 | ... | 12 | 13 | 14 | 15 |
┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ │ │ │ └─ Implied maximum
│ │ │ │ │ │ │ └─ Pivot 14
│ │ │ │ │ │ └─ Pivot 13
│ │ │ │ │ └─ Pivot 12
│ │ │ │ └─ Pivot 11
│ │ │ └─ Pivot 2
│ │ └─ Pivot 1
│ └─ Pivot 0
└─ Implied minimum
Regarding concurrent modification, the maple tree imposes a specific
restriction, that is, an exclusive lock must be held by writers (*Rule W*). In
the case of the VMA tree, the exclusive lock corresponds to the MM write lock.
As for readers, two options are available. The first option involves holding
the MM read lock (*Rule A1*), which results in the writer being blocked by the
MM read-write lock. Alternatively, the second option is to enter the RCU
critical section (*Rule A2*). By doing so, the writer is not blocked, and
readers can continue their operations since the maple tree is RCU-safe. While
most existing VMA accesses opt for the first option (i.e., Rule A1), Rule A2 is
employed in a few performance-critical scenarios, such as lockless page faults.
However, there is an additional aspect that requires particular attention,
which pertains to stack expansion. The stack represents a memory area that is
mapped with the MAP_GROWSDOWN flag, indicating automatic expansion when an
address below the region is accessed. In such cases, the start address of the
corresponding VMA is adjusted, as well as the associated interval within the
maple tree. Notably, these adjustments are made without holding the MM write
lock.```c
static inline
void do_user_addr_fault(struct pt_regs *regs,
unsigned long error_code,
unsigned long address)
{
// ...
if (unlikely(!mmap_read_trylock(mm))) {
// ...
}
// ...
if (unlikely(expand_stack(vma, address))) {
// ...
}
// ...
}
通常、スタックVMAとその隣接VMAの間にはギャップが存在します。これはカーネルがstack guardを実施するためです。このシナリオでは、スタックを拡張する際、maple node内のピボット値のみを更新する必要があり、この処理はアトミックに実行できます。ただし、隣接VMAもMAP_GROWSDOWNフラグを持っている場合、stack guardは実施されません。```c int expand_downwards(struct vm_area_struct *vma, unsigned long address) { // ...
if (prev) {
if (!(prev->vm_flags & VM_GROWSDOWN) &&
vma_is_accessible(prev) &&
(address - prev->vm_end < stack_guard_gap))
return -ENOMEM;
}
// ...
}
その結果、スタック拡張によってギャップを解消できます。このような状況では、メイプルノード内のギャップ間隔を削除する必要があります。メイプルツリーはRCU-safeであるため、ノードをその場で上書きすることはできません。代わりに、新しいノードが作成され、ノードの置き換えがトリガーされ、古いノードはその後RCUコールバックを使用して破棄されます。```c
static inline void mas_wr_modify(struct ma_wr_state *wr_mas)
{
// ...
if ((wr_mas->offset_end - mas->offset <= 1) &&
mas_wr_slot_store(wr_mas)) // <-- in-place update
return;
else if (mas_wr_node_store(wr_mas)) // <-- node replacement
return;
// ...
}
RCUコールバックは、既存のすべてのRCUクリティカルセクションが終了した後にのみ呼び出されます。しかし、VMAにアクセスする際に問題が発生します。MM読み取りロックだけが保持されており、RCUクリティカルセクションには入らないためです(ルールA1に従う)。その結果、理論的にはコールバックがいつでも呼び出される可能性があり、古いmapleノードが解放されることになります。しかし、古いノードへのポインタがすでに取得されている可能性があり、その後そのノードにアクセスしようとすると解放後使用(use-after-free)バグが発生します。
解放後使用(UAF)が発生するバックトレースを以下に示します。```
mm_read_lock() mm_read_lock() expand_stack() find_vma_prev() expand_downwards() mas_walk() mas_store_prealloc() mas_state_walk() mas_wr_story_entry() mas_start() mas_wr_modify() mas_root() mas_wr_node_store() node = rcu_dereference_check() mas_replace() [ The node pointer is recorded ] mas_free() ma_free_rcu() call_rcu(&mt_free_rcu) [ The node is dead ] mm_read_unlock()
[ Wait for the next RCU grace period.. ] rcu_do_batch() mas_prev() mt_free_rcu() mas_prev_entry() kmem_cache_free() mas_prev_nentry() [ The node is freed ] mas_slot() mt_slot() rcu_dereference_check(node->..) [ UAF occurs here ] mm_read_unlock()
## 修正
私はこの脆弱性を6月15日にLinuxカーネルセキュリティチームに報告しました。
その後、このバグの対処はLinus Torvaldsが主導しました。
その複雑さのため、コンセンサスを得た一連のパッチを作成するのにほぼ2週間を要しました。
6月28日、Linuxカーネル6.5のマージウィンドウ中に、修正がLinusのツリーにマージされました。Linusは、技術的な観点からパッチシリーズを明らかにする[包括的なマージメッセージ][fix]を提供しました。
[fix]: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=9471f1f2f50282b9e8f59198ec6bb738b4ccc009
これらのパッチはその後、安定版カーネル([6.1.37][6.1]、[6.3.11][6.3]、[6.4.1][6.4])にバックポートされ、7月1日までに"Stack Rot"バグを効果的に解決しました。
[6.1]: https://lore.kernel.org/stable/2023070133-create-stainless-9a8c@gregkh/T/
[6.3]: https://lore.kernel.org/stable/2023070146-endearing-bounding-d21a@gregkh/T/
[6.4]: https://lore.kernel.org/stable/2023070140-eldercare-landlord-133c@gregkh/T/
## 悪用
このエクスプロイトは主にGoogle kCTFチャレンジに焦点を当てており、特に`CONFIG_PREEMPT`も`CONFIG_SLAB_MERGE_DEFAULT`も設定されていない場合を対象としています。StackRotを悪用するために最も重要なタスクは、以下の条件を満たすVMAイテレーションを見つけることです:
1. イテレーションのタイミングを制御できること。この制御により、VMAイテレーション中にRCU grace periodが終了することを確実にできます。
2. イテレーションがVMA構造から特定の情報を取得し、その情報をユーザ空間に返すこと。この機能により、mapleノードのUAF脆弱性を悪用してカーネルアドレスを漏洩できます。
3. イテレーションがVMA構造内の特定の関数ポインタを呼び出すこと。この特定の能力により、mapleノードのUAFを悪用してカーネルモードのプログラムカウンタ(PC)を制御できます。
選択されたVMAイテレーションは、`/proc/[pid]/maps`の内容を生成するためのイテレーションです。以下のセクションでは、このイテレーションが上記の条件をどのように満たすかを示します。
### ステップ0: UAFBRからUAFへ
VMAイテレーション中は常に、VMAツリーのルートノードへの参照が取得され、そのスロットを介してイテレーションが進みます。したがって、VMAイテレーション中に別のCPU上の別のスレッドでスタック拡張をトリガーすることで、ノードの置換を同時に開始できます。この時点で、古いノードにアクセスすることは、RCUによるuse-after-free(UAFBR)状況と見なされます。しかし、実際の問題が発生するのは、古いノードが実際に解放されたとき、つまりRCUコールバック内で発生します。
これには2つの課題があります:(i) 古いノードがいつ解放されるかを判断すること、(ii) 古いノードが解放される前にVMAイテレーションが完了しないようにすることです。
最初の問題は比較的簡単です。カーネルでは、`synchronize_rcu()`関数を使用してRCU grace periodの終了を待機し、既存のすべてのRCUコールバックが呼び出されたことを保証できます。ユーザ空間では、最終的に`synchronize_rcu()`を呼び出すシステムコールを同じ目的で利用できます。したがって、そのようなシステムコールが終了したとき、古いノードが解放されたことがわかります。特に、`membarrier(MEMBARRIER_CMD_GLOBAL, 0, -1)`というシステムコールは、単に`synchronize_rcu()`のみを呼び出します。```c
SYSCALL_DEFINE3(membarrier, int, cmd, unsigned int, flags, int, cpu_id)
{
// ...
switch (cmd) {
// ...
case MEMBARRIER_CMD_GLOBAL:
/* MEMBARRIER_CMD_GLOBAL is not compatible with nohz_full. */
if (tick_nohz_full_enabled())
return -EINVAL;
if (num_online_cpus() > 1)
synchronize_rcu();
return 0;
// ...
}
}
2つ目の疑問にはさらなる検討が必要です。いくつかの可能な解決策を以下に示します。
CONFIG_PREEMPTが設定されていない場合には効果がありません。jiffies_till_first_fqs(デフォルトで数ジフィー)を超えると、プロセッサ間割り込み(IPI)が犠牲CPUに送信され、自発的プリエンプションがトリガーされます。VMAイテレーションの場合、自発的プリエンプションによりRCU grace periodが終了し、メイプルノードが解放される可能性があり、UAFBRを真のuse-after-free(UAF)シナリオに効果的に変換します。重要な観察点として、/proc/[pid]/mapsのためのVMAイテレーション中に、ファイルマップされたメモリ領域の完全なファイルパスが生成されます。ディレクトリ名は通常最大255文字に制限されていますが、ディレクトリの深さには制限がありません。つまり、非常に大きなディレクトリ深さを持つファイルを作成し、このファイルにメモリマッピングを確立することで、/proc/[pid]/mapsへのアクセス時にVMAイテレーション中にかなりの時間がかかる可能性があります。その結果、この延長された時間により、RCU grace periodを終了させ、UAFプリミティブを獲得することが可能になります。```c
static void
show_map_vma(struct seq_file *m, struct vm_area_struct *vma)
{
// ...
/*
* Print the dentry name for named mappings, and a
* special [heap] marker for the heap:
*/
if (file) {
seq_pad(m, ' ');
/*
* If user named this anon shared memory via
* prctl(PR_SET_VMA ..., use the provided name.
*/
if (anon_name)
seq_printf(m, "[anon_shmem:%s]", anon_name->name);
else
seq_file_path(m, file, "\n");
goto done;
}
// ...
}
この手順は次の図で示されています:

### Step 1: From slab UAF to page UAF
UAFがスラブ内で動作しているとします。CONFIG_SLAB_MERGE_DEFAULTが有効で、メイプルノードのスラブがkmalloc-256とマージされる場合、古いノード内の内容は、kmalloc-256から新しい構造体を割り当て、ユーザ空間データで埋めることで制御できます。ただし、CONFIG_SLAB_MERGE_DEFAULTが設定されていない場合は、別のアプローチが必要です。この場合、解放されたノードのページをページアロケータに戻し、新しいページを割り当てて適宜埋めることで古いノードを制御できるようにする必要があります。
VMAツリーにはノードが一つしか含まれないことを思い出してください。したがって、`fork()`/`clone()`を利用することで、複数のVMAツリーと同数のメイプルノードが生成されます。一つのスラブがM個のメイプルノードを含み、M個のノードごとに1つのノードを保持し、他のすべてのノードを`exit()`で解放すると仮定すると、残りのノードはそれぞれのスラブ内で唯一のノードになります。最初は、これらのスラブはCPUのパーシャルリストに存在します。パーシャルリストがその容量に達すると、スラブは対応するNUMAノードのパーシャルリストにフラッシュバックされます。
スラブ内の最後のメイプルノードが解放されると、スラブは空になります。このスラブがNUMAノードのパーシャルリストに存在し、その特定のNUMAノードのパーシャルリストがすでに最大容量である場合、ページは直ちにページアロケータに戻されます。その結果、スラブUAFはページUAFのシナリオに変わります。解放されたページ内の内容は、`msgsnd()`を介してデータを送信することで操作できます。これにより、弾性オブジェクトが割り当てられ、提供されたユーザデータで直接埋められます。```c
static void __slab_free(struct kmem_cache *s, struct slab *slab,
void *head, void *tail, int cnt,
unsigned long addr)
{
// ...
if (unlikely(!new.inuse && n->nr_partial >= s->min_partial))
goto slab_empty;
// ...
return;
slab_empty:
// ...
discard_slab(s, slab);
}
スラブあたりのメイプルノード数 M は CPU 数に依存します。エクスプロイト実装では 2 つの CPU を想定しているため、M の値として 16 を仮定しています。次の図に示します。

メイプルノードの制御を獲得すると、後で繰り返し処理される後続の VMA のアドレスを操作できるようになります。対象の繰り返し処理は /proc/self/maps の生成を目的としているため、VMA 構造体内に存在する開始アドレスや終了アドレスなどの特定の VMA 情報がユーザ空間に返されます。
しかし、課題があります。メイプルノード内の VMA 構造体のアドレスを適切に設定するには、いくつかのアドレスが既に分かっている必要があります。幸いなことに、CVE-2023-0597 がこの目的に直接役立ちます。CVE-2023-0597 によると、cpu_entry_area のアドレスはランダム化されていません。この脆弱性は Linux 6.2 で修正されましたが、執筆時点では以前の安定版カーネルにバックポートされていません。したがって、VMA 構造体のアドレスを最後の IDT エントリのアドレスで上書きすることにより、asm_sysvec_spurious_apic_interrupt のアドレスを含むエントリが直接漏洩し、カーネルコードとカーネルデータのベースアドレスが明らかになります。

前述の方法は繰り返し使用でき、カーネルデータセクションからより多くのアドレスを段階的に公開できます。例えば、データセクション内の init_task.tasks.prev ポインタは、最新に作成されたタスクの task_struct 構造体を指します。これは間違いなくヒープ上に割り当てられます。

新しく作成されたすべてのタスクが終了すると、それらの task_struct 構造体はその後解放されます。これらのタスクの数が十分に多い場合、対応するページはページアロケータに返還されます。これにより、これらのページを再割り当てしてユーザデータで埋める可能性が生まれます。ただし、解放されたページは通常、per-CPU ページ (PCP) リストに属することに注意してください。PCP リストにあるページは、同じページ順序でのみ再割り当て可能です。したがって、ページアロケータから order-0 ページのみを必要とするユーザ空間への新しいページのマッピングだけでは、目的を達成できません。
それにもかかわらず、msgsnd システムコールは kmalloc 経由でメモリチャンクを要求し、これらのチャンクにユーザ定義データを格納します。kmalloc キャッシュが枯渇すると、特定の順序でページアロケータからページを要求します。メッセージサイズが正確に調整されていれば、望みの順序が得られます。これにより、以前にアドレスが漏洩したページが再割り当てされます。その結果、アドレスが既知でユーザが操作したデータを持つページを取得することが可能になります。
これで、アドレスが既知のページ内で VMA 構造体を偽造し、vma->vm_ops->name 関数ポインタを制御できるようになりました。次のステップでは、コンテナを脱出して root 権限を取得するための適切なガジェットを見つけることが含まれます。```c
static void
show_map_vma(struct seq_file *m, struct vm_area_struct *vma)
{
// ...
if (vma->vm_ops && vma->vm_ops->name) {
name = vma->vm_ops->name(vma);
if (name)
goto done;
}
// ...
}

ガジェット構造は以下の通りです:
1. スタックピボット: `movq %rbx, %rsi; movq %rbp, %rdi; call
__x86_indirect_thunk_r13` -> `pushq %rsi; jmp 46(%rsi)` -> `popq %rsp; ret`
-> `popq %rsp; ret`、ここで%rdi、%rbx、%r13は_初期_にユーザー制御可能なデータを指しています。
2. ルート権限を取得: `popq %rdi; ret` -> `prepare_kernel_cred` -> `popq
%rdi; ret` -> `movq %rax, (%rdi); ret`、ここで%rdiは_今_スタックトップを指しています。; `popq %rdi; ret` -> `commit_creds`、実質的に
`commit_creds(prepare_kernel_cred(&init_task))` を実行します。
3. コンテナのエスケープ: `popq %rdi; ret` -> `find_task_by_vpid` -> `popq %rdi;
ret` -> `movq %rax, (%rdi); ret`、ここで%rdiは_今_スタックトップを指しています。;
`popq %rdi; ret` -> `popq %rsi; ret` -> `switch_task_namespaces`、
実質的に `switch_task_namespaces(find_task_by_vpid(1), &init_nsproxy)` を実行します。
4. mmのロック解除: `popq %rax; ret` -> `movq %rbp, %rdi; call
__x86_indirect_thunk_rax`、ここで%rbpは元のseq_fileを指しています。;
`popq %rax; ret` -> `m_stop`、実質的に `m_stop(seq_file, ..)` を実行します。
5. ユーザースペースへの復帰: `swapgs_restore_regs_and_return_to_usermode` を使用し、
`execve()` を呼び出してシェルを取得します。
最後に、`nsenter --mount=/proc/1/ns/mnt` を使用してマウント名前空間を復元し、`cat /flag/flag` でフラグを取得します。
### ソースコード
完全なエクスプロイトソースは[こちら](https://github.com/lrh2000/stackrot/blob/HEAD/exp)から入手できます。詳細については、そのREADMEファイルを参照してください。