From 14940946513fe2b9d4fc06026995ae24abc144f8 Mon Sep 17 00:00:00 2001 From: Rogee Date: Sun, 6 Sep 2026 02:14:16 +0800 Subject: [PATCH] fix: reject UI operations when the input desktop is unavailable --- .../M6-Locked-Desktop-2026-09-05.md | 31 ++++++++ .../Locked-Desktop-20260905/doctor.json | 71 +++++++++++++++++++ .../Locked-Desktop-20260905/summary.json | 15 ++++ scripts/windows/Test-LockedDesktop.ps1 | 51 +++++++++++++ src/WxAgent.Windows/NativeMethods.txt | 1 + .../WechatChatClient.Listening.cs | 2 + src/WxAgent.Windows/WechatChatClient.cs | 4 ++ src/WxAgent.Windows/WechatDesktop.cs | 22 ++++++ src/WxAgent.Windows/WechatDoctor.cs | 8 ++- 9 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 docs/validation/M6-Locked-Desktop-2026-09-05.md create mode 100644 docs/validation/evidence/Locked-Desktop-20260905/doctor.json create mode 100644 docs/validation/evidence/Locked-Desktop-20260905/summary.json create mode 100644 scripts/windows/Test-LockedDesktop.ps1 create mode 100644 src/WxAgent.Windows/WechatDesktop.cs diff --git a/docs/validation/M6-Locked-Desktop-2026-09-05.md b/docs/validation/M6-Locked-Desktop-2026-09-05.md new file mode 100644 index 0000000..565810a --- /dev/null +++ b/docs/validation/M6-Locked-Desktop-2026-09-05.md @@ -0,0 +1,31 @@ +# 锁定桌面的失败保护(2026-09-05) + +## 问题与修复 + +Windows Session 1 断开并出现同会话的 `LogonUI` 后,UIA 仍可读取微信窗口。单靠 `Environment.UserInteractive`、窗口存在和控件齐全,会把不可操作的桌面误判为可发送,甚至先写入测试草稿再在鼠标点击时失败。 + +共享检查使用当前进程的非零会话、`Environment.UserInteractive` 和公开 Win32 `GetCursorPos` 的输入桌面访问结果。不尝试解锁、切换到登录桌面或重启微信。 + +- `doctor` 新增 `InputDesktopAvailable`;不可访问时报告 `SessionLocked`。 +- 会话附着、输入步骤、鼠标点击和独立监听绑定前检查桌面。 +- 监听启动时立即拒绝不可操作的桌面;运行期间重新绑定也检查,不能把锁屏 UIA 快照算作正常心跳。 + +## 已验证的范围 + +本次从 Git 暂存区单独导出锁屏保护源码,构建并发布 `releases/desktop-guard-only-1`;不包含工作区尚待验收的命名文本发送改动。 + +- Windows 10.0.19044,微信 4.1.13.63,已锁定/断开的 Session 1。 +- 发布程序 SHA-256:`355AA5DA758998A07D0BA7FA883858E0B664F5FDB8745BFFE322F383F94BEF2A`。 +- Core 72/72 通过;完整 Release 构建与 self-contained 发布为 0 警告、0 错误。 +- `doctor` 退出 2,`InputDesktopAvailable=false`,错误含 `SessionLocked`。 +- 只读 `inspect-ui` 退出 0:证明 UIA 可读并不等于桌面可操作。 +- `smoke`、`chat send`、`chat monitor` 均退出 1,并明确报告 `SessionLocked`;这是本负向测试的预期结果,不是正向 smoke 通过。 +- 测试独立读取输入框值并仅在内存中计算摘要,确认执行前后草稿未变;没有创建监听 checkpoint。 + +复现:`scripts/windows/Test-LockedDesktop.ps1 -Executable -OutputDirectory <新目录>`。脚本要求桌面已经不可操作;它不会主动锁屏或断开会话。 + +脱敏结果:`evidence/Locked-Desktop-20260905/`。 + +## 仍未验收 + +原会话需要由用户重新连接并解锁,之后补做正向发送/重复文本/双窗口事件隔离回归。此前点击失败与桌面不可访问有关,不能据此声称旧控件引用是已经证实的唯一根因。本记录不代表完整 M6 验收,更不能替代 8/24/72 小时耐久测试。 diff --git a/docs/validation/evidence/Locked-Desktop-20260905/doctor.json b/docs/validation/evidence/Locked-Desktop-20260905/doctor.json new file mode 100644 index 0000000..0cba7a7 --- /dev/null +++ b/docs/validation/evidence/Locked-Desktop-20260905/doctor.json @@ -0,0 +1,71 @@ +{ + "OperatingSystem": "Microsoft Windows NT 10.0.19044.0", + "UserInteractive": true, + "CurrentSessionId": 1, + "Processes": [ + { + "Id": 8912, + "WorkingSetBytes": 54181888, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 2527, + "ThreadCount": 124 + }, + { + "Id": 11064, + "WorkingSetBytes": 9629696, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 301, + "ThreadCount": 10 + }, + { + "Id": 17780, + "WorkingSetBytes": 8536064, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 376, + "ThreadCount": 15 + }, + { + "Id": 2436, + "WorkingSetBytes": 1191936, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 219, + "ThreadCount": 19 + }, + { + "Id": 14720, + "WorkingSetBytes": 987136, + "SessionId": 1, + "Version": "4.1.13.63", + "CanReadMemory": true, + "AccessError": null, + "HandleCount": 216, + "ThreadCount": 13 + } + ], + "WindowFound": true, + "RequiredControls": { + "MainView": true, + "session_list": true, + "chat_message_page": true, + "chat_message_list": true, + "chat_input_field": true, + "tool_bar_accessible": true + }, + "DataRootCount": 1, + "Errors": [ + "SessionLocked" + ], + "InputDesktopAvailable": false +} diff --git a/docs/validation/evidence/Locked-Desktop-20260905/summary.json b/docs/validation/evidence/Locked-Desktop-20260905/summary.json new file mode 100644 index 0000000..78d474d --- /dev/null +++ b/docs/validation/evidence/Locked-Desktop-20260905/summary.json @@ -0,0 +1,15 @@ +{ + "InspectExit": 0, + "DraftUnchanged": true, + "ListenerError": "SessionLocked", + "SendExit": 1, + "SendError": "SessionLocked", + "SmokeExit": 1, + "DoctorExit": 2, + "InputDesktopAvailable": false, + "ListenerExit": 1, + "BinarySha256": "355AA5DA758998A07D0BA7FA883858E0B664F5FDB8745BFFE322F383F94BEF2A", + "Success": true, + "SmokeError": "SessionLocked", + "CheckpointCreated": false +} diff --git a/scripts/windows/Test-LockedDesktop.ps1 b/scripts/windows/Test-LockedDesktop.ps1 new file mode 100644 index 0000000..7c6a07d --- /dev/null +++ b/scripts/windows/Test-LockedDesktop.ps1 @@ -0,0 +1,51 @@ +param( + [Parameter(Mandatory=$true)][string]$Executable, + [Parameter(Mandatory=$true)][string]$OutputDirectory +) +$ErrorActionPreference='Stop' +$exe=(Resolve-Path $Executable).Path +if(Test-Path $OutputDirectory){throw 'Use a fresh evidence directory.'} +$directory=(New-Item -ItemType Directory $OutputDirectory).FullName +$doctor=(& $exe doctor | Out-String) | ConvertFrom-Json +$doctorCode=$LASTEXITCODE +$doctor | ConvertTo-Json -Depth 8 | Set-Content "$directory\doctor.json" -Encoding UTF8 +if($doctor.InputDesktopAvailable -ne $false -or $doctor.Errors -notcontains 'SessionLocked'){ + throw 'This negative test requires an already unavailable input desktop; it does not lock or disconnect it.' +} +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +function Get-DraftDigest { + $ae=[System.Windows.Automation.AutomationElement] + $scope=[System.Windows.Automation.TreeScope]::Descendants + $windows=$ae::RootElement.FindAll([System.Windows.Automation.TreeScope]::Children,[System.Windows.Automation.Condition]::TrueCondition) + foreach($window in $windows){ + $p=Get-Process -Id $window.Current.ProcessId -ErrorAction SilentlyContinue + if($null -eq $p -or $p.ProcessName -ne 'Weixin'){continue} + $view=$window.FindFirst($scope,(New-Object System.Windows.Automation.PropertyCondition($ae::AutomationIdProperty,'MainView'))) + if($null -eq $view){continue} + $input=$window.FindFirst($scope,(New-Object System.Windows.Automation.PropertyCondition($ae::AutomationIdProperty,'chat_input_field'))) + $value=$input.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Current.Value + $sha=[Security.Cryptography.SHA256]::Create() + try{return [Convert]::ToBase64String($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))}finally{$sha.Dispose()} + } + throw 'Cannot independently verify the draft; do not accept mutation protection as tested.' +} +$before=Get-DraftDigest +& $exe inspect-ui --output "$directory\locked-ui.json" --timeout 20 | Out-Null +$inspectCode=$LASTEXITCODE +$smoke=(& $exe smoke --output "$directory\smoke-ui.json" --timeout 20 | Out-String) | ConvertFrom-Json +$smokeCode=$LASTEXITCODE +$sent=(& $exe chat send --text ('wx-agent-locked-'+[Guid]::NewGuid().ToString('N')) --timeout 20 | Out-String) | ConvertFrom-Json +$sendCode=$LASTEXITCODE +$listener=(& $exe chat monitor --seconds 1 --state-file "$directory\state.json" --timeout 5 | Out-String) | ConvertFrom-Json +$listenerCode=$LASTEXITCODE +$after=Get-DraftDigest +$result=@{DoctorExit=$doctorCode;InputDesktopAvailable=$doctor.InputDesktopAvailable;InspectExit=$inspectCode; + SmokeExit=$smokeCode;SmokeError=$smoke.error;SendExit=$sendCode;SendError=$sent.error; + ListenerExit=$listenerCode;ListenerError=$listener.error;CheckpointCreated=(Test-Path "$directory\state.json");DraftUnchanged=($before -ceq $after); + BinarySha256=(Get-FileHash $exe -Algorithm SHA256).Hash} +$result.Success=($doctorCode -ne 0 -and $smokeCode -ne 0 -and $sendCode -ne 0 -and $listenerCode -ne 0 -and + $smoke.error -eq 'SessionLocked' -and $sent.error -eq 'SessionLocked' -and $listener.error -eq 'SessionLocked' -and + !$result.CheckpointCreated -and $result.DraftUnchanged) +$result | ConvertTo-Json -Depth 5 | Set-Content "$directory\summary.json" -Encoding UTF8 +if(!$result.Success){exit 1} diff --git a/src/WxAgent.Windows/NativeMethods.txt b/src/WxAgent.Windows/NativeMethods.txt index 4b12a34..5ea5d09 100644 --- a/src/WxAgent.Windows/NativeMethods.txt +++ b/src/WxAgent.Windows/NativeMethods.txt @@ -11,6 +11,7 @@ IsWindowVisible IsIconic GetDpiForWindow GetWindowRect +GetCursorPos AccessibleObjectFromWindow AccessibleChildren EnumChildWindows diff --git a/src/WxAgent.Windows/WechatChatClient.Listening.cs b/src/WxAgent.Windows/WechatChatClient.Listening.cs index d448956..9f2d21a 100644 --- a/src/WxAgent.Windows/WechatChatClient.Listening.cs +++ b/src/WxAgent.Windows/WechatChatClient.Listening.cs @@ -15,6 +15,8 @@ public static partial class WechatChatClient private static async Task BindListeningWindowAsync( UIA3Automation automation, string session, bool independent, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + WechatDesktop.EnsureInputAvailable(); if (!independent) { var main = AttachWindow(automation, cancellationToken); diff --git a/src/WxAgent.Windows/WechatChatClient.cs b/src/WxAgent.Windows/WechatChatClient.cs index 1161fa6..6fda118 100644 --- a/src/WxAgent.Windows/WechatChatClient.cs +++ b/src/WxAgent.Windows/WechatChatClient.cs @@ -369,6 +369,7 @@ public static partial class WechatChatClient } WechatOperationPolicy.ValidateNames([session], nameof(session)); + WechatDesktop.EnsureInputAvailable(); var checkpoint = await ListenerCheckpointStore.LoadAsync(checkpointPath, cancellationToken); if (checkpoint is not null && !string.Equals(checkpoint.Session, session, StringComparison.Ordinal)) { @@ -548,6 +549,7 @@ public static partial class WechatChatClient private static AutomationElement AttachWindow(UIA3Automation automation, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + WechatDesktop.EnsureInputAvailable(); var window = WechatDoctor.FindWechatWindow(automation); if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null) { @@ -814,6 +816,7 @@ public static partial class WechatChatClient private static void ClickCenter(AutomationElement element) { + WechatDesktop.EnsureInputAvailable(); var bounds = element.BoundingRectangle; if (bounds.Width <= 0 || bounds.Height <= 0) { @@ -825,6 +828,7 @@ public static partial class WechatChatClient private static void ExecuteInputStep(string step, Action action) { + WechatDesktop.EnsureInputAvailable(); try { action(); diff --git a/src/WxAgent.Windows/WechatDesktop.cs b/src/WxAgent.Windows/WechatDesktop.cs new file mode 100644 index 0000000..2e62eed --- /dev/null +++ b/src/WxAgent.Windows/WechatDesktop.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using Windows.Win32; +using WxAgent.Core; + +namespace WxAgent.Windows; + +internal static class WechatDesktop +{ + public static bool IsInputAvailable() + { + using var process = Process.GetCurrentProcess(); + // UIA can read cached windows on a locked/disconnected desktop. GetCursorPos requires the input desktop. + return Environment.UserInteractive && process.SessionId != 0 && PInvoke.GetCursorPos(out _); + } + + public static void EnsureInputAvailable() + { + if (!IsInputAvailable()) + throw new WxAgentException(WxAgentErrorCode.SessionLocked, + "The current session cannot access the input desktop. Connect and unlock the existing user session; no login bypass was attempted."); + } +} diff --git a/src/WxAgent.Windows/WechatDoctor.cs b/src/WxAgent.Windows/WechatDoctor.cs index 2b0453b..c086ab8 100644 --- a/src/WxAgent.Windows/WechatDoctor.cs +++ b/src/WxAgent.Windows/WechatDoctor.cs @@ -24,7 +24,8 @@ public sealed record DoctorReport( bool WindowFound, IReadOnlyDictionary RequiredControls, int DataRootCount, - IReadOnlyList Errors); + IReadOnlyList Errors, + bool InputDesktopAvailable = false); public static class WechatDoctor { @@ -32,6 +33,8 @@ public static class WechatDoctor { cancellationToken.ThrowIfCancellationRequested(); var errors = new List(); + var inputDesktopAvailable = WechatDesktop.IsInputAvailable(); + if (!inputDesktopAvailable) errors.Add(WxAgentErrorCode.SessionLocked); using var currentProcess = Process.GetCurrentProcess(); var rawProcesses = Process.GetProcessesByName("Weixin"); ProcessDiagnostic[] processes; @@ -111,7 +114,8 @@ public static class WechatDoctor windowFound, controls, dataRootCount, - errors.Distinct().ToArray()); + errors.Distinct().ToArray(), + inputDesktopAvailable); } internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation)