fix: reject UI operations when the input desktop is unavailable
This commit is contained in:
@@ -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 <exe> -OutputDirectory <新目录>`。脚本要求桌面已经不可操作;它不会主动锁屏或断开会话。
|
||||
|
||||
脱敏结果:`evidence/Locked-Desktop-20260905/`。
|
||||
|
||||
## 仍未验收
|
||||
|
||||
原会话需要由用户重新连接并解锁,之后补做正向发送/重复文本/双窗口事件隔离回归。此前点击失败与桌面不可访问有关,不能据此声称旧控件引用是已经证实的唯一根因。本记录不代表完整 M6 验收,更不能替代 8/24/72 小时耐久测试。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -11,6 +11,7 @@ IsWindowVisible
|
||||
IsIconic
|
||||
GetDpiForWindow
|
||||
GetWindowRect
|
||||
GetCursorPos
|
||||
AccessibleObjectFromWindow
|
||||
AccessibleChildren
|
||||
EnumChildWindows
|
||||
|
||||
@@ -15,6 +15,8 @@ public static partial class WechatChatClient
|
||||
private static async Task<AutomationElement> BindListeningWindowAsync(
|
||||
UIA3Automation automation, string session, bool independent, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
WechatDesktop.EnsureInputAvailable();
|
||||
if (!independent)
|
||||
{
|
||||
var main = AttachWindow(automation, cancellationToken);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ public sealed record DoctorReport(
|
||||
bool WindowFound,
|
||||
IReadOnlyDictionary<string, bool> RequiredControls,
|
||||
int DataRootCount,
|
||||
IReadOnlyList<WxAgentErrorCode> Errors);
|
||||
IReadOnlyList<WxAgentErrorCode> Errors,
|
||||
bool InputDesktopAvailable = false);
|
||||
|
||||
public static class WechatDoctor
|
||||
{
|
||||
@@ -32,6 +33,8 @@ public static class WechatDoctor
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var errors = new List<WxAgentErrorCode>();
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user