[PATCH v2] ext4: fix discard work use-after-free on failed mount
From: Fan Wu
Date: Wed Sep 09 2026 - 01:42:55 EST
ext4_put_super() shuts the journal down before it releases the mballoc
structures, but the failure unwind of __ext4_fill_super() runs the two
steps in the opposite order: failed_mount6 calls ext4_mb_release(),
which flushes sbi->s_discard_work, and the journal is destroyed only
later, just above failed_mount3a.
With -o discard, that journal destroy re-arms the work after the flush:
ext4_journal_destroy() calls ext4_force_commit(), and the commit
callback, registered once mballoc is initialized, queues s_discard_work
whenever the discard option is set, even with an empty freed-data list.
A running transaction can be live at that point: replaying the orphan
list is the easiest way to get one, and the quota paths on
failed_mount8/failed_mount9 can leave one too. The final force commit
is not necessarily a no-op.
Nothing drains s_discard_work after that point: failed_mount3 flushes
only s_sb_upd_work and the s_err_report timer, and ext4_fill_super()
then frees sbi with a plain kfree() through ext4_free_sbi(). If the
system_dfl_wq worker is delayed across the rest of the unwind,
ext4_discard_work() then accesses the freed sbi, first through
sbi->s_sb and then while taking sbi->s_md_lock.
This is the pattern fixed for the s_err_report timer in commit
0ce160c5bdb6 ("ext4: fix timer use-after-free on failed mount"):
async state armed after the unwind's last drain point.
Force the commit at failed_mount6, before ext4_mb_release(), so that
the discard work its commit callback queues is normally already
pending when the flush_work() in ext4_mb_release() runs.
disable_work_sync() there then makes this airtight: it also drains an
instance the flush missed, and no later commit, including the force
commit inside the journal destroy further down the unwind, can
requeue the work. The journal destroy stays at its existing position.
This issue was found by an in-house static analysis tool.
Fixes: 55cdd0af2bc5 ("ext4: get discard out of jbd2 commit kthread contex")
Cc: stable@xxxxxxxxxxxxxxx # v6.10+
Assisted-by: Codex:gpt-5.6
Signed-off-by: Fan Wu <fanwu01@xxxxxxxxxx>
---
v2: per review from Jan Kara, keep the journal shutdown at its existing
point instead of destroying the journal at failed_mount6: force the
outstanding transaction there so the re-arm happens before the
ext4_mb_release() flush, and disable_work_sync() the discard work
after the flush. jbd2 publishes j_commit_sequence before running
the commit callback, so the callback can still queue the work
after ext4_force_commit() and the flush have both returned;
disable_work_sync() drains that instance as well and keeps the
later journal destroy from requeueing the work.
v1: https://lore.kernel.org/linux-ext4/20260820052102.4616-1-fanwu01@xxxxxxxxxx/
---
fs/ext4/mballoc.c | 2 ++
fs/ext4/super.c | 9 +++++++++
2 files changed, 11 insertions(+)
diff --git a/fs/ext4/mballoc.c b/fs/ext4/mballoc.c
index ed1bd00e11cd..874b71931ab6 100644
--- a/fs/ext4/mballoc.c
+++ b/fs/ext4/mballoc.c
@@ -3897,6 +3897,8 @@ void ext4_mb_release(struct super_block *sb)
* wait the discard work to drain all of ext4_free_data
*/
flush_work(&sbi->s_discard_work);
+ /* Prevent the later journal teardown from requeueing discard work. */
+ disable_work_sync(&sbi->s_discard_work);
WARN_ON_ONCE(!list_empty(&sbi->s_discard_list));
group_info = rcu_access_pointer(sbi->s_group_info);
diff --git a/fs/ext4/super.c b/fs/ext4/super.c
index 4b6112e5d6c5..62d1930e9783 100644
--- a/fs/ext4/super.c
+++ b/fs/ext4/super.c
@@ -5761,6 +5761,15 @@ failed_mount8: __maybe_unused
failed_mount7:
ext4_unregister_li_request(sb);
failed_mount6:
+ /*
+ * Flush any running transaction: its commit callback may queue
+ * s_discard_work, which the flush_work() in ext4_mb_release()
+ * below drains; disable_work_sync() there also drains an
+ * instance queued after that, and keeps the journal destroy
+ * further down the unwind from requeueing the work.
+ */
+ if (sbi->s_journal)
+ ext4_force_commit(sb);
ext4_mb_release(sb);
ext4_flex_groups_free(sbi);
failed_mount5: