Skip to content

Fix use-after-free when a database tab close is re-entered - #13598

Merged
droidmonkey merged 3 commits into
keepassxreboot:developfrom
Aetf:fix/close-database-tab-reentrancy
Aug 24, 2026
Merged

droidmonkey merged 3 commits into
keepassxreboot:developfrom
Aetf:fix/close-database-tab-reentrancy

Conversation

@Aetf

@Aetf Aetf commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Overview

Fixes a heap-use-after-free that ASan catches in TestGuiFdoSecrets::testCollectionDeleteConcurrent on current develop (Qt 6.11, Debug/ASan build).

Root cause

Closing a database tab may spin a nested event loop while the widget performs save-on-close (Database::save waits for its worker thread in AsyncTask::waitForFuture). If DatabaseTabWidget::closeDatabaseTab is re-entered for the same widget from within that nested loop — e.g. two concurrent FdoSecrets prompts deleting the same collection — QWidget::close() short-circuits on its internal is_closing flag and reports success immediately without delivering another close event.

This short-circuit is identical in Qt 5 and Qt 6 (QWidgetPrivate::close_helper in Qt 5.15 and QWidgetPrivate::handleClose in Qt 6 both start with if (data.is_closing) return true;), so none of the downstream guards — including DatabaseWidget::lock()'s m_attemptingLock — ever run for the re-entrant close. The bug is therefore long-standing rather than a Qt 6 regression; it was surfaced by scheduler timing under an ASan build with Qt 6.11.

The re-entrant call proceeds to removeTab() + deleteLater(), the DeferredDelete executes inside the still-running nested loop and destroys the widget under the outer invocation's feet; the outer call afterwards also repeats parts of the teardown (stale-index removeTab, deleteLater on the freed widget, duplicate databaseClosed emission).

Event trace of the failure (from an instrumented run; "first"/"second" are the two concurrent delete prompts):

  1. first prompt: promptSync → closeDatabaseTab → close() → closeEvent → lock() → save → performSave → nested event loop in AsyncTask::waitForFuture
  2. second prompt's timer fires inside that loop: closeDatabaseTab (tab still present) → close() returns true immediately (is_closing), no close event delivered → removeTab + deleteLater
  3. ~DatabaseWidget runs via DeferredDelete inside the still-running nested loop
  4. first prompt's save() resumes after performSave() → write to freed memory

Changes

  • DatabaseTabWidget::closeDatabaseTab: track widgets with a close in progress; a re-entrant call returns false without side effects, and only the outermost invocation may remove the tab, schedule deletion, and emit databaseClosed (exactly once now). Also re-resolve the tab index after close() since nested event loops may shift tabs.

  • FdoSecrets PromptBase::prompt: schedule promptSync only once; repeated Prompt() calls wait for the same Completed signal instead of running the prompt action multiple times.

  • FdoSecrets DeleteCollectionPrompt: clear the collection reference on Collection::collectionAboutToDelete. The QPointer alone still sees a retired collection (already removed from D-Bus with m_backend reset) until its deleteLater is delivered, which would violate the invariant behind Q_ASSERT(m_backend) in Collection::doDelete. With the explicit clearing, "non-null m_collection implies usable backend" holds again and the assert stays intact.

  • FdoSecrets LockCollectionsPrompt/UnlockPrompt (second commit): the same retirement window let these call doLock()/doUnlock() on a retired collection (assert in debug builds), and UnlockPrompt additionally hung forever when a collection went away mid-prompt — the retired collection never emits doneUnlockCollection (service connections are severed on retirement) and the count-based completion condition could never be satisfied, so Completed never fired. LockCollectionsPrompt now clears retired references (skipped like destroyed ones); UnlockPrompt tracks awaited collections in an explicit pending set (pre-registered before issuing any doUnlock, so synchronous completions of already-unlocked databases cannot prematurely trigger the item unlock step) and accounts retirement as a rejection, guaranteeing termination. Covered by the new testServiceUnlockConcurrentDelete regression test (unlock prompt waiting on user + concurrent Collection.Delete), verified to fail via signal-spy timeout without the fix.

  • FdoSecrets Service unlock dialog bookkeeping (third commit): if the DatabaseWidget died while its unlock dialog was open, its m_unlockingDb entry stayed forever, keyed by a dangling pointer (identity comparison only, no UB) — permanently blocking doUnlockAnyDatabaseInDialog (unlock-before-search) and any future widget allocated at the same address. A cleanup connection on the widget's destroyed signal now removes the entry, in both the per-database and unlock-any flows. The regression test reopens the database after the concurrent delete and verifies the unlock dialog still appears; without the fix it times out in SearchItems waiting for a dialog that never shows.

  • TestGuiFdoSecrets::testCollectionDeleteConcurrent: fix the second prompt proxy accidentally created from the first prompt's path — the test was exercising a double-Prompt() on one object rather than two concurrent prompts. Assert the fail-fast semantics: the losing concurrent prompt completes as dismissed while the winner performs the deletion.

Testing

On Arch (Qt 6.11.1, GCC 16, Debug + ASan), each test on an isolated private D-Bus session:

  • testguifdosecrets: 36/36 pass (previously aborted with the UAF) — on a headless Wayland compositor (weston)
  • testfdosecrets: 6/6 pass — same environment
  • testgui: 47/50 — on Xvfb + openbox (X11), since this test relies on client self-activation which the Wayland protocol does not permit. The 2 failures are pre-existing focus-timing flakes under ASan slowness (editPassword->hasFocus(), QTestLib reports the 5s timeout as merely too short), plus 1 skip for the missing system tray

🤖 Generated with Claude Code

Closing a database tab may spin a nested event loop while the widget
performs save-on-close (Database::save waits for its worker thread in
AsyncTask::waitForFuture). If DatabaseTabWidget::closeDatabaseTab is
re-entered for the same widget from within that nested loop (e.g. two
concurrent FdoSecrets prompts deleting the same collection),
QWidget::close() short-circuits on its internal is_closing flag and
reports success immediately, without delivering another close event.
This behavior is identical in Qt 5 (QWidgetPrivate::close_helper) and
Qt 6 (QWidgetPrivate::handleClose), so none of the downstream guards
(such as DatabaseWidget::lock's m_attemptingLock) ever run for the
re-entrant close. The re-entrant call then removed the tab and
scheduled deletion, and the DeferredDelete event executed inside the
still-running nested loop, destroying the widget while the outer
invocation was still using it; the outer call afterwards repeated
parts of the teardown (stale-index removeTab, deleteLater on the freed
widget, duplicate databaseClosed emission). The bug is long-standing
and was surfaced by scheduler timing under an ASan build with Qt 6.11.

Detected by ASan as a heap-use-after-free in DatabaseWidget::save()
during TestGuiFdoSecrets::testCollectionDeleteConcurrent.

- DatabaseTabWidget::closeDatabaseTab: track widgets with a close in
  progress; a re-entrant call returns false without side effects, and
  only the outermost invocation may remove the tab, schedule deletion,
  and emit databaseClosed (exactly once now). Re-resolve the tab index
  after close() since nested event loops may shift tabs.
- FdoSecrets: PromptBase::prompt now schedules promptSync only once;
  repeated Prompt() calls wait for the same Completed signal instead
  of running the prompt action multiple times.
- FdoSecrets: DeleteCollectionPrompt clears its collection reference on
  Collection::collectionAboutToDelete. The QPointer alone still sees a
  retired collection (removed from D-Bus, m_backend already reset)
  until its deleteLater is delivered, which would violate the
  invariant behind Q_ASSERT(m_backend) in Collection::doDelete. With
  the explicit clearing the invariant "non-null m_collection implies
  usable backend" holds again and the assert stays.
- TestGuiFdoSecrets::testCollectionDeleteConcurrent: fix the second
  prompt proxy accidentally created from the first prompt's path (the
  test was exercising a double-Prompt on one object rather than two
  concurrent prompts), and assert the fail-fast behavior: the losing
  concurrent prompt completes as dismissed while the winner performs
  the deletion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Aetf
Aetf force-pushed the fix/close-database-tab-reentrancy branch from 3408415 to be68a8b Compare August 18, 2026 01:59
@Aetf
Aetf marked this pull request as ready for review August 18, 2026 03:14
@Aetf Aetf added pr: bugfix Pull request fixes a bug pr: ai-assisted Pull request contains significant contributions by generative AI feature: Secret Service labels Aug 18, 2026
@varjolintu varjolintu added this to the v2.8.0 milestone Aug 18, 2026
@varjolintu
varjolintu requested a review from a team August 18, 2026 05:09
A Collection retires (removed from D-Bus, backend reset, deleteLater
scheduled) synchronously when its database closes. Prompts, however,
reference collections via QPointer from timer callbacks outside D-Bus
dispatch, and a QPointer still sees the retired object until its
deferred deletion is delivered. This left two holes, both reachable
when a collection goes away while a prompt targeting it is in flight
(e.g. a concurrent Collection.Delete from another client):

- LockCollectionsPrompt/UnlockPrompt could call doLock()/doUnlock() on
  a retired collection, hitting Q_ASSERT(m_backend) in debug builds.
- UnlockPrompt would hang forever: the retired collection never emits
  doneUnlockCollection (its service connections are severed on
  retirement), and the count-based completion condition could no
  longer be satisfied, so the Completed signal never fired and the
  client waited indefinitely. The same accounting also hung when an
  entry was already destroyed by the time promptSync ran.

Following the same principle as DeleteCollectionPrompt: restore the
invariant that a non-null collection reference is usable, instead of
relaxing the asserts.

- LockCollectionsPrompt clears retired collection references on
  collectionAboutToDelete; a retired collection is skipped exactly
  like a destroyed one.
- UnlockPrompt tracks awaited collections in an explicit pending set,
  pre-registered before issuing any doUnlock so that synchronous
  completions (already-unlocked databases) cannot prematurely trigger
  the item unlock step. Retirement is accounted as a rejection and
  re-evaluates completion, so the prompt always terminates.
- Add regression test testServiceUnlockConcurrentDelete covering the
  unlock/delete race; verified to fail (hang detected via signal spy
  timeout) without this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Service::m_unlockingDb tracks databases with an unlock dialog shown to
prevent duplicate dialogs. If the DatabaseWidget is destroyed while
its dialog is open (e.g. the database is deleted via a concurrent
D-Bus call), dialogFinished never fires for it and the entry stayed
forever, keyed by a dangling pointer. The pointer was only ever
compared, not dereferenced, but the stale entry permanently blocked
doUnlockAnyDatabaseInDialog (used by unlock-before-search), and a
future widget allocated at the same address would have been refused an
unlock dialog as well.

Track a cleanup connection on the widget's destroyed signal alongside
the existing databaseUnlocked oneshot, covering both the per-database
and the unlock-any-database flows. Extend the
testServiceUnlockConcurrentDelete regression test to reopen the
database after the concurrent delete and verify the unlock dialog
still appears; without the fix the test times out in SearchItems
waiting for a dialog that never shows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@droidmonkey
droidmonkey merged commit 30e1fa5 into keepassxreboot:develop Aug 24, 2026
12 checks passed
pull Bot pushed a commit to chizee/keepassxc that referenced this pull request Aug 24, 2026
…eboot#13598)

* Fix use-after-free when a database tab close is re-entered

Closing a database tab may spin a nested event loop while the widget
performs save-on-close (Database::save waits for its worker thread in
AsyncTask::waitForFuture). If DatabaseTabWidget::closeDatabaseTab is
re-entered for the same widget from within that nested loop (e.g. two
concurrent FdoSecrets prompts deleting the same collection),
QWidget::close() short-circuits on its internal is_closing flag and
reports success immediately, without delivering another close event.
This behavior is identical in Qt 5 (QWidgetPrivate::close_helper) and
Qt 6 (QWidgetPrivate::handleClose), so none of the downstream guards
(such as DatabaseWidget::lock's m_attemptingLock) ever run for the
re-entrant close. The re-entrant call then removed the tab and
scheduled deletion, and the DeferredDelete event executed inside the
still-running nested loop, destroying the widget while the outer
invocation was still using it; the outer call afterwards repeated
parts of the teardown (stale-index removeTab, deleteLater on the freed
widget, duplicate databaseClosed emission). The bug is long-standing
and was surfaced by scheduler timing under an ASan build with Qt 6.11.

Detected by ASan as a heap-use-after-free in DatabaseWidget::save()
during TestGuiFdoSecrets::testCollectionDeleteConcurrent.

- DatabaseTabWidget::closeDatabaseTab: track widgets with a close in
  progress; a re-entrant call returns false without side effects, and
  only the outermost invocation may remove the tab, schedule deletion,
  and emit databaseClosed (exactly once now). Re-resolve the tab index
  after close() since nested event loops may shift tabs.
- FdoSecrets: PromptBase::prompt now schedules promptSync only once;
  repeated Prompt() calls wait for the same Completed signal instead
  of running the prompt action multiple times.
- FdoSecrets: DeleteCollectionPrompt clears its collection reference on
  Collection::collectionAboutToDelete. The QPointer alone still sees a
  retired collection (removed from D-Bus, m_backend already reset)
  until its deleteLater is delivered, which would violate the
  invariant behind Q_ASSERT(m_backend) in Collection::doDelete. With
  the explicit clearing the invariant "non-null m_collection implies
  usable backend" holds again and the assert stays.
- TestGuiFdoSecrets::testCollectionDeleteConcurrent: fix the second
  prompt proxy accidentally created from the first prompt's path (the
  test was exercising a double-Prompt on one object rather than two
  concurrent prompts), and assert the fail-fast behavior: the losing
  concurrent prompt completes as dismissed while the winner performs
  the deletion.

* FdoSecrets: complete prompts whose collections retire mid-prompt

A Collection retires (removed from D-Bus, backend reset, deleteLater
scheduled) synchronously when its database closes. Prompts, however,
reference collections via QPointer from timer callbacks outside D-Bus
dispatch, and a QPointer still sees the retired object until its
deferred deletion is delivered. This left two holes, both reachable
when a collection goes away while a prompt targeting it is in flight
(e.g. a concurrent Collection.Delete from another client):

- LockCollectionsPrompt/UnlockPrompt could call doLock()/doUnlock() on
  a retired collection, hitting Q_ASSERT(m_backend) in debug builds.
- UnlockPrompt would hang forever: the retired collection never emits
  doneUnlockCollection (its service connections are severed on
  retirement), and the count-based completion condition could no
  longer be satisfied, so the Completed signal never fired and the
  client waited indefinitely. The same accounting also hung when an
  entry was already destroyed by the time promptSync ran.

Following the same principle as DeleteCollectionPrompt: restore the
invariant that a non-null collection reference is usable, instead of
relaxing the asserts.

- LockCollectionsPrompt clears retired collection references on
  collectionAboutToDelete; a retired collection is skipped exactly
  like a destroyed one.
- UnlockPrompt tracks awaited collections in an explicit pending set,
  pre-registered before issuing any doUnlock so that synchronous
  completions (already-unlocked databases) cannot prematurely trigger
  the item unlock step. Retirement is accounted as a rejection and
  re-evaluates completion, so the prompt always terminates.
- Add regression test testServiceUnlockConcurrentDelete covering the
  unlock/delete race; verified to fail (hang detected via signal spy
  timeout) without this fix.

* FdoSecrets: clean up unlock dialog bookkeeping when the widget dies

Service::m_unlockingDb tracks databases with an unlock dialog shown to
prevent duplicate dialogs. If the DatabaseWidget is destroyed while
its dialog is open (e.g. the database is deleted via a concurrent
D-Bus call), dialogFinished never fires for it and the entry stayed
forever, keyed by a dangling pointer. The pointer was only ever
compared, not dereferenced, but the stale entry permanently blocked
doUnlockAnyDatabaseInDialog (used by unlock-before-search), and a
future widget allocated at the same address would have been refused an
unlock dialog as well.

Track a cleanup connection on the widget's destroyed signal alongside
the existing databaseUnlocked oneshot, covering both the per-database
and the unlock-any-database flows. Extend the
testServiceUnlockConcurrentDelete regression test to reopen the
database after the concurrent delete and verify the unlock dialog
still appears; without the fix the test times out in SearchItems
waiting for a dialog that never shows.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature: Secret Service high priority 🚨 pr: ai-assisted Pull request contains significant contributions by generative AI pr: bugfix Pull request fixes a bug security

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fdo Secret Service GUI tests crash on Qt6

4 participants