Skip to contentSkip to conversation

mm: pgtable: free kernel page tables via RCU to fix ptdump UAF

PATCH Superseded David Carlier 2 participants 3 messages Jun 13 – Jun 15

Subsystems (2)

v4 of 8 Superseded Latest is v8 →

lore
Share & tools
lore
#0 lore raw

PATCH v4 1/1 mm: pgtable: free kernel page tables via RCU to fix ptdump UAF

ptdump walks the kernel page tables holding only the init_mm mmap lock and the memory hotplug lock. Neither of those stops vmalloc or ioremap from freeing a kernel PTE page underneath the walk. When vmap_try_huge_pmd() installs a huge mapping it collapses the existing PTE table and frees it through pmd_free_pte_page(), and on x86 that happens without the init_mm mmap lock. syzbot caught the resulting use after free in ptdump_pte_entry() reading a page table that had already been freed.

pagetable_free_kernel() used to free the page immediately on configurations without CONFIG_ASYNC_KERNEL_PGTABLE_FREE, and on the async ones it only batched a TLB flush before freeing. In both cases a lockless walker could still be dereferencing the page.

Defer the free by a grace period instead. pagetable_free_kernel() now hands every kernel page table to call_rcu(), so the page stays valid until any walk that may have observed it has finished. The async path keeps doing its TLB flush first and then queues the RCU free per page.

On the read side, walk_page_range_debug() takes the RCU read lock around the kernel walk through the new walk_kernel_page_table_range_rcu() helper. A walker either sees the cleared PMD and skips the page, or keeps it alive until it drops the lock. The plain walk_kernel_page_table_range() stays as it is for callers that already own their range and cannot race a free, such as the arm64 page table split paths.

Fixes 5ba2f0a15564 ("mm: introduce deferred freeing for kernel page tables")
Reported-by [email protected]
Assisted-by Claude:claude-opus-4-8

---
v4: defer the free in both the async and non async configs, not just
the async one. Move the walk under a named walk_kernel_page_table_range_rcu() helper instead of open coding rcu_read_lock() in walk_page_range_debug().
v3: take rcu_read_lock() in the init_mm branch of
walk_page_range_debug() rather than inside the lockless walker, which the arm64 split paths also use with GFP_PGTABLE_KERNEL and can sleep.
v2: use call_rcu() instead of synchronize_rcu().

---
 include/linux/mm.h   |  7 -------
 mm/pagewalk.c        | 18 ++++++++++++++++--
 mm/pgtable-generic.c | 21 ++++++++++++++++++++-
 3 files changed, 36 insertions(+), 10 deletions(-)
include/linux/mm.h +0 -7
index 485df9c2dbdd..79408a17a1b0 100644
@@ -3695,14 +3695,7 @@ static inline void __pagetable_free(struct ptdesc *pt)
 	__free_pages(page, compound_order(page));
 }
 
-#ifdef CONFIG_ASYNC_KERNEL_PGTABLE_FREE
 void pagetable_free_kernel(struct ptdesc *pt);
-#else
-static inline void pagetable_free_kernel(struct ptdesc *pt)
-{
-	__pagetable_free(pt);
-}
-#endif
 /**
  * pagetable_free - Free pagetables
  * @pt:	The page table descriptor
mm/pagewalk.c +16 -2
index 3ae2586ff45b..5b5807a88394 100644
@@ -664,6 +664,19 @@ int walk_kernel_page_table_range_lockless(unsigned long start, unsigned long end
 	return walk_pgd_range(start, end, &walk);
 }
 
+static int walk_kernel_page_table_range_rcu(unsigned long start, unsigned long end,
+		const struct mm_walk_ops *ops, pgd_t *pgd,
+		void *private)
+{
+	int err;
+
+	rcu_read_lock();
+	err = walk_kernel_page_table_range(start, end, ops, pgd, private);
+	rcu_read_unlock();
+
+	return err;
+}
+
 /**
  * walk_page_range_debug - walk a range of pagetables not backed by a vma
  * @mm:		mm_struct representing the target process of page table walk
@@ -693,8 +706,9 @@ int walk_page_range_debug(struct mm_struct *mm, unsigned long start,
 
 	/* For convenience, we allow traversal of kernel mappings. */
 	if (mm == &init_mm)
-		return walk_kernel_page_table_range(start, end, ops,
-						    pgd, private);
+		return walk_kernel_page_table_range_rcu(start, end, ops, pgd,
+							private);
+
 	if (start >= end || !walk.mm)
 		return -EINVAL;
 	if (!check_ops_safe(ops))
mm/pgtable-generic.c +20 -1
index b91b1a98029c..d45a556b4021 100644
@@ -410,6 +410,13 @@ pte_t *pte_offset_map_lock(struct mm_struct *mm, pmd_t *pmd,
 	goto again;
 }
 
+static void kernel_pgtable_free_rcu(struct rcu_head *head)
+{
+	struct ptdesc *pt = container_of(head, struct ptdesc, pt_rcu_head);
+
+	__pagetable_free(pt);
+}
+
 #ifdef CONFIG_ASYNC_KERNEL_PGTABLE_FREE
 static void kernel_pgtable_work_func(struct work_struct *work);
 
@@ -434,8 +441,15 @@ static void kernel_pgtable_work_func(struct work_struct *work)
 	spin_unlock(&kernel_pgtable_work.lock);
 
 	iommu_sva_invalidate_kva_range(PAGE_OFFSET, TLB_FLUSH_ALL);
+
+	/*
+	 * Lockless kernel page table walkers (ptdump, and any other user of
+	 * walk_kernel_page_table_range_lockless()) dereference these pages
+	 * under rcu_read_lock(). Free them after a grace period so a walker
+	 * cannot still be reading a page we release.
+	 */
 	list_for_each_entry_safe(pt, next, &page_list, pt_list)
-		__pagetable_free(pt);
+		call_rcu(&pt->pt_rcu_head, kernel_pgtable_free_rcu);
 }
 
 void pagetable_free_kernel(struct ptdesc *pt)
@@ -446,4 +460,9 @@ void pagetable_free_kernel(struct ptdesc *pt)
 
 	schedule_work(&kernel_pgtable_work.work);
 }
+#else
+void pagetable_free_kernel(struct ptdesc *pt)
+{
+	call_rcu(&pt->pt_rcu_head, kernel_pgtable_free_rcu);
+}
 #endif
Signature · 1 line
2.53.0
#1 lore raw

TL;DR you're fixing what was an existing race because ptdump touches memory it doesn't own, unsafely. 5ba2f0a15564 just made it more likely by increasing the race window.

Your commit message should reflect this.

Also you should have ptdump take the RCU lock and assert the RCU lock in walk_page_range_debug() and update comments to reflect this, rather than taking the RCU lock there.

Quoted 1 line · David Carlier

ptdump walks the kernel page tables holding only the init_mm mmap lock

Can you be specific, i.e. mention that ptdump_walk_pgd() takes these locks.

Quoted 2 lines

and the memory hotplug lock. Neither of those stops vmalloc or ioremap from freeing a kernel PTE page underneath the walk. When

I think saying 'or ioremap' is not really useful here. It's vmalloc.

Quoted 3 lines

vmap_try_huge_pmd() installs a huge mapping it collapses the existing PTE table and frees it through pmd_free_pte_page(), and on x86 that happens without the init_mm mmap lock. syzbot caught the resulting

And on other arches we acquire the init_mm mmap_lock? I don't think we do do we?

Quoted 2 lines

use after free in ptdump_pte_entry() reading a page table that had already been freed.

It's usually best practice to respond to the syzbot thread explaining your reasoning, as anybody else looking at the report doesn't know that you've provided a diagnosis, so it just seems like an open bug right now.

(If you're leaning heavily on Claude, I'd strongly suggest that you ensure you have a good understanding of what's going here without it).

Quoted 2 lines

pagetable_free_kernel() used to free the page immediately on

Not used to, currently does. This patch is the change...

Quoted 2 lines

configurations without CONFIG_ASYNC_KERNEL_PGTABLE_FREE, and on the async ones it only batched a TLB flush before freeing. In both cases a

'And on the async ones'? What does this mean? Can we be clearer here please.

I think it's horribly confused really.

You need to identify the _actual_ problem.

It seems to me that what's going on here is that this bug _already existed_ previously.

It's just that 5ba2f0a15564 made it (much?) _more likely_ by increasing the race window.

The underlying problem is that ptdump is accessing ranges of memory it shouldn't be, completely unprotected.

Quoted 1 line

lockless walker could still be dereferencing the page.

You say this, then only change the logic for for the ptdump case...

I find it odd also that we take all the (previously assumed to be) required locks in ptdump_walk_pgd() - the memory hotplug and mmap write lock, but now you insert _another_ lock in walk_page_range_debug()?

So why are we taking some locks in the caller, and others in the function that is called?

In fact the convention in pagewalk.c is that we _assert_ locks not take them.

Quoted 6 lines

Defer the free by a grace period instead. pagetable_free_kernel() now hands every kernel page table to call_rcu(), so the page stays valid until any walk that may have observed it has finished. The async path keeps doing its TLB flush first and then queues the RCU free per page.

Quoted 7 lines

On the read side, walk_page_range_debug() takes the RCU read lock around the kernel walk through the new walk_kernel_page_table_range_rcu() helper. A walker either sees the cleared PMD and skips the page, or keeps it alive until it drops the lock. The plain walk_kernel_page_table_range() stays as it is for callers that already own their range and cannot race a free, such as the arm64 page table split paths.

You didn't actually explain as I requested what exactly 'owning a range' means and why that protects them from this race.

You need to _spell out_ convincingly why only walk_page_range_debug() need be changed.

In fact, you need to list the callers of all the functions that can walk kernel page tables and explain, convincingly, why they are all safe and do not require an RCU read lock.

- All the callers of walk_kernel_page_table_range()
https://elixir.bootlin.com/linux/v7.1/A/ident/walk_kernel_page_table_range
- All the callers of walk_kernel_page_table_range_lockless()
https://elixir.bootlin.com/linux/v7.1/C/ident/walk_kernel_page_table_range_lockless

I think the argument hinges on _ownership_.

Using Claude myself, suggests so (see below). But I'd want a convincing argument here.

  ┌───────────────────────────────────────────┬────────────────────────────────────┬──────────┐
  │                  Caller                   │           Range it walks           │ Exposed? │
  ├───────────────────────────────────────────┼────────────────────────────────────┼──────────┤
  │ arm64 __change_memory_common (set_memory) │ caller-owned                       │ No       │
  ├───────────────────────────────────────────┼────────────────────────────────────┼──────────┤
  │ arm64 range_split_to_ptes (block split)   │ caller-owned                       │ No       │
  ├───────────────────────────────────────────┼────────────────────────────────────┼──────────┤
  │ riscv __set_memory                        │ caller-owned                       │ No       │
  ├───────────────────────────────────────────┼────────────────────────────────────┼──────────┤
  │ loongarch set_memory                      │ caller-owned                       │ No       │
  ├───────────────────────────────────────────┼────────────────────────────────────┼──────────┤
  │ openrisc DMA set-uncached                 │ caller-owned                       │ No       │
  ├───────────────────────────────────────────┼────────────────────────────────────┼──────────┤
  │ hugetlb_vmemmap remap                     │ caller-owned (one folio's vmemmap) │ No       │
  ├───────────────────────────────────────────┼────────────────────────────────────┼──────────┤
  │ ptdump (walk_page_range_debug)            │ the entire kernel address space    │ Yes      │
  └───────────────────────────────────────────┴────────────────────────────────────┴──────────┘
Quoted 2 lines
Fixes 5ba2f0a15564 ("mm: introduce deferred freeing for kernel page tables")

It's not really fixing this commit honestly. But I guess it's a sensible fiction, since it increased the race window.

Quoted 44 lines

---
v4: defer the free in both the async and non async configs, not just
the async one. Move the walk under a named walk_kernel_page_table_range_rcu() helper instead of open coding rcu_read_lock() in walk_page_range_debug().
v3: take rcu_read_lock() in the init_mm branch of
walk_page_range_debug() rather than inside the lockless walker, which the arm64 split paths also use with GFP_PGTABLE_KERNEL and can sleep.
v2: use call_rcu() instead of synchronize_rcu().

---
 include/linux/mm.h   |  7 -------
 mm/pagewalk.c        | 18 ++++++++++++++++--
 mm/pgtable-generic.c | 21 ++++++++++++++++++++-
 3 files changed, 36 insertions(+), 10 deletions(-)
include/linux/mm.h +0 -7
index 485df9c2dbdd..79408a17a1b0 100644
@@ -3695,14 +3695,7 @@ static inline void __pagetable_free(struct ptdesc *pt)
 	__free_pages(page, compound_order(page));
 }
 
-#ifdef CONFIG_ASYNC_KERNEL_PGTABLE_FREE
 void pagetable_free_kernel(struct ptdesc *pt);
-#else
-static inline void pagetable_free_kernel(struct ptdesc *pt)
-{
-	__pagetable_free(pt);
-}
-#endif
 /**
  * pagetable_free - Free pagetables
  * @pt:	The page table descriptor
mm/pagewalk.c +0 -0
index 3ae2586ff45b..5b5807a88394 100644

mm/pagewalk.c int walk_kernel_page_table_range_lockless(unsigned long start, unsigned long end · line 666

Quoted 3 lines
mm/pagewalk.c +0 -0
@@ -664,6 +664,19 @@ int walk_kernel_page_table_range_lockless(unsigned long start, unsigned long end
 	return walk_pgd_range(start, end, &walk);
 }
 

No comments explaining anything?

mm/pagewalk.c int walk_kernel_page_table_range_lockless(unsigned long start, unsigned long end · line 669

Quoted 3 lines
mm/pagewalk.c +3 -0
+static int walk_kernel_page_table_range_rcu(unsigned long start, unsigned long end,
+		const struct mm_walk_ops *ops, pgd_t *pgd,
+		void *private)

I don't understand your whitespace here, why is void *private on a random newline?

Also this seems to be far less code/comment than the previous version so separation doesn't make sense here any more...

mm/pagewalk.c int walk_kernel_page_table_range_lockless(unsigned long start, unsigned long end · line 675

Quoted 6 lines
mm/pagewalk.c +6 -0
+{
+	int err;
+
+	rcu_read_lock();
+	err = walk_kernel_page_table_range(start, end, ops, pgd, private);
+	rcu_read_unlock();

As mentioned previously, let's just assert the RCU lock is held.

Quoted 38 lines
mm/pagewalk.c +7 -2
+
+	return err;
+}
+
 /**
  * walk_page_range_debug - walk a range of pagetables not backed by a vma
  * @mm:		mm_struct representing the target process of page table walk
@@ -693,8 +706,9 @@ int walk_page_range_debug(struct mm_struct *mm, unsigned long start,
 
 	/* For convenience, we allow traversal of kernel mappings. */
 	if (mm == &init_mm)
-		return walk_kernel_page_table_range(start, end, ops,
-						    pgd, private);
+		return walk_kernel_page_table_range_rcu(start, end, ops, pgd,
+							private);
+
 	if (start >= end || !walk.mm)
 		return -EINVAL;
 	if (!check_ops_safe(ops))
mm/pgtable-generic.c +7 -0
index b91b1a98029c..d45a556b4021 100644
@@ -410,6 +410,13 @@ pte_t *pte_offset_map_lock(struct mm_struct *mm, pmd_t *pmd,
 	goto again;
 }
 
+static void kernel_pgtable_free_rcu(struct rcu_head *head)
+{
+	struct ptdesc *pt = container_of(head, struct ptdesc, pt_rcu_head);
+
+	__pagetable_free(pt);
+}
+
 #ifdef CONFIG_ASYNC_KERNEL_PGTABLE_FREE
 static void kernel_pgtable_work_func(struct work_struct *work);
 

mm/pgtable-generic.c static void kernel_pgtable_work_func(struct work_struct *work) · line 448

Quoted 8 lines
mm/pgtable-generic.c +5 -0
@@ -434,8 +441,15 @@ static void kernel_pgtable_work_func(struct work_struct *work)
 	spin_unlock(&kernel_pgtable_work.lock);
 
 	iommu_sva_invalidate_kva_range(PAGE_OFFSET, TLB_FLUSH_ALL);
+
+	/*
+	 * Lockless kernel page table walkers (ptdump, and any other user of
+	 * walk_kernel_page_table_range_lockless()) dereference these pages
+	 * under rcu_read_lock(). Free them after a grace period so a walker

Err what? walk_kernel_page_table_range_lockless() callers? Now you're contradicting yourself...

Instead, refer to _debug_ kernel page table walkers may be walking non-owned ranges of memory, thus we must protect against concurrent page table freeing.

mm/pgtable-generic.c static void kernel_pgtable_work_func(struct work_struct *work) · line 452

Quoted 5 lines
mm/pgtable-generic.c +3 -1
+	 * cannot still be reading a page we release.
+	 */
 	list_for_each_entry_safe(pt, next, &page_list, pt_list)
-		__pagetable_free(pt);
+		call_rcu(&pt->pt_rcu_head, kernel_pgtable_free_rcu);

I wonder if this would somehow be inefficient for a lot of page tables being freed... but not sure there's an alternative here without somehow pinning the list.

But probably it's ok.

mm/pgtable-generic.c void pagetable_free_kernel(struct ptdesc *pt) · line 466

Quoted 11 lines
mm/pgtable-generic.c +4 -0
 }
 
 void pagetable_free_kernel(struct ptdesc *pt)
@@ -446,4 +460,9 @@ void pagetable_free_kernel(struct ptdesc *pt)
 
 	schedule_work(&kernel_pgtable_work.work);
 }
+#else
+void pagetable_free_kernel(struct ptdesc *pt)
+{
+	call_rcu(&pt->pt_rcu_head, kernel_pgtable_free_rcu);

Please don't add code like this without explanation. Add a comment explaining why you are doing this, or say 'refer to the comment in kernel_pgtable_work_func()'.

Quoted 5 lines
mm/pgtable-generic.c +1 -1
+}
 #endif
--

2.53.0

I think doing this under RCU is the correct solution here, overall.

So with the comments addressed above for the freeing side, I think the locking side should be something like:

mm/pagewalk.c +13 -4
index 3ae2586ff45b..b247c973e4d6 100644
@@ -620,7 +620,7 @@ int walk_page_range(struct mm_struct *mm, unsigned long start,
  * Note: Be careful to walk the kernel pages tables, the caller may be need to
  * take other effective approaches (mmap lock may be insufficient) to prevent
  * the intermediate kernel page tables belonging to the specified address range
- * from being freed (e.g. memory hot-remove).
+ * from being freed (e.g. memory hot-remove, vmap huge page promotion).
  */
 int walk_kernel_page_table_range(unsigned long start, unsigned long end,
                const struct mm_walk_ops *ops, pgd_t *pgd, void *private)
@@ -643,7 +643,7 @@ int walk_kernel_page_table_range(unsigned long start, unsigned long end,
  * Use this function to walk the kernel page tables locklessly. It should be
  * guaranteed that the caller has exclusive access over the range they are
  * operating on - that there should be no concurrent access, for example,
- * changing permissions for vmalloc objects.
+ * changing permissions for vmalloc objects, or vmap huge page promotion).
  */
 int walk_kernel_page_table_range_lockless(unsigned long start, unsigned long end,
                const struct mm_walk_ops *ops, pgd_t *pgd, void *private)
@@ -677,6 +677,11 @@ int walk_kernel_page_table_range_lockless(unsigned long start, unsigned long end
  * not backed by VMAs. Because 'unusual' entries may be walked this function
  * will also not lock the PTEs for the pte_entry() callback.
  *
+ * If traversing kernel mappings, the RCU lock must be held, since debug access
+ * to memory ranges implies the caller does not own these pages and thus the
+ * traversal might race with vmap huge page promotion which frees page tables
+ * under RCU.
+ *
  * This is for debugging purposes ONLY.
  */
 int walk_page_range_debug(struct mm_struct *mm, unsigned long start,
@@ -691,10 +696,14 @@ int walk_page_range_debug(struct mm_struct *mm, unsigned long start,
                .no_vma         = true
        };
 
-       /* For convenience, we allow traversal of kernel mappings. */
-       if (mm == &init_mm)
+       /* For convenience, we allow traversal of kernel mappings under RCU. */
+       if (mm == &init_mm) {
+               RCU_LOCKDEP_WARN(!rcu_read_lock_held(),
+                                "no rcu read lock held");
+
                return walk_kernel_page_table_range(start, end, ops,
                                                    pgd, private);
+       }
        if (start >= end || !walk.mm)
                return -EINVAL;
        if (!check_ops_safe(ops))
mm/ptdump.c +2 -0
index 973020000096..50cd96a33dfd 100644
@@ -178,11 +178,13 @@ void ptdump_walk_pgd(struct ptdump_state *st, struct mm_struct *mm, pgd_t *pgd)
 
        get_online_mems();
        mmap_write_lock(mm);
+       rcu_read_lock();
        while (range->start != range->end) {
                walk_page_range_debug(mm, range->start, range->end,
                                      &ptdump_ops, pgd, st);
                range++;
        }
+       rcu_read_unlock();
        mmap_write_unlock(mm);
        put_online_mems();
 

Can you also then:

- Check that none of the architectures that implement ptdump_state->effective_prot_pXX() and ptdump_state->note_page_pXX() callbacks sleep or do anything that's RCU-unsafe.

- Document this in the commit message.

Thanks, Lorenzo

#2 lore raw
Quoted 3 lines

TL;DR you're fixing what was an existing race because ptdump touches memory it doesn't own, unsafely. 5ba2f0a15564 just made it more likely by increasing the race window.

Right, better framing. I'll rewrite the changelog around the pre-existing race and keep the Fixes tag only as the window-widener.

Quoted 2 lines

And on other arches we acquire the init_mm mmap_lock? I don't think we do do we?

My bad .., I had it backwards. Will drop the x86 wording.

Quoted 2 lines

Also you should have ptdump take the RCU lock and assert the RCU lock in walk_page_range_debug() [...] rather than taking the RCU lock there.

Will do. r

Quoted 2 lines

In fact, you need to list the callers [...] and explain, convincingly, why they are all safe

It s ownership. Every other walker works on a range it owns and is the only one mutating it:

walk_kernel_page_table_range():
openrisc DMA, loongarch/riscv set_memory, hugetlb_vmemmap remap
walk_kernel_page_table_range_lockless():
arm64 __change_memory_common, arm64 range_split_to_ptes

Only ptdump walks the whole address space, including ranges vmalloc is promoting to huge PMDs, so only it can race a free. I ll spell it out.

Quoted 3 lines

- Check that none of the architectures that implement ptdump_state->effective_prot_pXX() and ptdump_state->note_page_pXX() callbacks sleep [...]

Checked arm64/powerpc/s390/x86/riscv: callbacks only format into the preallocated seq_file buffer, no sleeping, and pagewalk.c has no cond_resched(). The only GFP_KERNEL bits (s390 markers) run before the walk. Will note it.

I'll reply to the syzbot thread with the diagnosis too. v5 to follow.

Cheers.