[PATCH v2 2/2] mm/migrate: refuse to migrate folios containing hwpoisoned pages
From: Yuanhe Shu
Date: Sun Oct 25 2026 - 22:56:27 EST
All callers of migrate_pages() without their own poison handling -
move_pages(2), mbind(), NUMA balancing, alloc_contig_range() (CMA)
and long-term GUP - can race memory_failure() the same way compaction
does (see patch 1): the migration isolates a folio and clears
PG_lru, memory_failure() sets PG_hwpoison, then fails to pin it and
gives up with MF_IGNORED, and the pending migration copies the
corrupted data into a fresh folio that carries no poison marker;
the userspace mapping ends up silently serving the corrupted copy
with no SIGBUS.
This was reproduced on v7.3-rc4-75-g62f4c998b297: an order-0 stress
workload running madvise(MADV_HWPOISON) concurrently with
move_pages() left the poison flag set on 8426 abandoned folios in
a 180-second run, identified via /proc/kpageflags; 698 of them
were subsequently migrated, and in every case the address then
served the copied corrupted data with no SIGBUS.
Refuse such migrations in the migration core, with three checks:
- migrate_folio_unmap() checks at entry, so a folio that is already
poisoned when the batch reaches it fails immediately, without
allocating a destination folio or unmapping it.
- migrate_folio_move() checks again immediately before the copy,
because migrate_pages_batch() unmaps a whole chunk of folios
before copying any of them, so a poison can land in between. A
GUP-based path such as madvise(MADV_HWPOISON) takes its
reference while the folio is still mapped, i.e. before the
unmap, and memory_failure() sets PG_hwpoison before releasing
that reference, so a racing poison is visible by the time
migration reaches the copy, unless the injecting thread is
delayed between taking the reference and setting the flag. A
hardware MCE can likewise land in the final instructions
between the check and the copy; on x86_64 folio_mc_copy()
additionally catches real-DRAM corruption at copy time.
- __migrate_folio() and migrate_huge_page_move_mapping() check
again after the copy, before the mapping is transferred: the
debugfs corrupt-pfn injector has no GUP or PTE ordering
constraint, so it can set the flag while the copy is in
progress, and only this recheck catches that window. Failing
there unwinds exactly like a copy failure.
All checks fail with -EHWPOISON, a permanent failure: the folio
goes back on the LRU with the poison flag intact, for reclaim or
a later memory_failure() to deal with - the same reasoning reclaim
settled on in
commit 9f1e8cd0b7c4 ("mm/vmscan: fix hwpoisoned large folio handling in shrink_folio_list").
The hugetlb path gets the same checks: its unmap-to-copy window is
single-folio and much narrower than the batched path, but the
checks cost bit tests.
Measured with the workload above (180-second runs; the count of
abandoned folios varies between runs with the race rate): the
unpatched kernel propagated 698 of 8426 abandoned folios; with the
pre-copy checks alone, 0 of 9711 were propagated. The post-copy
recheck closes the remaining injector window.
Soft offline is unaffected: it sets the poison marker only after its
own migration has succeeded. Memory hotplug offlining is
unaffected: it filters poisoned folios out before calling
migrate_pages(). Reclaim is unaffected: poisoned folios go through
its own unmap and skip paths, not migrate_pages().
A check at the copy site was previously proposed by Kaitao Cheng,
for the hugetlb path and for move_to_new_folio(); that discussion
stalled over the remaining race with memory_failure(). The
GUP-reference argument and the measurement above address that
concern for injection paths, and the post-copy recheck here is his
suggestion.
Assisted-by: LLM
Suggested-by: Kaitao Cheng <kaitao.cheng@xxxxxxxxx>
Signed-off-by: Yuanhe Shu <xiangzao@xxxxxxxxxxxxxxxxx>
Link: https://lore.kernel.org/r/20260707090136.52904-1-kaitao.cheng@xxxxxxxxx/
---
mm/migrate.c | 49 +++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 47 insertions(+), 2 deletions(-)
diff --git a/mm/migrate.c b/mm/migrate.c
index 15b45832bcfa..208a3991ca06 100644
--- a/mm/migrate.c
+++ b/mm/migrate.c
@@ -749,6 +749,14 @@ int migrate_huge_page_move_mapping(struct address_space *mapping,
if (unlikely(rc))
return rc;
+ /*
+ * Recheck after the copy: an injector can set the flag while the
+ * copy is in progress. The mapping has not been transferred yet,
+ * so failing here is the same unwinding as a copy failure.
+ */
+ if (unlikely(folio_contain_hwpoisoned_page(src)))
+ return -EHWPOISON;
+
xas_lock_irq(&xas);
if (!folio_ref_freeze(src, expected_count)) {
xas_unlock_irq(&xas);
@@ -881,6 +889,14 @@ static int __migrate_folio(struct address_space *mapping, struct folio *dst,
if (unlikely(rc))
return rc;
+ /*
+ * Recheck after the copy: an injector can set the flag while the
+ * copy is in progress. The mapping has not been transferred yet,
+ * so failing here is the same unwinding as a copy failure.
+ */
+ if (unlikely(folio_contain_hwpoisoned_page(src)))
+ return -EHWPOISON;
+
rc = __folio_migrate_mapping(mapping, dst, src, expected_count);
if (rc)
return rc;
@@ -1222,6 +1238,15 @@ static int migrate_folio_unmap(new_folio_t get_new_folio,
bool locked = false;
bool dst_locked = false;
+ if (unlikely(folio_contain_hwpoisoned_page(src))) {
+ /*
+ * Fail permanently before allocating dst: the folio
+ * goes back on the LRU for a later memory_failure().
+ */
+ migrate_folio_undo_src(src, 0, NULL, false, ret);
+ return -EHWPOISON;
+ }
+
dst = get_new_folio(src, private);
if (!dst)
return -ENOMEM;
@@ -1376,6 +1401,15 @@ static int migrate_folio_move(free_folio_t put_new_folio, unsigned long private,
prev = dst->lru.prev;
list_del(&dst->lru);
+ if (unlikely(folio_contain_hwpoisoned_page(src))) {
+ /*
+ * Recheck before the copy: a poison can land while the
+ * batch is still unmapping the rest of its chunk.
+ */
+ rc = -EHWPOISON;
+ goto out;
+ }
+
if (unlikely(page_has_movable_ops(&src->page))) {
rc = migrate_movable_ops_page(&dst->page, &src->page, mode);
if (rc)
@@ -1521,6 +1555,12 @@ static int unmap_and_move_hugetlb_folio(new_folio_t get_new_folio,
goto out_unlock;
}
+ if (unlikely(folio_contain_hwpoisoned_page(src))) {
+ /* Same reasoning as in migrate_folio_unmap(). */
+ rc = -EHWPOISON;
+ goto out_unlock;
+ }
+
if (folio_test_anon(src))
anon_vma = folio_get_anon_vma(src);
@@ -1546,8 +1586,13 @@ static int unmap_and_move_hugetlb_folio(new_folio_t get_new_folio,
was_mapped = 1;
}
- if (!folio_mapped(src))
- rc = move_to_new_folio(dst, src, mode);
+ if (!folio_mapped(src)) {
+ /* Same reasoning as in migrate_folio_move(). */
+ if (unlikely(folio_contain_hwpoisoned_page(src)))
+ rc = -EHWPOISON;
+ else
+ rc = move_to_new_folio(dst, src, mode);
+ }
if (was_mapped)
remove_migration_ptes(src, !rc ? dst : src, ttu);
--
2.43.7