Repository navigation
Fix use-after-free when a database tab close is re-entered - #13598
Merged
droidmonkey merged 3 commits intoAug 24, 2026
Merged
Conversation
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
force-pushed
the
fix/close-database-tab-reentrancy
branch
from
August 18, 2026 01:59
3408415 to
be68a8b
Compare
Aetf
marked this pull request as ready for review
August 18, 2026 03:14
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>
Aetf
force-pushed
the
fix/close-database-tab-reentrancy
branch
from
August 18, 2026 07:00
962cfca to
b74355a
Compare
droidmonkey
approved these changes
Aug 24, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Overview
Fixes a heap-use-after-free that ASan catches in
TestGuiFdoSecrets::testCollectionDeleteConcurrenton 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::savewaits for its worker thread inAsyncTask::waitForFuture). IfDatabaseTabWidget::closeDatabaseTabis 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 internalis_closingflag and reports success immediately without delivering another close event.This short-circuit is identical in Qt 5 and Qt 6 (
QWidgetPrivate::close_helperin Qt 5.15 andQWidgetPrivate::handleClosein Qt 6 both start withif (data.is_closing) return true;), so none of the downstream guards — includingDatabaseWidget::lock()'sm_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(), theDeferredDeleteexecutes 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-indexremoveTab,deleteLateron the freed widget, duplicatedatabaseClosedemission).Event trace of the failure (from an instrumented run; "first"/"second" are the two concurrent delete prompts):
promptSync→closeDatabaseTab→close()→ closeEvent →lock()→ save →performSave→ nested event loop inAsyncTask::waitForFuturecloseDatabaseTab(tab still present) →close()returnstrueimmediately (is_closing), no close event delivered →removeTab+deleteLater~DatabaseWidgetruns viaDeferredDeleteinside the still-running nested loopsave()resumes afterperformSave()→ write to freed memoryChanges
DatabaseTabWidget::closeDatabaseTab: track widgets with a close in progress; a re-entrant call returnsfalsewithout side effects, and only the outermost invocation may remove the tab, schedule deletion, and emitdatabaseClosed(exactly once now). Also re-resolve the tab index afterclose()since nested event loops may shift tabs.FdoSecrets
PromptBase::prompt: schedulepromptSynconly once; repeatedPrompt()calls wait for the sameCompletedsignal instead of running the prompt action multiple times.FdoSecrets
DeleteCollectionPrompt: clear the collection reference onCollection::collectionAboutToDelete. TheQPointeralone still sees a retired collection (already removed from D-Bus withm_backendreset) until itsdeleteLateris delivered, which would violate the invariant behindQ_ASSERT(m_backend)inCollection::doDelete. With the explicit clearing, "non-nullm_collectionimplies usable backend" holds again and the assert stays intact.FdoSecrets
LockCollectionsPrompt/UnlockPrompt(second commit): the same retirement window let these calldoLock()/doUnlock()on a retired collection (assert in debug builds), andUnlockPromptadditionally hung forever when a collection went away mid-prompt — the retired collection never emitsdoneUnlockCollection(service connections are severed on retirement) and the count-based completion condition could never be satisfied, soCompletednever fired.LockCollectionsPromptnow clears retired references (skipped like destroyed ones);UnlockPrompttracks awaited collections in an explicit pending set (pre-registered before issuing anydoUnlock, 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 newtestServiceUnlockConcurrentDeleteregression test (unlock prompt waiting on user + concurrentCollection.Delete), verified to fail via signal-spy timeout without the fix.FdoSecrets
Serviceunlock dialog bookkeeping (third commit): if theDatabaseWidgetdied while its unlock dialog was open, itsm_unlockingDbentry stayed forever, keyed by a dangling pointer (identity comparison only, no UB) — permanently blockingdoUnlockAnyDatabaseInDialog(unlock-before-search) and any future widget allocated at the same address. A cleanup connection on the widget'sdestroyedsignal 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 inSearchItemswaiting 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 environmenttestgui: 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