From b05861de78679f69493673b3152b7014eecaa2b2 Mon Sep 17 00:00:00 2001 From: Damocles Date: Mon, 20 Apr 2026 20:39:38 +0200 Subject: [PATCH 1/3] add minimal reproducer for qtbase wayland screen use-after-free --- test/screen-uaf-reproducer/CMakeLists.txt | 9 ++++ test/screen-uaf-reproducer/main.cpp | 63 +++++++++++++++++++++++ test/screen-uaf-reproducer/shell.nix | 4 ++ test/screen-uaf-reproducer/trigger.sh | 44 ++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 test/screen-uaf-reproducer/CMakeLists.txt create mode 100644 test/screen-uaf-reproducer/main.cpp create mode 100644 test/screen-uaf-reproducer/shell.nix create mode 100755 test/screen-uaf-reproducer/trigger.sh diff --git a/test/screen-uaf-reproducer/CMakeLists.txt b/test/screen-uaf-reproducer/CMakeLists.txt new file mode 100644 index 0000000..122c8b4 --- /dev/null +++ b/test/screen-uaf-reproducer/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.19) +project(screen-uaf-reproducer LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Gui) +add_executable(screen-uaf-reproducer main.cpp) +target_link_libraries(screen-uaf-reproducer PRIVATE Qt6::Gui) diff --git a/test/screen-uaf-reproducer/main.cpp b/test/screen-uaf-reproducer/main.cpp new file mode 100644 index 0000000..7a108b2 --- /dev/null +++ b/test/screen-uaf-reproducer/main.cpp @@ -0,0 +1,63 @@ +// Minimal reproducer for Qt 6 Wayland screen use-after-free. +// +// Bug: libwayland resolves wl_output proxy pointers at demarshal time (when +// events are read from the socket). If wl_registry.global_remove and +// wl_surface.enter/leave for the same output arrive in the same dispatch +// batch, the surface event handler receives a dangling proxy pointer. +// QWaylandScreen::fromWlOutput() reads stale userdata from the freed proxy +// and returns a garbage QWaylandScreen*, which crashes on dereference. +// +// Build: +// cmake -B build && cmake --build build +// +// Run (on any wlroots-based compositor or Niri): +// ./build/screen-uaf-reproducer & +// # then toggle an output off/on rapidly, e.g.: +// # wlr-randr --output eDP-1 --off && wlr-randr --output eDP-1 --on +// # niri msg action power-off-monitors (then move mouse to wake) +// # or just unplug/replug an external monitor +// +// The crash typically occurs within seconds of the output toggle. +// Creating many surfaces increases the odds that a surface.leave event +// lands in the same batch as the global_remove. + +#include +#include +#include +#include +#include +#include + +int main(int argc, char *argv[]) +{ + QGuiApplication app(argc, argv); + + // Many surfaces = higher chance of batched enter/leave events + constexpr int kWindows = 20; + std::vector> windows; + windows.reserve(kWindows); + + for (int i = 0; i < kWindows; ++i) { + auto w = std::make_unique(); + w->setTitle(QStringLiteral("uaf-%1").arg(i)); + w->resize(1, 1); + w->setFlag(Qt::ToolTip); // small, no decoration, no focus + w->show(); + windows.push_back(std::move(w)); + } + + std::fprintf(stderr, + "screen-uaf-reproducer: %d surfaces created on %d screen(s).\n" + "Toggle an output off/on to trigger the crash.\n", + kWindows, QGuiApplication::screens().size()); + + // Log screen changes so we know when the toggle happens + QObject::connect(&app, &QGuiApplication::screenAdded, [](QScreen *s) { + std::fprintf(stderr, " screenAdded: %s\n", qPrintable(s->name())); + }); + QObject::connect(&app, &QGuiApplication::screenRemoved, [](QScreen *s) { + std::fprintf(stderr, " screenRemoved: %s\n", qPrintable(s->name())); + }); + + return app.exec(); +} diff --git a/test/screen-uaf-reproducer/shell.nix b/test/screen-uaf-reproducer/shell.nix new file mode 100644 index 0000000..aa50db7 --- /dev/null +++ b/test/screen-uaf-reproducer/shell.nix @@ -0,0 +1,4 @@ +{ pkgs ? import {} }: +pkgs.mkShell { + nativeBuildInputs = with pkgs; [ cmake qt6.qtbase qt6.wrapQtAppsHook wlr-randr ]; +} diff --git a/test/screen-uaf-reproducer/trigger.sh b/test/screen-uaf-reproducer/trigger.sh new file mode 100755 index 0000000..31609ff --- /dev/null +++ b/test/screen-uaf-reproducer/trigger.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# Trigger rapid output power-cycling to provoke the Qt Wayland screen UAF. +# Run the reproducer binary first, then this script in another terminal. +# +# Usage: ./trigger.sh [iterations] +# iterations: number of off/on cycles (default 20) + +set -euo pipefail + +cycles="${1:-20}" + +if command -v niri &>/dev/null && niri msg version &>/dev/null; then + echo "Detected Niri - using niri msg" + for i in $(seq 1 "$cycles"); do + echo "cycle $i/$cycles" + niri msg action power-off-monitors + sleep 0.3 + niri msg action power-on-monitors + sleep 0.5 + done +elif command -v wlr-randr &>/dev/null; then + output=$(wlr-randr --json 2>/dev/null | python3 -c \ + "import sys,json; print(next(o['name'] for o in json.load(sys.stdin) if o['enabled']))" 2>/dev/null \ + || wlr-randr | grep -oP '^\S+' | head -1) + if [ -z "$output" ]; then + echo "error: could not detect an output via wlr-randr" >&2 + exit 1 + fi + echo "Detected wlroots compositor - toggling output $output" + for i in $(seq 1 "$cycles"); do + echo "cycle $i/$cycles" + wlr-randr --output "$output" --off + sleep 0.3 + wlr-randr --output "$output" --on + sleep 0.5 + done +else + echo "error: neither niri nor wlr-randr found" >&2 + echo "Manually unplug/replug a monitor while the reproducer is running." >&2 + exit 1 +fi + +echo "Done. If the reproducer is still alive, the bug did not trigger." +echo "Try increasing iterations or adding more surfaces." From e85cd402668fa42d547755bbfb9185b6b2743d80 Mon Sep 17 00:00:00 2001 From: Damocles Date: Mon, 20 Apr 2026 20:43:36 +0200 Subject: [PATCH 2/3] add patched/unpatched reproducer packages for screen uaf --- flake.nix | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/flake.nix b/flake.nix index c62df34..ea8a45d 100644 --- a/flake.nix +++ b/flake.nix @@ -87,6 +87,27 @@ ''; docs = pkgs.callPackage ./nix/docs.nix { inherit self; }; default = nova-shell; + + # Reproducer for the qtbase wayland screen UAF, built twice: + # patched (should survive) and unpatched (should crash). + screen-uaf-reproducer-patched = pkgs.stdenv.mkDerivation { + pname = "screen-uaf-reproducer-patched"; + version = "0"; + src = ./test/screen-uaf-reproducer; + nativeBuildInputs = [ pkgs.cmake pkgs.qt6.wrapQtAppsHook ]; + buildInputs = [ pkgs.qt6.qtbase ]; + }; + screen-uaf-reproducer-unpatched = + let + rawPkgs = nixpkgs.legacyPackages.${pkgs.stdenv.hostPlatform.system}; + in + rawPkgs.stdenv.mkDerivation { + pname = "screen-uaf-reproducer-unpatched"; + version = "0"; + src = ./test/screen-uaf-reproducer; + nativeBuildInputs = [ rawPkgs.cmake rawPkgs.qt6.wrapQtAppsHook ]; + buildInputs = [ rawPkgs.qt6.qtbase ]; + }; } ); From 06fb97bc23f4e605f00d6c01dba9ab86d89425e5 Mon Sep 17 00:00:00 2001 From: Damocles Date: Mon, 20 Apr 2026 20:59:06 +0200 Subject: [PATCH 3/3] use unpatched qt for dev tooling (fmt, lint, devshell) --- flake.nix | 51 ++++++++++++++++------------ test/screen-uaf-reproducer/shell.nix | 11 ++++-- 2 files changed, 38 insertions(+), 24 deletions(-) diff --git a/flake.nix b/flake.nix index ea8a45d..5365927 100644 --- a/flake.nix +++ b/flake.nix @@ -55,8 +55,9 @@ nixpkgs.lib.genAttrs systems ( system: fn rec { - pkgs = (nixpkgs.legacyPackages.${system}).extend qtbaseOverlay; - treefmt-eval = treefmt-nix.lib.evalModule pkgs treefmt-config; + rawPkgs = nixpkgs.legacyPackages.${system}; + pkgs = rawPkgs.extend qtbaseOverlay; + treefmt-eval = treefmt-nix.lib.evalModule rawPkgs treefmt-config; } ); in @@ -64,7 +65,7 @@ formatter = forAllSystems ({ treefmt-eval, ... }: treefmt-eval.config.build.wrapper); packages = forAllSystems ( - { pkgs, ... }: + { pkgs, rawPkgs, ... }: let # Rebuild quickshell against patched Qt via its overlay qs = (pkgs.extend quickshell.overlays.default).quickshell.override { @@ -94,28 +95,30 @@ pname = "screen-uaf-reproducer-patched"; version = "0"; src = ./test/screen-uaf-reproducer; - nativeBuildInputs = [ pkgs.cmake pkgs.qt6.wrapQtAppsHook ]; + nativeBuildInputs = [ + pkgs.cmake + pkgs.qt6.wrapQtAppsHook + ]; buildInputs = [ pkgs.qt6.qtbase ]; }; - screen-uaf-reproducer-unpatched = - let - rawPkgs = nixpkgs.legacyPackages.${pkgs.stdenv.hostPlatform.system}; - in - rawPkgs.stdenv.mkDerivation { - pname = "screen-uaf-reproducer-unpatched"; - version = "0"; - src = ./test/screen-uaf-reproducer; - nativeBuildInputs = [ rawPkgs.cmake rawPkgs.qt6.wrapQtAppsHook ]; - buildInputs = [ rawPkgs.qt6.qtbase ]; - }; + screen-uaf-reproducer-unpatched = rawPkgs.stdenv.mkDerivation { + pname = "screen-uaf-reproducer-unpatched"; + version = "0"; + src = ./test/screen-uaf-reproducer; + nativeBuildInputs = [ + rawPkgs.cmake + rawPkgs.qt6.wrapQtAppsHook + ]; + buildInputs = [ rawPkgs.qt6.qtbase ]; + }; } ); devShells = forAllSystems ( - { pkgs, ... }: + { rawPkgs, ... }: { - default = pkgs.mkShell { - packages = with pkgs; [ + default = rawPkgs.mkShell { + packages = with rawPkgs; [ cargo rustc rust-analyzer @@ -129,21 +132,25 @@ ); checks = forAllSystems ( - { pkgs, treefmt-eval }: + { + pkgs, + rawPkgs, + treefmt-eval, + }: { formatting = treefmt-eval.config.build.check self; build = self.packages.${pkgs.stdenv.hostPlatform.system}.default; nova-stats = self.packages.${pkgs.stdenv.hostPlatform.system}.nova-stats; docs = self.packages.${pkgs.stdenv.hostPlatform.system}.docs; qmllint = - pkgs.runCommand "nova-shell-qmllint" + rawPkgs.runCommand "nova-shell-qmllint" { - nativeBuildInputs = [ pkgs.qt6.qtdeclarative ]; + nativeBuildInputs = [ rawPkgs.qt6.qtdeclarative ]; src = self; } '' cd $src - export QML_IMPORT_PATH="${pkgs.qt6.qtdeclarative}/lib/qt-6/qml:${ + export QML_IMPORT_PATH="${rawPkgs.qt6.qtdeclarative}/lib/qt-6/qml:${ quickshell.packages.${pkgs.stdenv.hostPlatform.system}.default }/lib/qt-6/qml" qmllint -E \ diff --git a/test/screen-uaf-reproducer/shell.nix b/test/screen-uaf-reproducer/shell.nix index aa50db7..0c6fd61 100644 --- a/test/screen-uaf-reproducer/shell.nix +++ b/test/screen-uaf-reproducer/shell.nix @@ -1,4 +1,11 @@ -{ pkgs ? import {} }: +{ + pkgs ? import { }, +}: pkgs.mkShell { - nativeBuildInputs = with pkgs; [ cmake qt6.qtbase qt6.wrapQtAppsHook wlr-randr ]; + nativeBuildInputs = with pkgs; [ + cmake + qt6.qtbase + qt6.wrapQtAppsHook + wlr-randr + ]; }