Skip to content

Fix use-after-free in copy-on-select mouse release path#36

Open
wzoom wants to merge 1 commit intomanaflow-ai:mainfrom
wzoom:fix-copy-on-select-uaf
Open

Fix use-after-free in copy-on-select mouse release path#36
wzoom wants to merge 1 commit intomanaflow-ai:mainfrom
wzoom:fix-copy-on-select-uaf

Conversation

@wzoom
Copy link
Copy Markdown

@wzoom wzoom commented Apr 8, 2026

Summary

  • Fix a use-after-free bug in mouseButtonCallback that silently skips copySelectionToClipboards on mouse release after a drag selection
  • Bypass the setSelection() eql optimization by reading the existing tracked selection and copying to clipboard directly

Problem

When the left mouse button is released after a drag selection, the mouse release handler calls setSelection() with a new untracked Selection built from the existing tracked selection's start()/end().

Inside setSelection():

  1. Screen.select() frees the old tracked pins via old.deinit(self)
  2. The optimization if (prev_) |prev| if (sel.eql(prev)) return; then dereferences the freed prev pointer (use-after-free)
  3. Since freed memory pool slots usually retain their old values, eql() returns true
  4. copySelectionToClipboards is never called

The bug is intermittent: when the pool happens to reuse the freed slots before the eql check, the comparison fails and the copy succeeds.

Fix

The mouse release path doesn't need to change the selection — it only needs to copy it to the clipboard. Read the existing tracked selection directly and call copySelectionToClipboards, bypassing setSelection() entirely.

Fixes manaflow-ai/cmux#2664


Summary by cubic

Fixes a use-after-free in selection handling and ensures copy-on-select always updates the clipboard on mouse release.

  • Bug Fixes
    • In setSelection(), evaluate eql before Screen.select() frees the old pins to avoid dereferencing a freed prev.
    • On left-button release after a drag, copy the tracked selection via copySelectionToClipboards, honoring copy_on_select (.clipboard copies to both .standard and .selection; .true prefers .selection with fallback to .standard).

Written for commit 8eaee4d. Summary will update on new commits.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a race that could skip clipboard updates after selection changes, ensuring selection equality is checked safely before proceeding.
    • Improved mouse-release behavior so the active selection is consistently copied to the correct clipboards according to user settings and system support.

wzoom pushed a commit to wzoom/cmux that referenced this pull request Apr 8, 2026
Override copy-on-select = clipboard so that selecting text in the
terminal copies to the system clipboard (NSPasteboard.general),
matching macOS user expectations. Ghostty's default of .true writes
to a private named pasteboard that is not accessible via Cmd+V.

Update the ghostty submodule to include the fix for the
use-after-free in the mouse release copy-on-select path
(manaflow-ai/ghostty#36).

Update docs/ghostty-fork.md with section 8 documenting the fork
change.

Fixes manaflow-ai#2664
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 1 file

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75fab63a-4b5a-4c62-a638-b84d2cfea638

📥 Commits

Reviewing files that changed from the base of the PR and between 353d9b5 and 8eaee4d.

📒 Files selected for processing (1)
  • src/Surface.zig

📝 Walkthrough

Walkthrough

setSelection now captures previous-selection equality before calling Screen.select() to avoid reading freed state; left-button release handling no longer reconstructs and re-sets a selection but instead copies the currently tracked active.selection to appropriate clipboards while holding the renderer mutex.

Changes

Cohort / File(s) Summary
Copy-on-select / selection handling
src/Surface.zig
Compute selection equality (same) before calling Screen.select() to avoid post-select use-after-free; on left-mouse release, copy active.selection directly via copySelectionToClipboards (while holding renderer mutex) rather than reconstructing and calling setSelection(). Clipboard target logic now follows config.copy_on_select and supportsClipboard rules.

Sequence Diagram(s)

sequenceDiagram
    participant Mouse
    participant Surface
    participant Screen
    participant Renderer
    participant Clipboard

    Mouse->>Surface: left-button release event
    Surface->>Screen: read prev selection, compute `same`
    Surface->>Screen: Screen.select(sel_)  --(calls)-->
    Note right of Screen: selection may reallocate/deinit old pins
    Surface->>Renderer: lock renderer mutex
    Renderer->>Surface: held
    Surface->>Clipboard: copySelectionToClipboards(active.selection, targets)
    Clipboard-->>Surface: copy complete
    Surface->>Renderer: unlock mutex
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I nibble at knots in the code so neat,
With tiny paws I make selections complete.
No letters lost, the clipboard sings,
Hopping fixes on gentle spring wings. 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: fixing a use-after-free bug in the copy-on-select mouse release path, which is the primary objective of the PR.
Linked Issues check ✅ Passed The PR fixes the use-after-free bug causing intermittent copy-on-select failures on macOS by redesigning mouseButtonCallback to read tracked selection directly and call copySelectionToClipboards, directly addressing the reported issue of only the first character being copied.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to fixing the use-after-free bug: the setSelection function captures previous selection equality before potential deallocation, and mouseButtonCallback directly copies tracked selection to clipboard with proper fallback modes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 8, 2026

Greptile Summary

This PR fixes a bug where copy-on-select silently failed after a left-click drag: the mouse release path called setSelection() with a selection equal to the existing one, triggering a use-after-free inside setSelection() that caused eql() to return true and skip copySelectionToClipboards. The fix bypasses setSelection() entirely on release, reading the tracked selection directly and calling copySelectionToClipboards.

  • P1: setSelection() itself still has the UAF at line 2377 — eql(prev) dereferences freed pins after Screen.select() calls old.deinit(). This now only affects other callers (e.g., selectCursorCell, right-click word select), but is genuine undefined behaviour in safety-checked builds.

Confidence Score: 4/5

Safe to merge as a targeted fix, but the root use-after-free in setSelection() is still live and should be addressed before or shortly after.

The mouse release fix is correct and well-reasoned. One P1 remains: the UAF in setSelection() at line 2377 is not patched, leaving a genuine undefined-behaviour defect in selectCursorCell() and right-click word-select paths that could manifest in safety-checked builds.

src/Surface.zig — specifically setSelection() lines 2365–2377, which still dereferences freed pins after Screen.select().

Vulnerabilities

No security concerns identified. The use-after-free is a memory-safety issue that causes incorrect clipboard behaviour rather than an exploitable vulnerability, since all access is within the same process and protected by the renderer mutex.

Important Files Changed

Filename Overview
src/Surface.zig Mouse release path correctly bypasses setSelection() to fix the copy-on-select UAF, but the root use-after-free in setSelection() (line 2377) remains and still affects other callers.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant MBC as mouseButtonCallback
    participant SS as setSelection()
    participant Scr as Screen.select()
    participant CTC as copySelectionToClipboards()

    Note over U,CTC: BEFORE FIX (broken path)
    U->>MBC: left mouse release (drag selection)
    MBC->>SS: setSelection(Selection.init(start, end))
    SS->>Scr: Screen.select(new_sel)
    Scr-->>Scr: old.deinit() — frees tracked pins
    SS->>SS: eql(prev) — USE-AFTER-FREE on freed *Pin
    SS-->>MBC: returns early (eql=true), clipboard never copied

    Note over U,CTC: AFTER FIX (correct path)
    U->>MBC: left mouse release (drag selection)
    MBC->>MBC: renderer_state.mutex.lock()
    MBC->>MBC: read screens.active.selection (existing tracked sel)
    MBC->>CTC: copySelectionToClipboards(sel, clipboards, .mixed)
    CTC-->>MBC: clipboard updated
    MBC->>MBC: mutex.unlock() (deferred)
Loading

Comments Outside Diff (1)

  1. src/Surface.zig, line 2365-2377 (link)

    P1 Use-after-free in setSelection() is not fixed

    setSelection() itself still has the UAF. At line 2367, Screen.select() calls old.deinit(self) which frees the tracked pins (s.pages.untrackPin(v.start), untrackPin(v.end)). Line 2377 then calls sel.eql(prev), which dispatches to prev.start()v.start.* (dereferencing the freed pointer).

    This PR correctly bypasses setSelection() for the mouse release path, but setSelection() is still called with a potentially-equal non-null selection from selectCursorCell() (line 2066) and from right-click context menu word selection (lines 4116, 4122). In a Zig safety-checked build, untrackPin may poison the freed slot, causing the eql check to misbehave or panic.

    A minimal fix is to evaluate eql before freeing old pins:

    fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void {
        // Capture equality BEFORE Screen.select() frees the old pins.
        const prev_ = self.io.terminal.screens.active.selection;
        const same = if (prev_) |prev| if (sel_) |sel| sel.eql(prev) else false else false;
        try self.io.terminal.screens.active.select(sel_);
    
        if (self.config.copy_on_select == .false) return;
        if (sel_ == null) return;
        if (same) return;
    
        switch (self.config.copy_on_select) { ... }
    }

Reviews (1): Last reviewed commit: "Fix use-after-free in copy-on-select mou..." | Re-trigger Greptile

Comment thread src/Surface.zig
Comment on lines 3848 to 3872
if (self.config.copy_on_select != .false) {
self.renderer_state.mutex.lock();
defer self.renderer_state.mutex.unlock();
const prev_ = self.io.terminal.screens.active.selection;
if (prev_) |prev| {
try self.setSelection(terminal.Selection.init(
prev.start(),
prev.end(),
prev.rectangle,
));
if (self.io.terminal.screens.active.selection) |sel| {
switch (self.config.copy_on_select) {
.false => unreachable,
.clipboard => try self.copySelectionToClipboards(
sel,
&.{ .standard, .selection },
.mixed,
),
.true => {
const clipboard: apprt.Clipboard = if (self.rt_surface.supportsClipboard(.selection))
.selection
else
.standard;
try self.copySelectionToClipboards(
sel,
&.{clipboard},
.mixed,
);
},
}
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Clipboard dispatch logic duplicated from setSelection()

The switch (self.config.copy_on_select) block here (lines 3852–3870) is identical to the one in setSelection() (lines 2379–2401). Extracting this into a private helper (e.g., copySelectionToConfiguredClipboards) would eliminate the duplication and make future copy_on_select variant changes a single-site edit.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid observation. The two switch blocks are identical. We intentionally kept them duplicated to minimize the fork diff against upstream Ghostty. Extracting a helper would be a clean improvement, but it increases the surface area for merge conflicts when syncing with upstream. If this fix lands upstream, the refactor would be better done there as a follow-up.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two switch blocks are identical, yeah. We kept them duplicated on purpose to minimize the fork diff against upstream Ghostty. Extracting a helper would be cleaner but increases the surface area for merge conflicts when syncing with upstream. If this fix lands upstream, the refactor would be better done there.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/Surface.zig`:
- Around line 3840-3870: The setSelection() path still snapshots prev_ then
calls Screen.select(sel_) which frees old tracked pins (old.deinit) and later
dereferences prev, causing a use-after-free; move the eql()/clipboard decision
so it runs before calling Screen.select or extract a safe helper that computes
selection equality and determines which clipboards to copy to (reusing
copySelectionToClipboards and the same clipboard-choice logic used in the
mouse-release path), then replace the existing logic in setSelection(), the
mouse-release flow, and other callers (e.g., the blocks around lines referencing
sel_ and prev_) to call that new helper prior to calling Screen.select() to
ensure no access to freed tracked pins after deinit.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bfed9c9e-a9f7-4bc4-8d96-789397632fe4

📥 Commits

Reviewing files that changed from the base of the PR and between 3b684a0 and 75ec7bf.

📒 Files selected for processing (1)
  • src/Surface.zig

Comment thread src/Surface.zig Outdated
@wzoom wzoom force-pushed the fix-copy-on-select-uaf branch from 75ec7bf to 353d9b5 Compare April 8, 2026 11:42
setSelection() captured the old tracked selection pointer, then called
Screen.select() which frees the old tracked pins via old.deinit().
The subsequent eql() check dereferenced the freed pointer.  Since the
freed memory usually retains its old values, eql() returned true and
copySelectionToClipboards was silently skipped — producing the
one-character-paste symptom reported in cmux ghostty-org#2664.

Fix: evaluate eql() BEFORE Screen.select() frees the old pins.

Additionally, copy the final selection to the clipboard directly on
mouse-up (left-button release) to guarantee the clipboard is always
up-to-date at the end of a drag selection.
@wzoom wzoom force-pushed the fix-copy-on-select-uaf branch from 353d9b5 to 8eaee4d Compare April 8, 2026 12:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Opencdoe Copy on select not work on mac

1 participant