Skip to content

macOS: Quit during FirstSetupDialog attempts to delete a stack-allocated wizard and aborts #8549

Description

@glslang

Version and Platform

  • Binary Ninja: 6.0.10601-Stable Personal.
  • OS: macOS 26.6.2 (25G83), Apple Silicon (arm64; Mac16,6).
  • Latest development build: not tested; no installation/channel changes were made during this investigation.
  • Executable SHA-256: 3f2da87b412d3a273da2866f8dfb6f6d3d90a5e35aff9f3ffd0905f201b09b10.

Bug Description

Invoking the application Quit action through UIActionHandler.executeAction("Quit") while the first-run wizard is open causes an invalid free and SIGABRT. The wizard is a stack-allocated FirstSetupDialog parented to MainWindow. Destruction of the main window reaches the wizard's deleting destructor through QObjectPrivate::deleteChildren() and attempts to pass that stack pointer to operator delete.

This reproduces in an empty, isolated GUI with no input binary, no BinDiff, and no companion plugin loaded. It was discovered during companion-plugin acceptance testing, then reduced to the following minimal probe. Ordinary keyboard/menu interaction has not been tested.

Steps To Reproduce

  1. Create a fresh disposable BN_USER_DIRECTORY, with a unique BN_QSETTINGS_POSTFIX. Configure the existing license for that disposable launch. Leave ui.allowWelcome at its default true.
  2. Save the following as plugins/quit_probe/__init__.py inside that disposable user directory:
import binaryninja as bn
from binaryninjaui import UIContext
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication


def reproduce():
    modal = QApplication.instance().activeModalWidget()
    assert modal is not None
    assert modal.metaObject().className() == "FirstSetupDialog"
    context, = UIContext.allContexts()
    context.getCurrentActionHandler().executeAction("Quit")


bn.execute_on_main_thread(lambda: QTimer.singleShot(10000, reproduce))
  1. Launch Binary Ninja with --new-instance --stderr-log and those environment variables. Leave the first-run wizard open; do not open any input file.
  2. After the timer fires, observe the native invalid-free abort. Run this only in the disposable instance: the probe deliberately requests application exit.

Expected Behavior

Quit should either exit safely or be refused/deferred while onboarding is active. It must not attempt to heap-delete a stack object.

Native evidence

In a separate LLDB run, the wizard pointer obtained with shiboken6.getCppPointer(modal)[0] was 0x16b4d9db0. Its parent class was MainWindow, and the address was within the main-thread stack bounds [0x16ace0000, 0x16b4dc000) obtained with pthread_get_stackaddr_np / pthread_get_stacksize_np.

LLDB stopped at executable image offset 0x146c0, immediately before the deleting destructor's tail branch to operator delete(void*):

x0 = 0x000000016b4d9db0  # exactly the observed FirstSetupDialog stack address
sp = 0x000000016b4d7f40

binaryninja +0x146c0: b operator delete(void*)
  QObjectPrivate::deleteChildren()
  QWidget::~QWidget()
  binaryninja +0xfaed8
  binaryninja +0xfb0a4
  QObject::event(QEvent*)
  QWidget::event(QEvent*)
  QMainWindow::event(QEvent*)

The debugger terminated the owned process before executing the invalid free. Earlier untraced reproductions reached SIGABRT, through the allocator's ___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED report. The empty-profile reproduction and original companion run had the same first 37 crash frames.

Static disassembly independently shows construction, modal execution, and normal destruction of the same stack object:

+0xff488: add x0, sp, #0x1e0
+0xff48c: ldr x1, [sp, #0x30]
+0xff490: bl  wizard constructor
+0xff494: add x0, sp, #0x1e0
+0xff498: bl  QDialog::exec()
+0xff49c: add x0, sp, #0x1e0
+0xff4a0: bl  wizard destructor

The deleting destructor at +0x146b0 calls the normal destructor and then branches to operator delete at +0x146c0. All offsets above are relative to the tested executable's image base; the runtime pointer is specific to that probe.

Controls / workaround

  • With {"ui.allowWelcome": false} in the disposable profile's settings.json before launch, the same empty-GUI application Quit path emits aboutToQuit and exits with code 0.
  • Companion-enabled shutdown during active native export and external BinDiff matching also cleans up its resources and exits with code 0 when the wizard is disabled.
  • Direct QCoreApplication.instance().quit() is a distinct control: while the startup wizard was active, the process and wizard remained alive three seconds later and a timer still fired. Dismissing the wizard and then invoking application Quit exited cleanly. With the wizard disabled, direct Qt quit also exited cleanly.
  • Our capture helper now refuses startup/quit through an active modal dialog. This avoids the trigger without modifying Binary Ninja.

Binary / screenshots

No analysis input is required. No screenshots are needed to reproduce; native observations are included above. Full crash reports and license data are not attached.

The complete investigation document follows. Its local artifact/code references are shown as filenames rather than links to unpublished files; the key native evidence is included directly in this issue.

Investigation document — 2026-09-10–11

Binary Ninja shutdown investigation — 2026-09-10–11

The shutdown aborts were triggered by the disposable-profile test harness invoking
the application Quit action while BN's FirstSetupDialog was still open.
The same abort occurs without loading the companion or opening a binary. Disabling
the first-run wizard in the disposable profile removes this trigger: baseline,
active native export, and active external BinDiff matching all exited with code zero.

This relates the failures to how the acceptance work launched and closed BN.
It does not implicate the similarity implementation or metadata invalidation fix.
The 2026-09-11 native follow-up confirmed the ownership defect: destruction of the
main window tries to heap-delete its stack-allocated wizard child. These tests do
not rule out other shutdown failures.

Controlled results

Each case used BN 6.0.10601 Personal on Apple Silicon macOS, a fresh user directory
and QSettings suffix, and one owned GUI process at a time. Baseline cases loaded
only a small observation/quit probe; their Python module inventories contained no
companion modules. No application bundle or normal user profile was changed.

Case State at Quit Result
Empty baseline, default fresh profile FirstSetupDialog active; no input files or companion SIGABRT, exit −6; same first 37 crash frames as the earlier companion run
Empty baseline, ui.allowWelcome: false No modal dialog; no companion aboutToQuit observed; exit 0
Companion 2eb95c5, native export active No modal dialog; exporter thread running Job stopped for shutdown; export thread ended; temporary directory removed; listener stopped; exit 0
Companion 2eb95c5, external matching active No modal dialog; live BinDiff process and output reader BinDiff terminated and reaped; reader/export thread ended; temporary directory removed; listener stopped; exit 0
Updated capture helper Temporary modal dialog, then no modal dialog Quit refused while modal; application Quit succeeded after dismissal; exit 0

The four successful cases required no forced termination. The active cases reused
the verified ARM64 securekernel input pair from the earlier acceptance record.
No input binary was executed. A final process check found no running BN or BinDiff
processes.

The matching native stacks pass through the allocator's invalid-free report,
QObjectPrivate::deleteChildren(), QWidget::~QWidget(), BN image offsets
0xfaed8 and 0xfb0a4, and a nested QDialog::exec() loop. The baseline's modal
widget inspection identifies that dialog as FirstSetupDialog. The experiment
established the trigger. The following native breakpoint identifies the exact
object that the deletion path tries to free.

Native ownership proof — 2026-09-11

The wizard's C++ address, read through shiboken6.getCppPointer, lies within the
main thread's stack bounds reported by pthread_get_stackaddr_np and
pthread_get_stacksize_np. Its Qt parent is MainWindow. In the debugger probe:

  • Wizard address: 0x16b4d9db0.
  • Main-thread stack: [0x16ace0000, 0x16b4dc000).
  • Immediately before operator delete, ARM64 argument register x0:
    0x16b4d9db0, exactly the wizard address.

LLDB stopped at BN image offset 0x146c0, the tail of the wizard's deleting
destructor. Its next instruction branches to operator delete(void*). The caller
is QObjectPrivate::deleteChildren(), reached through main-window destruction and
the same BN image offsets 0xfaed8 and 0xfb0a4 seen in the original crash.
The debugger terminated only this owned probe before executing the invalid free.

Static disassembly independently shows the wizard constructor receiving
sp + 0x1e0 at image offset 0xff488. The same address is passed to
QDialog::exec() and then the normal destructor when the modal loop returns.
Main-window destruction during that loop instead takes the deleting-destructor
path, which is inappropriate for this stack object. Qt documents that parent
destruction deletes children and that stack-allocated objects require compatible
construction/destruction order.

This is a confirmed native lifetime error in the tested BN build, triggered by the
automated application-action path while onboarding is active. It does not require
the companion, a loaded input, or BinDiff. An upstream correction would need to
keep the parent alive until the modal loop and stack destructor have finished, or
change the dialog's allocation/ownership consistently. No installed BN executable
was patched, and ordinary keyboard/menu interaction was not tested.

The LLDB probe is not clean-quit acceptance. Its launcher reported zero after
the debugger killed/reaped the process; LLDB explicitly recorded termination with
status 9. For a traced process, use the debugger's exit observation. The earlier
successful baseline/export/matching cases ran without a debugger attached.

Lingering-process controls — 2026-09-11

Two additional empty-profile controls used no companion and called
QCoreApplication.instance().quit():

Startup state Observation
First-run wizard active BN and FirstSetupDialog remained alive three seconds after the call; a timer still fired. Dismissing the wizard and then invoking application Quit exited with code zero
Wizard disabled in the disposable profile The same direct Qt quit emitted aboutToQuit and exited with code zero

This reproduces the earlier lingering-instance symptom in the startup modal state,
without any comparison worker or listener. It is distinct from the invalid-free
path: the direct Qt call was ineffective there, while the application Quit action
destroyed the main window too early. The capture helper's modal guards and the
one-instance-at-a-time launcher procedure address both observed cases.

Native follow-up evidence (samples/bn-shutdown-native-20260911.json) records the
pointer equality, stack bounds, debugger frames and termination, executable hash,
disassembly offsets, and both Qt-quit controls. All three owned probes exited;
the debugger probe was deliberately killed at the breakpoint.

Reproduction and correction

To reproduce only in a disposable BN process, use a fresh BN_USER_DIRECTORY and
BN_QSETTINGS_POSTFIX, launch with --new-instance, and leave the first-run wizard
open. A minimal Python plugin can reproduce the observed application-action path:

import binaryninja as bn
from binaryninjaui import UIContext
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication

def reproduce():
    modal = QApplication.instance().activeModalWidget()
    assert modal is not None
    assert modal.metaObject().className() == "FirstSetupDialog"
    context, = UIContext.allContexts()
    context.getCurrentActionHandler().executeAction("Quit")

bn.execute_on_main_thread(lambda: QTimer.singleShot(10000, reproduce))

This deliberately reproduces the crash and is not a capture launcher. The control
uses the same launch setup with {"ui.allowWelcome": false} in the disposable
profile's settings.json before launch, then requests Quit with no active modal.
The setting is BN's built-in Allow First Run Wizard option. The isolation
variables are documented in BN troubleshooting.

The shipped capture helper (../skills/msrc-patch-diff/scripts/gui_capture.py) now
checks for modal dialogs before starting or opening files and again before quit.
It requests the application's Quit action and records a refusal in cleanup_errors.
The capture instructions (../skills/msrc-patch-diff/references/acquisition.md#gui-capture-fallback)
document wizard suppression for automated disposable profiles and require recording
the actual exit code. A successful in-process capture or quit request alone is not
clean-shutdown evidence. No companion shutdown code needed changing.

The helper's 17 offline tests and Ruff pass. A real GUI check confirmed that its
modal guard refuses quit, then exits normally once the modal is dismissed.

Windows Rust validation

cargo test --locked -- --nocapture passed on the Windows ARM64 debugger VM in a
separate detached worktree of PR #300 revision
8eafc8fb8b57ae5824d4b7a6c53e7687d0ac9a52, using Rust 1.96.1 for
aarch64-pc-windows-msvc. The existing checkout and running debugger service were
left untouched.

  • Unit-test harness: 724 passed, zero failures.
  • MCP smoke harness: 105 passed, zero failures, 12 ignored.
  • Opt-in debugger, kernel and TTD tiers were disabled. Gated smoke tests may report
    ok after printing SKIPPED; the count does not imply those tiers ran.

These results replace the earlier macOS-only compilation limitation for that PR's
Rust revision. This investigation changes Python helpers and documentation, not
Rust source.

Evidence and remaining acceptance

Sanitized isolation evidence (samples/bn-shutdown-isolation-20260910.json) contains
the five outcomes, pre/post shutdown resource observations, source hashes, matching
crash frames, and Windows test summaries. The
original lifecycle evidence (samples/similarity-lifecycle-20260910.json) remains
unchanged, including failed and forcibly terminated probes.

Normal quit during active export and matching is now established with the
corrected harness. Listener restart, complete rebase/view-close acceptance, and
guarded WinDbg handoff remain pending. Ultimate remains tentative.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions