https://bugs.kde.org/show_bug.cgi?id=523471

            Bug ID: 523471
           Summary: Use-after-free crash after a few decryptions in the
                    Notepad: clearAddedRecipients() deletes
                    mUnknownWidgets entries without clearing the list
    Classification: Applications
           Product: kleopatra
      Version First git master
       Reported In:
          Platform: Microsoft Windows
                OS: Microsoft Windows
            Status: REPORTED
          Severity: crash
          Priority: NOR
         Component: general
          Assignee: [email protected]
          Reporter: [email protected]
                CC: [email protected], [email protected],
                    [email protected]
  Target Milestone: ---

SUMMARY
Kleopatra crashes with a use-after-free after a few decryptions in the Notepad.
SignEncryptWidget::clearAddedRecipients() deletes every widget in
d->mUnknownWidgets but never clears the list, leaving it full of dangling
pointers. PadWidget::doDecryptVerify() calls it on every decrypt, so the freed
pointers are deleted a second time on a later decrypt.

The bug is still present in current master; the two functions involved are
byte-identical between 5.0.2 and master.


STEPS TO REPRODUCE
The most reliable trigger is a message encrypted to a hidden recipient
(--hidden-recipient / --throw-keyids). Such a message carries key ID
0000000000000000, which can never be found in any keyring, so every decrypt of
it registers an unknown recipient.

  gpg --throw-keyids -r <your-key> -e -a message.txt

1. Kleopatra -> Notepad -> paste the ciphertext -> Decrypt/Verify.
   It decrypts correctly ("anonymous recipient", keyid 0000000000000000).
2. Repeat step 1 in the SAME Kleopatra session.
3. Kleopatra crashes on roughly the third decrypt.

A message encrypted to any ordinary key you do not hold also works, but the
hidden-recipient case is deterministic and needs no second keypair.

This matters in practice: any automated sender using --throw-keyids for
recipient privacy (contact forms, mailers) produces a stream of messages that
each advance the counter, so Kleopatra dies every few messages.


OBSERVED RESULT
Access violation, identical Windows Error Reporting bucket every time:

  Faulting application name: kleopatra.exe, version: 5.0.2.0
  Faulting module name:      kleopatra.exe, version: 5.0.2.0
  Exception code:            0xc0000005
  Fault offset:              0x0000000000163538

Four consecutive crashes were captured at that same offset.


EXPECTED RESULT
No crash.


SOFTWARE/OS VERSIONS
Kleopatra:  5.0.2 (as shipped in Gpg4win 5.0.2, released 2026-03-16)
Source rev: 782c464a9801d6189e3d3be494922f42857bd26c
            (kleopatra snapshot 2026-03-02, per Gpg4win versioninfo.txt)
Qt / KF:    6.10.1 / 6.20.0
GnuPG:      2.5.18, gpgme 2.0.1
OS:         Windows 11 (x86_64)
Reproduced on master @ f80768bc98026b73d0d225d6fde5363c0d6a440c (2026-07-22)


ROOT CAUSE
src/crypto/gui/signencryptwidget.cpp (line numbers from the 5.0.2 revision;
master is identical at 597-602):

    void SignEncryptWidget::clearAddedRecipients()
    {
        for (auto w : std::as_const(d->mUnknownWidgets)) {
            d->mRecpLayout->removeWidget(w);
            delete w;
        }
        // <-- d->mUnknownWidgets still contains every freed pointer

        for (auto &key : std::as_const(d->mAddedKeys)) {
            removeRecipient(key);
        }
        ...
    }

The list is never emptied anywhere else. Every reference in the file:

    143:  QList<UnknownRecipientWidget *> mUnknownWidgets;   // declaration
    615:  for (auto w : std::as_const(d->mUnknownWidgets))   // deletes, no
clear
    632:  d->mUnknownWidgets << unknownWidget;               // append
    642:  for (auto w : std::as_const(d->mUnknownWidgets))   //
keysMayHaveChanged handler
    657:  d->mUnknownWidgets.removeAll(w);                   // only removal

WHY AN ORDINARY DECRYPT REACHES IT
 - PadWidget::doDecryptVerify() (src/view/padwidget.cpp:396) calls
   clearAddedRecipients() at line 399 -- on every decrypt.
 - PadWidget::updateRecipientsFromResult() (line 273) calls
   addUnknownRecipient(recipient.keyID()) at line 295 for every recipient of
the
   decrypted message whose key is not in the keyring.

  decrypt #1 -> clearAddedRecipients(): list empty
                addUnknownRecipient()  -> [w1]
  decrypt #2 -> clearAddedRecipients(): delete w1, list is STILL [w1]
(dangling)
                addUnknownRecipient()  -> [w1(freed), w2]
  decrypt #3 -> clearAddedRecipients(): "delete w1" a second time -> UAF

Because a freed QWidget may still look plausible to the allocator, the second
delete can also succeed silently and corrupt the heap, so the visible crash may
come a decrypt or two later.


SCOPE
Notepad-only. The unknown-recipient list is touched from nowhere else:

    addUnknownRecipient()        <- only src/view/padwidget.cpp:295
    clearAddedRecipients()       <- only src/view/padwidget.cpp:399
    updateRecipientsFromResult() <- private to PadWidget (padwidget.cpp:273)

SignEncryptWidget is also instantiated by signencryptfilesdialog.cpp:131 and
signencryptclipboarddialog.cpp:55, but neither calls those methods, so
decrypting via the file or clipboard dialogs does not hit this bug.


EVIDENCE FOR THE CRASH SITE
The shipped binary is stripped, so the fault offset was resolved by hand.
Disassembly at the faulting instruction (ImageBase 0x140000000, fault
0x140163538):

    mov  0x28(%rcx),%rax     ; rax = d (unique_ptr<Private>)
    mov  0x48(%rax),%rbx     ; begin \
    mov  0x50(%rax),%rdx     ; size  /  QList<UnknownRecipientWidget*> @ 0x40
    lea  (%rbx,%rdx,8),%rbp  ; end -- 8-byte elements
  loop:
    mov  (%rbx),%rdi         ; w = *it
    mov  0x88(%rax),%rcx     ; QVBoxLayout* @ 0x88
    mov  %rdi,%rdx
    call *%r12               ; -> QLayout::removeWidget(QWidget*) [IAT,
Qt6Widgets.dll]
    test %rdi,%rdi
    je   ...                 ; the NULL check that `delete` emits
    mov  (%rdi),%rax         ; load vtable pointer (succeeds)
    add  $0x8,%rbx
    mov  %rdi,%rcx
    call *0x20(%rax)         ; <<< FAULT: virtual deleting destructor = `delete
w`
    mov  0x28(%rsi),%rax
    cmp  %rbx,%rbp
    jne  loop
    ; then ptr @ 0x60, size @ 0x68, shl $0x4 -> QList<GpgME::Key> @ 0x58

 - call *0x20(%rax) is the deleting destructor, not qt_metacall: the Q_OBJECT
   macro sits at the top of the class body, so it declares metaObject /
   qt_metacast / qt_metacall at vtable slots 0x00/0x08/0x10 BEFORE
   virtual ~QObject(), putting the complete/deleting destructors at 0x18/0x20.
   UnknownRecipientWidget is "class UnknownRecipientWidget : public QWidget"
   with Q_OBJECT (unknownrecipientwidget.h:14-16).
 - The pointer is explicitly NULL-checked and the vtable load itself succeeds,
   so this is a non-NULL dangling pointer, not a NULL dereference.
 - Every Private member offset matches the declaration order at
   signencryptwidget.cpp:138-146:
       0x28  std::vector<RecipientWidgets> mRecpWidgets      (24 bytes)
       0x40  QList<UnknownRecipientWidget *> mUnknownWidgets (ptr 0x48, size
0x50)
       0x58  QList<GpgME::Key> mAddedKeys   (ptr 0x60, size 0x68, 16-byte
elems)
       0x70  QList<KeyGroup> mAddedGroups                    (24 bytes)
       0x88  QVBoxLayout *mRecpLayout       (passed as QLayout* to
removeWidget)


RULING OUT THE ENVIRONMENT
The reporting machine also had an unrelated GnuPG fault ("gpg: sending fd ...
to
keyboxd: Input/output error", every key operation exiting 2). That was cleared
before the most recent crash, and the crash still occurred with a healthy
keyboxd and gpg exiting 0 -- so this defect does not depend on it.


POSSIBLY RELATED
dev.gnupg.org T7476 -- "Kleopatra crashes when decrypting file that was only
encrypted for a hidden recipient" (closed fixed for gpg4win5, Jan 2025). Same
trigger scenario, different defect; the use-after-free described here is still
present in 5.0.2 and in master.


PROPOSED PATCH
Attached: kleopatra-signencryptwidget-uaf.patch -- verified to apply cleanly
(git apply --check, exit 0) to both 782c464a98 (the 5.0.2 revision) and
origin/master @ f80768bc98.

The primary fix is one line, "d->mUnknownWidgets.clear();". The patch also
hardens two related defects in the same area:

 1. The keysMayHaveChanged handler iterates d->mUnknownWidgets while its body
    calls d->mUnknownWidgets.removeAll(w) on that same container, deletes w,
and
    calls addRecipient() which may modify it again. Mutating a container while
    range-iterating it invalidates the cached begin/end. Fixed by iterating a
    copy.
 2. connect() ran on every addUnknownRecipient() call, so N unknown recipients
    installed N identical handlers and one keysMayHaveChanged emission executed
    the loop N times. The handler already re-checks every widget, so a single
    connection suffices.

CAVEAT: the patch has NOT been compiled or run -- no Qt 6 / KF 6 build
environment was available. It is a source-level fix derived from reading the
code; please review accordingly.

-- 
You are receiving this mail because:
You are watching all bug changes.

Reply via email to