学习某Desk远程,实现windows自动登录
沧海浮萍_ 2026-11-13 17:59 上海

看雪论坛作者ID:沧海浮萍_
最近发现某Desk远程桌面产品有一个很不错的功能,在软件上保存好目标计算机的密码之后,下次远程可以实现自动激活锁屏界面-自动输入密码-实现自动化登录,用起来很是舒畅,于是学习了一下相关的资料,实现了关键功能的模拟。
这里为大家介绍一下这个能力实现的核心路径,附带的代码也是本地登录的核心代码,大家可以根据自己的需要进行改造,比如改成云端实时动态传输密码进行自动登录等,都可以,下面进入正题。
如何识别当前计算机是锁屏状态?
Windows 的锁定状态属于“会话”,因此首先要明确目标会话。本项目通过 WTSGetActiveConsoleSessionId() 获取当前物理控制台会话,再查询该会话的 WTSSessionInfoEx 信息。
识别时检查三件事:会话为 WTSActive、已经存在登录用户,以及 SessionFlags 明确为 WTS_SESSIONSTATE_LOCK 或 WTS_SESSIONSTATE_UNLOCK。WTSActive 只表示会话连接状态,不能单独说明已经解锁。查询失败、未登录或断开连接应作为“未知/不适用”处理,不能直接当作解锁。接口定义参见 [WTSINFOEX_LEVEL1_W][1] 和 [WTSQuerySessionInformationW][2]。
关键代码:查询锁定状态
// 返回值表示“是否取得有效状态”;locked 才表示“是否锁定”。
// 返回 false 时,调用者必须忽略 locked,避免把查询失败当成解锁。
bool QueryLocked(DWORD session, bool& locked) {
LPWSTR memory = nullptr;
DWORD bytes = 0;
if (!WTSQuerySessionInformationW(
WTS_CURRENT_SERVER_HANDLE, session, WTSSessionInfoEx,
&memory, &bytes)) {
return false;
}
bool valid = false;
if (memory && bytes >= sizeof(WTSINFOEXW)) {
auto* info = reinterpret_cast<WTSINFOEXW*>(memory);
if (info->Level == 1) {
const auto& state = info->Data.WTSInfoExLevel1;
if (state.SessionState == WTSActive && state.UserName[0] != L'\0') {
if (state.SessionFlags == WTS_SESSIONSTATE_LOCK ||
state.SessionFlags == WTS_SESSIONSTATE_UNLOCK) {
locked = state.SessionFlags == WTS_SESSIONSTATE_LOCK;
valid = true;
}
}
}
}
// WTS 分配的内存必须通过 WTSFreeMemory 释放。
WTSFreeMemory(memory);
return valid;
}在服务监视循环中,只有查询成功时才更新 LockSchedule。调度器在首次观察到锁屏时设置 10 秒后的截止时间,后续轮询不推迟截止时间;到期领取一次任务后标记为已尝试;观察到解锁才清除该轮状态。服务每 500ms 轮询一次,会话通知同时用于唤醒轮询。具体实现见 main.cpp 的 Monitor() 和 schedule.h。
这使“等待期间手动解锁”能够取消任务,也使“密码错误”不会触发同一轮的持续重试。服务刚启动时若发现已经锁屏,10 秒从服务第一次观察到该状态开始计算,并非追溯用户实际锁屏的时间。
本文限定 Windows 10/11。若扩展到 Windows 7/Server 2008 R2,需注意微软文档记录的锁定/解锁标记反转问题,不能直接复用当前判断。[WTSINFOEX_LEVEL1_W][1]
如何发送通知激活系统登录界面?
这里使用的是软件安全注意序列 SAS(Secure Attention Sequence)。项目调用系统 sas.dll 导出的 SendSAS,由 Windows 处理登录界面的激活流程。它和普通窗口消息、拼接 Ctrl+Alt+Del 键盘事件具有不同的调用条件。[SendSAS][3]
首先要准备正确的执行上下文。服务本体留在 Session 0;worker 则通过复制服务主令牌、设置目标 TokenSessionId,再调用 CreateProcessAsUserW 进入目标会话。其 STARTUPINFO.lpDesktop 设为 winsta0\Winlogon。项目中这部分由 SpawnWorker() 完成,服务启动时还会检查 LocalSystem 身份并启用所需特权。
worker 在初始化 COM 和创建窗口之前调用 OpenDesktopW、SetThreadDesktop。已有窗口或钩子的线程切换桌面可能失败,因此该操作应尽早完成。桌面句柄在 worker 生命周期内保持有效。[SetThreadDesktop][4]
下面将项目 Worker() 的起始部分整理为一个辅助函数,突出“身份、会话、桌面”的准备顺序:
DWORD AttachWorkerDesktop(DWORD session) {
DWORD actual = MAXDWORD;
if (!IsSystem() ||
!ProcessIdToSessionId(GetCurrentProcessId(), &actual) ||
actual != session) {
return ERROR_ACCESS_DENIED;
}
if (!StillLocked(session)) return ERROR_CANCELLED;
HDESK desktop = OpenDesktopW(
L"Winlogon", 0, FALSE,
DESKTOP_READOBJECTS | DESKTOP_WRITEOBJECTS |
DESKTOP_CREATEWINDOW | DESKTOP_ENUMERATE | DESKTOP_SWITCHDESKTOP);
if (!desktop) return GetLastError();
if (!SetThreadDesktop(desktop)) {
const DWORD error = GetLastError();
CloseDesktop(desktop);
return error;
}
// 该示例用于短生命周期 worker:成功后保留句柄至进程退出。
// 线程仍绑定该桌面时,不在此处关闭句柄。
return ERROR_SUCCESS;
}关键代码:发送软件 SAS 并等待桌面状态
boolActivateSignIn(DWORD session){
wchar_t path[MAX_PATH]{};
const UINT n = GetSystemDirectoryW(path, MAX_PATH);
if (!n || n >= MAX_PATH - 9) returnfalse;
wcscat_s(path, L"\\sas.dll");
// 从系统目录加载,避免使用不明确的 DLL 搜索路径。
HMODULE module = LoadLibraryW(path);
if (!module) returnfalse;
using SendSas = void (WINAPI*)(BOOL);
auto send = reinterpret_cast<SendSas>(GetProcAddress(module, "SendSAS"));
if (send) {
// 沿用项目调用:FALSE 表示不以当前用户方式发送。
// 仍须满足服务/UIAccess、会话及软件 SAS 策略的要求。
send(FALSE);
}
FreeLibrary(module);
if (!send) returnfalse;
// SendSAS 返回 void,不能从返回值推断密码框已经就绪。
const ULONGLONG deadline = GetTickCount64() + 8000;
while (GetTickCount64() < deadline) {
if (!StillLocked(session)) returnfalse;
if (SecureDesktopActive()) returntrue;
Sleep(100);
}
returnfalse;
}其中 SecureDesktopActive() 通过 OpenInputDesktop 打开当前输入桌面,再用 GetUserObjectInformationW(..., UOI_NAME, ...) 确认名称为 Winlogon。这一步只确认桌面条件,密码控件是否已出现仍要在 Step 3 中检查。
软件 SAS 的策略条件
SendSAS 是受限制的接口;以管理员身份启动一个普通程序,并不能直接推导它具备相同能力。微软文档要求调用应用以服务方式运行,或者满足 UIAccess 条件;非服务调用还涉及调用者身份、UAC、签名与受保护安装路径等要求。项目在 LocalSystem worker 中执行该调用,但 worker 本身并不是由 SCM 注册运行的服务,因此“由服务启动且为 SYSTEM”不应直接视为满足文档中所有服务调用条件,实际目标系统上的行为仍需验证。[SendSAS][3]
项目安装脚本对 SoftwareSASGeneration 采用保留原能力的方式:未配置/0 设置为 1,2 设置为 3,已有 1/3 保持。它保存原值供卸载恢复。该策略允许服务生成 SAS,但不保证后续 UI Automation 或 SendInput 一定被允许。
激活后,项目还会发送一次空格以推进界面,再进入密码框识别循环;空格必须经过 Step 4 所示的输入桌面校验函数。SAS、桌面切换、密码框出现是连续的异步过程,需要逐步观察状态。
如何将光标定位到系统密码输入框?
这里的“光标定位”是让密码编辑框获得键盘焦点。项目使用 UI Automation 从当前 LogonUI 窗口查找密码元素,再调用 IUIAutomationElement::SetFocus(),随后重新读取当前焦点进行复核。[SetFocus][5]
UI Automation 初始化在桌面绑定之后进行:调用 CoInitializeEx(nullptr, COINIT_MULTITHREADED),然后以 CLSID_CUIAutomation 创建 IUIAutomation。项目的 Com<T> 负责接口释放,成功初始化 COM 后还应对应调用 CoUninitialize()。
先确认“这确实是目标系统密码框”
项目组合了以下条件:目标会话仍锁定、当前输入桌面为 Winlogon、前台进程属于同一会话且路径为系统目录中的 LogonUI.exe,焦点元素同时满足编辑框类型、密码属性、可用状态和允许的字段标签。
IsLogonUIProcess() 会通过 ProcessIdToSessionId 检查会话,再通过 QueryFullProcessImageNameW 比较系统目录中的完整可执行文件路径。它是一组降低误操作概率的检查,不等同于对进程文件的签名验证,也无法把“检查焦点”和“发送输入”变成原子操作。
bool PasswordLabel(const wchar_t* name) {
// 当前工程只接受这些中英文密码标签;PIN 不在允许列表中。
returnname && (
_wcsicmp(name, L"Password") == 0 ||
_wcsicmp(name, L"Enter password") == 0 ||
wcscmp(name, L"密码") == 0 ||
wcscmp(name, L"输入密码") == 0);
}
// 为文章精简了工程中可选的 output 参数,保留完整判定链。
bool FocusIsPassword(IUIAutomation* automation, DWORD session) {
if (!StillLocked(session) || !SecureDesktopActive()) returnfalse;
DWORD foregroundPid = 0;
GetWindowThreadProcessId(GetForegroundWindow(), &foregroundPid);
if (!IsLogonUIProcess(foregroundPid, session)) returnfalse;
Com<IUIAutomationElement> focused;
if (FAILED(automation->GetFocusedElement(focused.out())) || !focused.p)
returnfalse;
BOOL secret = FALSE, enabled = FALSE;
int pid = 0, control = 0;
BSTR name = nullptr;
const bool ok =
SUCCEEDED(focused->get_CurrentProcessId(&pid)) &&
IsLogonUIProcess(static_cast<DWORD>(pid), session) &&
SUCCEEDED(focused->get_CurrentControlType(&control)) &&
control == UIA_EditControlTypeId &&
SUCCEEDED(focused->get_CurrentIsPassword(&secret)) && secret &&
SUCCEEDED(focused->get_CurrentIsEnabled(&enabled)) && enabled &&
SUCCEEDED(focused->get_CurrentName(&name)) && PasswordLabel(name);
if (name) SysFreeString(name);
return ok;
}再查找密码控件并设置焦点
bool FocusPasswordField(IUIAutomation* automation, DWORD session) {
if (!StillLocked(session) || !SecureDesktopActive()) returnfalse;
HWND window = GetForegroundWindow();
DWORD pid = 0;
GetWindowThreadProcessId(window, &pid);
if (!IsLogonUIProcess(pid, session)) returnfalse;
Com<IUIAutomationElement> root;
Com<IUIAutomationCondition> condition;
Com<IUIAutomationElementArray> elements;
if (FAILED(automation->ElementFromHandle(window, root.out()))) returnfalse;
// 在当前 LogonUI 窗口子树中查找 IsPassword=true 的控件。
VARIANT secret{};
secret.vt = VT_BOOL;
secret.boolVal = VARIANT_TRUE;
if (FAILED(automation->CreatePropertyCondition(
UIA_IsPasswordPropertyId, secret, condition.out())) ||
FAILED(root->FindAll(TreeScope_Descendants, condition.p, elements.out()))) {
returnfalse;
}
int count = 0;
if (FAILED(elements->get_Length(&count))) returnfalse;
for (int i = 0; i < count; ++i) {
Com<IUIAutomationElement> element;
if (FAILED(elements->GetElement(i, element.out()))) continue;
BSTR name = nullptr;
BOOL offscreen = TRUE;
if (FAILED(element->get_CurrentName(&name))) continue;
const bool match = PasswordLabel(name);
SysFreeString(name);
// SetFocus 成功后,仍重新验证实际焦点,避免仅凭调用成功就输入。
if (match &&
SUCCEEDED(element->get_CurrentIsOffscreen(&offscreen)) && !offscreen &&
SUCCEEDED(element->SetFocus()) &&
FocusIsPassword(automation, session)) {
returntrue;
}
}
returnfalse;
}如果界面当前选择 PIN,项目还会用 ClickNamedButton() 查找“登录选项 / Sign-in options”和“密码 / Password”按钮,并通过 UI Automation 的 Invoke 模式切换。切换后继续轮询并验证密码框;12 秒内仍未确认就结束本轮,且不读取密码文件。该逻辑依赖目标系统和凭据提供程序暴露的 UI Automation 元素,需要针对实际 Windows 界面验证。
这一阶段只能确认输入目标;账户是否选对仍是使用前提。当前代码没有把界面中的用户名与 C:\psw.txt 中密码所属账户做自动绑定。
如何自动化输入密码并回车确认?
确认目标后,worker 才读取 C:\psw.txt。工程中的 ReadPassword() / DecodePassword() 支持 UTF-8(可带 BOM)和带 BOM 的 UTF-16LE,保留密码首尾空格,只移除末尾一个换行,并拒绝空密码、多行、无效编码及超长输入。最大长度为 1024 个 UTF-16 代码单元。文件在安装时限制为 SYSTEM/Administrators 访问,密码不进入命令行、环境变量或日志。
完整的输入顺序是:复核焦点 → Ctrl+A → 再次复核 → 逐字符发送 Unicode 事件 → 等待最后一个字符的 200ms 间隔 → 复核焦点 → 回车 → 查询解锁状态。
每次输入前重新检查输入桌面
SAS 可能导致输入桌面切换,因此不能只在 worker 启动时绑定一次桌面。项目在每次 SendInput 前重新调用 OpenInputDesktop,确认仍为 Winlogon,再绑定该桌面。OpenInputDesktop 的具体访问条件参见[接口文档][8]。
bool InjectInput(INPUT* events, UINT count, constwchar_t* operation) {
DWORD session = MAXDWORD;
ProcessIdToSessionId(GetCurrentProcessId(), &session);
HDESK desktop = OpenInputDesktop(
0, FALSE, GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE);
if (!desktop) returnfalse;
if (!DesktopNameIs(desktop, L"Winlogon")) {
CloseDesktop(desktop);
SetLastError(ERROR_CANCELLED);
returnfalse;
}
if (!SetThreadDesktop(desktop)) {
const DWORD error = GetLastError();
CloseDesktop(desktop);
SetLastError(error);
returnfalse;
}
if (gInputDesktop) CloseDesktop(gInputDesktop);
gInputDesktop = desktop; // 保留当前绑定的句柄。
SetLastError(ERROR_SUCCESS);
constUINT sent = SendInput(count, events, sizeof(INPUT));
const DWORD error = GetLastError(); // 立即保存,避免被日志 API 改写。
if (sent == count) returntrue;
wchar_t line[180]{};
swprintf_s(line, L"send_input_failed operation=%ls sent=%u expected=%u",
operation, sent, count);
Log(line, session, error, EVENTLOG_ERROR_TYPE);
LogInputContext(operation);
SetLastError(error);
returnfalse;
}SendInput 返回的是成功插入输入流的事件数量,而不是认证结果。它受 UIPI 等机制约束;返回值与 GetLastError() 不能单独证明失败就是 UIPI 导致。项目保存返回数量、即时错误值和桌面/令牌上下文,供后续定位。[SendInput][6]
清空已有输入,并逐字符发送 Unicode
boolKey(WORD key){
INPUT events[2]{};
for (auto& event : events) {
event.type = INPUT_KEYBOARD;
event.ki.wVk = key;
}
events[1].ki.dwFlags = KEYEVENTF_KEYUP;
returnInjectInput(events, 2, L"virtual_key");
}
boolSelectAll(){
INPUT events[4]{};
for (auto& event : events) event.type = INPUT_KEYBOARD;
events[0].ki.wVk = VK_CONTROL;
events[1].ki.wVk = 'A';
events[2].ki.wVk = 'A';
events[2].ki.dwFlags = KEYEVENTF_KEYUP;
events[3].ki.wVk = VK_CONTROL;
events[3].ki.dwFlags = KEYEVENTF_KEYUP;
returnInjectInput(events, 4, L"select_all");
}
boolTypeUnicode(IUIAutomation* automation, DWORD session,
const std::vector<wchar_t>& text){
if (text.empty()) {
SetLastError(ERROR_INVALID_DATA);
returnfalse;
}
Secret<INPUT> events;
events.data.resize(4); // 一个 UTF-16 代理对需要两组按下/抬起事件。
for (size_t i = 0; i < text.size();) {
if (i != 0) Sleep(kCharacterIntervalMs);
// 用户可能在输入期间切换焦点或手动解锁。
if (!FocusIsPassword(automation, session)) {
SetLastError(ERROR_CANCELLED);
returnfalse;
}
// 输入来自 DecodePassword,已经完成 UTF-16 合法性验证。
constbool pair = text[i] >= 0xd800 && text[i] <= 0xdbff;
if (pair && (i + 1 >= text.size() ||
text[i + 1] < 0xdc00 || text[i + 1] > 0xdfff)) {
SetLastError(ERROR_INVALID_DATA);
returnfalse;
}
constsize_t units = pair ? 2 : 1;
for (size_t n = 0; n < units; ++n) {
INPUT& down = events.data[2 * n];
down.type = INPUT_KEYBOARD;
down.ki.wVk = 0; // KEYEVENTF_UNICODE 要求 wVk 为 0。
down.ki.wScan = static_cast<WORD>(text[i + n]);
down.ki.dwFlags = KEYEVENTF_UNICODE;
events.data[2 * n + 1] = down;
events.data[2 * n + 1].ki.dwFlags |= KEYEVENTF_KEYUP;
}
if (!InjectInput(events.data.data(), static_cast<UINT>(units * 2),
L"password_unicode")) returnfalse;
SecureZeroMemory(events.data.data(), events.data.size() * sizeof(INPUT));
i += units;
}
returntrue;
}KEYEVENTF_UNICODE 用 wScan 携带 UTF-16 代码单元,并要求 wVk=0;抬起事件叠加 KEYEVENTF_KEYUP。这里按 Unicode 标量值分组发送,高低代理一起发送,中间不插入 200ms 等待。组合字符可能包含多个标量,因此这个节奏不等同于按用户感知的字形计时。[KEYBDINPUT][7]
200ms 是项目指定的发送间隔,实际显示节奏还受 UI Automation 调用、线程调度及系统界面处理影响。若 Ctrl、Shift、Alt 等键被用户持续按住,也可能影响模拟输入;SendInput 不会自动重置现有键盘状态。[SendInput][6]
回车提交,并区分“已经发送”和“已经解锁”
下面将项目 Worker() 的提交部分整理为一个函数;UI Automation 已初始化,密码框已经定位,但在读取密码前仍会再次验证焦点。
DWORD SubmitAndObserveUnlock(IUIAutomation* automation, DWORD session) {
if (!FocusIsPassword(automation, session)) return ERROR_CANCELLED;
Secret<wchar_t> password;
if (!ReadPassword(password)) return ERROR_INVALID_DATA;
// Ctrl+A 后直接输入会替换选中内容,无须额外发 Backspace。
if (!FocusIsPassword(automation, session) || !SelectAll() ||
!FocusIsPassword(automation, session) ||
!TypeUnicode(automation, session, password.data)) {
return ERROR_WRITE_FAULT;
}
// 给最后一个标量保留同样的显示间隔,再检查焦点并按回车。
Sleep(kCharacterIntervalMs);
if (!FocusIsPassword(automation, session) || !Key(VK_RETURN))
return ERROR_WRITE_FAULT;
Log(L"password_submitted_once", session);
const ULONGLONG deadline = GetTickCount64() + 10000;
while (GetTickCount64() < deadline) {
bool locked = true;
if (QueryLocked(session, locked) && !locked) {
Log(L"unlock_observed", session);
return ERROR_SUCCESS;
}
// 查询失败不能当成成功,继续等待有效的解锁状态。
Sleep(200);
}
Log(L"unlock_not_observed_no_retry", session, ERROR_TIMEOUT,
EVENTLOG_WARNING_TYPE);
return ERROR_TIMEOUT;
}注意,项目中的password_submitted_once 仅代表密码与回车事件已经发送,unlock_observed 表示之后确实观察到了会话解锁;后者也不能区分解锁究竟来自自动提交还是用户同时手动操作。若 10 秒内没有观察到解锁,结束本轮且不自动重试。
输入保护主要依靠“输入前反复校验状态并在异常时退出”。焦点变化与 SendInput 之间仍存在很短的竞争窗口,因此应避免在自动输入期间操作键盘或切换用户。当前实现也不会替用户识别密码是否正确,认证仍由系统凭据提供程序处理。
项目中的测试代码基本流程:读取“c:\psw.txt”中的铭文密码-10s内连续检测锁屏状态-激活登录界面-逐个字符输入密码-发送回车解锁登录,代码在"阅读原文"附件,大家可以自由下载。
*本文为看雪论坛优秀文章,由 沧海浮萍_原创,转载请注明来自看雪社区


球分享

球点赞

球在看

点击阅读原文查看更多
本文转载自微信公众号「看雪学院」,查看原文。版权归原作者所有,如有侵权请联系删除。

