星号密码查看器 — 原理与实现详解

从 Win32 ES_PASSWORD、WM_GETTEXT、MSAA 到 Electron 拾取引擎,说明星号密码查看器为何能在本机读出密码框明文,以及 Chrome/UWP 等读不到的原因。

本文面向对技术原理感兴趣的同学和读者,尽量用真实、可验证的方式解释:为什么界面上是 *,工具却能在本机读出明文;它到底调用了哪些 Windows 机制;又有哪些场景确实读不到。若您只想了解如何使用,请先阅读 《功能介绍》


阅读导航

章节 内容
一、从「星号」说起 密码框里实际存的是什么
二、Windows 窗口基础 HWND、消息、跨进程读取
三、和键盘记录器的区别 主动拾取 vs 被动监听
四、整体架构 Electron + C++ 引擎
五、一次拾取的完整时间线 30ms 轮询与状态机
六、probeAt 探测流水线 从坐标到文本
七、三条读取路径 Edit / IE·MSAA / ComboBox
八、辅助机制 高亮、黑名单、隐私
九、真实场景走查 Xshell、IE、现代浏览器
十、能力边界与安全观 能读什么、不能读什么
十一、常见误解 FAQ 辟谣与答疑
附录 A 路径与状态对照总表

一、从「星号」说起

1.1 屏幕上看到的 ≠ 内存里保存的

很多 Windows 程序里的密码框,看起来是一串 *,但控件内部通常一直保存着您输入的真实字符(在 Win32 里往往是 UTF-16 宽字符串,存在 Edit 控件的内部缓冲区里)。

以标准 Win32 Edit 控件为例,样式里有一个位叫 ES_PASSWORD(0x0020)。它的含义是:

绘制文本时,用密码字符(默认 *)代替每个真实字符。

不是加密、不是哈希、不是把明文删掉。就像 Word 里把字体设成白色——字还在,只是显示方式变了。

因此,只要程序用的是「带密码样式的经典文本框」,并且您在本机有权限向该窗口发消息,就有可能通过 Windows 提供的合法接口问一句:「你缓冲区里现在是什么?」——答案就是明文。

1.2 星号密码查看器在做什么

可以概括为四步:

  1. 您主动在工具里按住拾取热区(左键不松开);
  2. 把鼠标移到目标程序的输入框上;
  3. 工具根据屏幕坐标找到下面的窗口(HWND),再按控件类型选策略读取文本;
  4. 读到的内容只显示在工具自己的窗口里,默认不上传、不写文件。

拾取过程示意

拾取时:左侧为工具窗,右侧为目标程序;引擎跨进程读取目标控件内的文本

1.3 它「破解」了什么?

更准确的说法是:它没有破解加密,只是读取 UI 控件里已有的缓冲区内容


二、Windows 窗口基础(科普)

要理解拾取引擎,需要先弄清几个 Win32 概念。不必会写 C++,知道「系统怎么组织界面」即可。

2.1 HWND:窗口句柄

每个可见(或不可见)的窗口、控件,在系统里都有一个 HWND(窗口句柄)——可以理解成系统内部的「身份证号」。

鼠标点在屏幕上时,系统需要回答:「这个坐标最上面是哪个窗口?」——这就是 WindowFromPoint 做的事。

2.2 窗口树:父窗口与子窗口

Windows 界面是一棵

顶级窗口(例如 Xshell 主窗)
  └─ 对话框 #32770
       └─ Edit(真正的密码输入区)

有时鼠标下的 HWND父对话框,而不是里面的 Edit。拾取引擎会:

  1. RealChildWindowFromPoint 从坐标向下穿透几层;
  2. 若仍不准,对整棵子树 EnumChildWindows,找类名像 EditTEdit 且包含鼠标点的最小矩形。

这就是为什么「点在对话框空白处,仍能读到里面密码框」——引擎会主动找子 Edit

2.3 SendMessage:跨进程「问一句」

SendMessage(WM_GETTEXT, …) 的意思是:向目标窗口发送「请把你缓冲区的文字复制到我给的缓冲区里」。

要点:

概念 说明
跨进程 目标程序在另一个进程;Windows 内核会把消息投递到对方线程,由对方窗口过程处理
同步 SendMessage 会等对方处理完才返回
权限 若目标进程完整性级别更高(例如「以管理员运行」),普通用户进程可能被 UIPI 拒绝

这不是注入、不是 Hook,而是 Win32 公开文档化的消息机制,Spy++、Accessibility Insights 等工具同样依赖类似思路。

2.4 宽字符与长度上限

Edit 内部多用 UTF-16(wchar_t。本工具单次最多读取 120 个宽字符(与经典实现保持一致,足够覆盖常见密码框;极长文本可能截断)。


三、和键盘记录器的区别

维度 星号密码查看器 典型键盘记录器
触发 您按住拾取、主动指向目标 常后台静默运行
数据来源 目标控件的 WM_GETTEXT / MSAA 等 全局键盘 Hook / 驱动
进程行为 不注入目标进程 常注入或挂钩
结果去向 仅本工具界面 可能写文件、外传
适用场景 核对已输入、忘记复制出来的密码 恶意窃听

结论:原理是「读控件内存」,不是「记您按了哪些键」。两者在合规与风险上完全不同——本工具设计前提是您在本机、对您有权操作的软件、主动发起拾取


四、整体架构:界面与拾取引擎如何配合

工具分三层:React 界面Electron 主进程C++ 拾取引擎(DLL)

flowchart TB
  User[用户按住拾取热区]
  UI[Electron 界面 React]
  Main[Electron 主进程]
  DLL[C++ 拾取引擎]
  Target[其他程序的窗口控件]

  User --> UI
  UI -->|IPC: 开始/结束拾取| Main
  Main -->|每约 30ms| MainLoop[读取光标 + 左键状态]
  MainLoop -->|pickerProbeAt x y| DLL
  DLL -->|WindowFromPoint SendMessage MSAA| Target
  DLL -->|state text path| Main
  Main -->|picker:update| UI

4.1 为什么拾取放在主进程?

渲染网页的 Renderer 进程沙箱化、焦点受限,很难稳定获得「全屏鼠标坐标」并调用原生 DLL。
主进程可以:

4.2 为什么用 C++ 动态库?

读取其他程序窗口需要直接调用 Win32 APICOM(IE 的 IHTMLDocument2IAccessible)。C++ 延迟低,也和 UI 解耦:界面只展示,引擎只探测。

进程初始化时会 OleInitialize,保证 MSAA/COM 调用稳定。

4.3 如何避免拾取到自己?

开始拾取时,主进程把本工具窗口的 native HWND 传给引擎(pickerBegin(exclude_hwnd))。引擎会:

4.4 界面层(React)做什么?

usePicker hook 监听 picker:update / picker:ended:更新坐标、结果文本、路径标签(如 Win32 Edit · ES_PASSWORD)。不直接碰 Win32,只消费主进程推送的结构化结果。

主界面拾取热区


五、一次拾取的完整时间线

5.1 状态机

stateDiagram-v2
  [*] --> Idle
  Idle --> Picking: 在拾取热区按下左键
  Picking --> Picking: 移动鼠标 每30ms探测
  Picking --> Idle: 松开左键
  Idle --> [*]
状态 用户侧 系统侧
空闲 显示上次结果或占位 不探测
拾取中 十字光标、坐标变化、目标白框 pickerProbeAt 循环;IE 会话可能保持
结束 提示已退出 pickerEnd:擦高亮、clearIeSession

5.2 时序(简化)

sequenceDiagram
  participant U as 用户
  participant R as React
  participant M as 主进程
  participant D as C++引擎
  participant T as 目标程序

  U->>R: 在热区按下左键
  R->>M: beginPickerSession
  M->>D: pickerBegin exclude_hwnd
  loop 每30ms且左键仍按下
    M->>M: getCursorScreenPoint
    M->>D: pickerProbeAt x y
    D->>T: WindowFromPoint / SendMessage / MSAA
    T-->>D: 文本或拒绝
    D-->>M: state text path
    M->>R: picker:update
  end
  U->>R: 松开左键
  M->>D: pickerEnd
  M->>R: picker:ended

5.3 引擎返回的状态

state 含义 界面典型文案
Success 读到非空文本 显示明文 + 路径标签
IeHint 进入 IE 区域但未命中 input 「已进入 IE 浏览器区域」
Denied UIPI 等权限拒绝 「无法访问目标窗口…」
Unsupported 类名黑名单或不支持 「当前控件不支持读取」
Empty 无窗口、自身窗口、空控件 空白或占位

六、probeAt 探测流水线

每次 pickerProbeAt(x, y) 大致按下面顺序执行(与实现一致):

flowchart TD
  Start[pickerProbeAt] --> Picking{picking?}
  Picking -->|否| Empty[Empty]
  Picking -->|是| Resolve[resolveHwndAtPoint]
  Resolve --> ChildEdit{非Combo且可找子Edit?}
  ChildEdit -->|是| UseEdit[hwnd = 子Edit]
  ChildEdit -->|否| Keep[保持 hwnd]
  UseEdit --> Ie2b
  Keep --> Ie2b{ie_root 存在且 hwnd 无效或自身?}
  Ie2b -->|是| Inner[probeIeInner 路径2b]
  Ie2b -->|否| NoHwnd{hwnd 有效?}
  NoHwnd -->|否| Clear[擦高亮 clearIeSession Empty]
  NoHwnd -->|是| ProbeWin[probeWindow]
  ProbeWin --> Branch{类名分支}
  Branch --> Combo[probeComboBox]
  Branch --> Edit[probeEdit]
  Branch --> IE[probeIeServer]
  Branch --> Gen[probeGeneric]

关键设计点:

  1. 子 Edit 优先:点在对话框上也能找到内嵌密码框。
  2. 路径 2b:IE 会话建立后,即使鼠标 briefly 经过工具窗口,仍用缓存的 ie_rootaccHitTest,避免 IE 内拾取「闪断」。
  3. 离开 IE 时清理:进入 probeEdit / probeComboBox / probeGeneric 前调用 clearIeSession(),防止用错误的 IE 根对象继续 hitTest。

七、三条读取路径

定位到 HWND 后,按类名分支。三者互斥,优先级体现在 probeWindow 的分支顺序上。

flowchart TD
  Point[屏幕坐标] --> Resolve[定位 HWND]
  Resolve --> Combo{ComboBox 类?}
  Combo -->|是| ComboPath[子 Edit 或 CB_GETLBTEXT]
  Combo -->|否| Edit{Edit 类?}
  Edit -->|是| EditPath[密码绕过 / WM_GETTEXT]
  Edit -->|否| IE{Internet Explorer_Server?}
  IE -->|是| IEPath[WM_HTML_GETOBJECT + MSAA]
  IE -->|否| Generic[WM_GETTEXT 或 不支持]

7.1 路径一:Win32 Edit 密码框

适用:类名为 Edit,或 TEdit(Delphi)、ThunderRT6TextBox(VB6)、含 .EDIT. 的 MFC 等。

核心思路:密码样式只影响绘制;临时去掉密码模式 → WM_GETTEXT立即恢复

7.1.1 ES_PASSWORD 绕过(逐步)

步骤 API / 消息 说明
1 GetWindowLongPtr(GWL_STYLE) 读取样式,检查 ES_PASSWORD
2 SetWindowWord + SetWindowLongPtr 清密码位 促使控件刷新内部状态(经典实现顺序)
3 SendMessage(WM_GETTEXT) 跨进程读宽字符,最多 120
4 SetWindowLongPtr 写回原始 style 用户通常无感知

伪代码:

style = GetWindowLongPtr(hwnd, GWL_STYLE)
if (style & ES_PASSWORD) {
    SetWindowWord(hwnd, GWL_STYLE, 0)
    SetWindowLongPtr(hwnd, GWL_STYLE, style & ~ES_PASSWORD)
    SendMessage(hwnd, WM_GETTEXT, buf, ...)
    SetWindowLongPtr(hwnd, GWL_STYLE, style)  // 恢复
}

7.1.2 多级 fallback(真实顺序)

probeEdit 并非只试一种办法,而是按失败顺序逐级降级(与源码一致):

顺序 策略 路径标签示例 典型场景
ES_PASSWORD 位存在 → 样式绕过 Win32 Edit · ES_PASSWORD 标准密码框
EM_SETPASSWORDCHAR 置 0 再读,读完后恢复 * Edit · EM_SETPASSWORDCHAR Xshell 等:显示 * 但未置 ES_PASSWORD
无 ES_PASSWORD 位仍尝试样式绕过 Win32 Edit · ES_PASSWORD 部分程序样式位读不准
普通 WM_GETTEXT Win32 Edit · WM_GETTEXT 普通 Edit、非密码
SendMessageTimeout 500ms (同上) 目标线程繁忙防卡死

任一步若 ERROR_ACCESS_DENIED → 状态 Denied(UIPI)。

7.1.3 子 Edit 穿透

ComboBox 的可编辑区另见 7.3


7.2 路径二:IE / Trident(MSAA)

适用:类名 Internet Explorer_Server——仍用 IE/Trident 内核嵌入页面的老式程序(部分工控、老 ERP、旧版内嵌 WebView)。

不适用:Chrome、Edge(Chromium)、Firefox 的网页——它们的渲染层不是 Win32 Edit,也没有上述类名(见 九、场景走查)。

7.2.1 什么是 MSAA?

MSAA(Microsoft Active Accessibility) 是 Windows 早期的无障碍接口。屏幕阅读器用它获取「这是什么控件、当前值是多少」。
IE 的 HTML 元素会映射到 IAccessible 树:角色(role)可以是 passwordtext,值(value)即输入框里的字符串。

工具走的是文档化的 COM 接口,不是私有协议。

7.2.2 阶段 A — 建立 IE 会话

  1. 向 IE 控件发注册消息 WM_HTML_GETOBJECT
  2. 通过 ObjectFromLresult 拿到文档相关对象;
  3. 取得 IHTMLDocument2,再拿到文档的 IAccessible缓存ie_root_)。

若 COM 失败,仍可能显示 IeHint(「已进入 IE 浏览器区域」),路径标签 IE · WM_HTML_GETOBJECT

7.2.3 阶段 B — accHitTest 递归

对缓存的根对象:

  1. accHitTest(x, y) — 坐标先试 IE 客户区(ScreenToClient),未命中再试屏幕坐标;
  2. 检查命中元素 role 是否为 ROLE_SYSTEM_PASSWORD / ROLE_SYSTEM_TEXT(或字符串 "password" / "text");
  3. 匹配则 get_accValue 读当前值;
  4. 未匹配则沿 HTML 父节点调整坐标递归,深度上限 32,防死循环。

还可降级尝试 IHTMLElement 相关接口(实现中的补充路径)。

7.2.4 路径 2b — IE 内持续追踪

鼠标移过工具窗口时,WindowFromPoint 可能命中工具自身,但 ie_root_ 仍有效。此时走 probeIeInner,继续用 IE 根做 hitTest,使在 IE 页面内移动时光标不必一直压在 IE 控件上也能更新结果。

7.2.5 何时 clearIeSession?

进入 Edit、ComboBox、Generic 分支前都会 clearIeSession()(释放 IAccessible、清空 ie_server_hwnd_),避免从 IE 切到普通控件后仍用 IE 树探测。


7.3 路径三:ComboBox 与普通控件

7.3.1 普通控件

对实现了 WM_GETTEXT 的标准控件直接读取;失败则 UnsupportedDenied

7.3.2 ComboBox 结构

ComboBox / ComboBoxEx32
  ├─ Edit(CBS_DROPDOWN 可编辑时)
  └─ ListBox(下拉列表)

控件类型示例

类型 样式 读取方式 路径标签
可编辑下拉 CBS_DROPDOWN CB_GETEDITCONTROL → 对子 Edit 走路径一 ComboBox · Edit
只读下拉 CBS_DROPDOWNLIST CB_GETCURSEL + CB_GETLBTEXT ComboBox · CB_GETLBTEXT
ComboBoxEx32 扩展控件 GetWindow(GW_CHILD) 找到内嵌标准 ComboBox 再处理 同上

ComboBoxEx32 细节:外层类名是 ComboBoxEx32,真正逻辑在第一个子 ComboBox 上;若 Edit 区有预设文本,需宿主正确初始化(回归靶场 ComboBoxPickerTest 验证了 CBEM_SETEDITTEXT 等场景)。

鼠标点在 Edit 区域 → findComboBoxEditAtPointprobeEdit;否则 probeComboBoxList 读当前选中项文字。


八、辅助机制

8.1 XOR 白色高亮

切换目标时,在目标客户区XOR 模式R2_XORPEN)画 3 像素白色空心矩形:

切换目标或结束拾取时 eraseHighlight 擦除。

8.2 类名黑名单

对已知无法通过 Win32 消息可靠读取的类名,直接 Unsupported,避免长时间 SendMessage 挂起或误导:

类名(部分) 原因
Chrome_WidgetWin_1 / Chrome_RenderWidgetHostHWND Chromium 多进程渲染,非 Edit
MozillaWindowClass / 含 Firefox Gecko 自绘
ApplicationFrameWindow / Windows.UI.Core.CoreWindow UWP
Credential Dialog Xaml Host 系统凭据 UI

ChromeMozillaFirefox 子串的类名也会拦截。

8.3 路径标签(path)

成功或部分成功时,界面右下角显示简短标签,表示本次命中的策略,便于您判断场景是否被支持,例如:

8.4 隐私与数据流

目标控件内存 → C++ 引擎(栈上 wchar 缓冲)→ UTF-8 字符串 → 主进程 → React 状态

九、真实场景走查

9.1 Xshell 连接对话框(已验证)

现象:密码框显示 *,类名常为 Edit 或类似,未必ES_PASSWORD

引擎行为

  1. WindowFromPoint → 可能先命中对话框 → findEditAtPoint 找到子 Edit;
  2. ES_PASSWORD 绕过可能失败;
  3. EM_SETPASSWORDCHAR 清 0WM_GETTEXT 成功 → 恢复 *
  4. 标签 Edit · EM_SETPASSWORDCHAR

这是路径一 fallback ② 的典型用例。

9.2 老式 IE 内嵌表单

现象:窗口类名 Internet Explorer_Server,页面里有 <input type="password">

引擎行为

  1. 首次进入:WM_HTML_GETOBJECT 建立 ie_root_
  2. 移动鼠标:accHitTest 命中 role=password → get_accValue
  3. 移过工具窗:路径 2b 仍可能更新;
  4. 移到 IE 外普通 Edit:clearIeSession 后改走路径一。

9.3 Chrome / Edge 里的网站密码框(读不到)

原因(分层说明)

  1. 窗口类名是 Chrome 自有类,在黑名单内;
  2. 网页输入发生在 渲染进程 的独立 surface 里,不是 Win32 Edit
  3. 密码框由 Blink 绘制,不响应 WM_GETTEXT
  4. 现代浏览器对跨进程 accessibility 也做了严格隔离。

结论:不是「工具偷懒」,而是架构上就没有经典 Win32 密码缓冲区可读。应用内密码框、Win32 原生对话框才是主战场。

9.4 以管理员运行的程序

若目标 高完整性(管理员),工具 普通用户 运行 → SendMessage 返回 ACCESS_DENIED → 界面 Denied

解决办法:让工具与目标同级权限(都普通或都管理员),而非绕过 UIPI——那是系统故意设计的安全边界。

9.5 ComboBox 回归靶场

项目内 ComboBoxPickerTest 提供多种 ComboBox 变体(含 ES_PASSWORD 子 Edit、ComboBoxEx32),用于验证:

便于开发回归,也帮助理解「下拉框里藏着一个 Edit」这一结构。


十、能力边界与安全观

10.1 通常可以读取

10.2 通常无法读取

10.3 合规使用

适合在您有权操作的设备与软件上:核对已输入密码、运维排障、学习 Win32 机制。
不适合:未授权访问他人设备、绕过他人系统的访问控制。
本工具不能替代 KeePass、Bitwarden 等正规凭据管理。


十一、常见误解 FAQ

Q:读到明文是不是说明网站加密没用?
A:不是。HTTPS 保护的是传输过程;密码进浏览器后要在输入框里暂存明文才能提交,读的是本机 UI 层,与 TLS 无关。

Q:会不会偷偷上传密码?
A:实现上数据流止于本机界面;是否复制到剪贴板由您点击决定。若您自行改源码或装不可信构建,则另当别论——请使用可信来源。

Q:和「浏览器保存密码后查看」有何不同?
A:浏览器保存的是持久化凭据存储(常加密);本工具读的是当前这次填在控件里的字符串,不写库。

Q:为什么有时显示「已进入 IE 区域」但没有字?
A:鼠标在 IE 控件上但未对准 password/text 角色元素(例如点在空白处),状态为 IeHint,继续移到输入框即可。

Q:120 字符够用吗?
A:对绝大多数密码框足够;极长 token 可能被截断——这是与经典工具一致的上限设计。

Q:松开鼠标后目标窗口会变吗?
A:ES_PASSWORD / EM_SETPASSWORDCHAR 路径都会在读完后恢复原样式或掩码字符;正常应无可见副作用。

Q:Linux / macOS 呢?
A:当前引擎仅实现 Win32;非 Windows 平台 probeAt 返回「仅支持 Windows」。


附录 A:路径与状态对照总表

条件 策略 成功标签 失败状态
Edit + ES_PASSWORD 样式绕过 Win32 Edit · ES_PASSWORD Denied / Empty
Edit + 掩码 char EM_SETPASSWORDCHAR Edit · EM_SETPASSWORDCHAR Denied / Empty
Edit 普通 WM_GETTEXT Win32 Edit · WM_GETTEXT Denied / Empty
Internet Explorer_Server MSAA IE · MSAA IeHint
ComboBox 可编辑 CB_GETEDITCONTROL → Edit ComboBox · Edit Unsupported
ComboBox 只读 CB_GETLBTEXT ComboBox · CB_GETLBTEXT Unsupported
Chrome / Firefox / UWP 类 黑名单 Unsupported
高完整性目标 Denied

结语

星号密码查看器的原理可以浓缩为:

密码框里的字一直在,* 只是画出来的;在用户主动拾取的前提下,用 Windows 公开 API 把这段字读到自己窗口里。

读懂 Win32 的「显示与存储分离」,就读懂了这类工具 90% 的边界:能读的是经典控件缓冲区,读不了的是现代浏览器与自绘 UI

若您想亲手试用,请参阅 《功能介绍》。若希望继续深入,可在 Microsoft Learn 查阅 ES_PASSWORDWM_GETTEXTIAccessibleCB_GETEDITCONTROLUIPI 等主题。