从 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 | 路径与状态对照总表 |
很多 Windows 程序里的密码框,看起来是一串 * 或 ●,但控件内部通常一直保存着您输入的真实字符(在 Win32 里往往是 UTF-16 宽字符串,存在 Edit 控件的内部缓冲区里)。
以标准 Win32 Edit 控件为例,样式里有一个位叫 ES_PASSWORD(0x0020)。它的含义是:
绘制文本时,用密码字符(默认
*)代替每个真实字符。
它不是加密、不是哈希、不是把明文删掉。就像 Word 里把字体设成白色——字还在,只是显示方式变了。
因此,只要程序用的是「带密码样式的经典文本框」,并且您在本机有权限向该窗口发消息,就有可能通过 Windows 提供的合法接口问一句:「你缓冲区里现在是什么?」——答案就是明文。
可以概括为四步:
HWND),再按控件类型选策略读取文本;
拾取时:左侧为工具窗,右侧为目标程序;引擎跨进程读取目标控件内的文本
更准确的说法是:它没有破解加密,只是读取 UI 控件里已有的缓冲区内容。
要理解拾取引擎,需要先弄清几个 Win32 概念。不必会写 C++,知道「系统怎么组织界面」即可。
每个可见(或不可见)的窗口、控件,在系统里都有一个 HWND(窗口句柄)——可以理解成系统内部的「身份证号」。
HWND;HWND;鼠标点在屏幕上时,系统需要回答:「这个坐标最上面是哪个窗口?」——这就是 WindowFromPoint 做的事。
Windows 界面是一棵树:
顶级窗口(例如 Xshell 主窗)
└─ 对话框 #32770
└─ Edit(真正的密码输入区)
有时鼠标下的 HWND 是父对话框,而不是里面的 Edit。拾取引擎会:
RealChildWindowFromPoint 从坐标向下穿透几层;EnumChildWindows,找类名像 Edit、TEdit 且包含鼠标点的最小矩形。这就是为什么「点在对话框空白处,仍能读到里面密码框」——引擎会主动找子 Edit。
SendMessage(WM_GETTEXT, …) 的意思是:向目标窗口发送「请把你缓冲区的文字复制到我给的缓冲区里」。
要点:
| 概念 | 说明 |
|---|---|
| 跨进程 | 目标程序在另一个进程;Windows 内核会把消息投递到对方线程,由对方窗口过程处理 |
| 同步 | SendMessage 会等对方处理完才返回 |
| 权限 | 若目标进程完整性级别更高(例如「以管理员运行」),普通用户进程可能被 UIPI 拒绝 |
这不是注入、不是 Hook,而是 Win32 公开文档化的消息机制,Spy++、Accessibility Insights 等工具同样依赖类似思路。
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
渲染网页的 Renderer 进程沙箱化、焦点受限,很难稳定获得「全屏鼠标坐标」并调用原生 DLL。
主进程可以:
screen.getCursorScreenPoint() 读光标;setInterval(..., 30) 约每 30ms 采样一次;pickerProbeAt;webContents.send('picker:update', …) 推给界面。读取其他程序窗口需要直接调用 Win32 API 和 COM(IE 的 IHTMLDocument2、IAccessible)。C++ 延迟低,也和 UI 解耦:界面只展示,引擎只探测。
进程初始化时会 OleInitialize,保证 MSAA/COM 调用稳定。
开始拾取时,主进程把本工具窗口的 native HWND 传给引擎(pickerBegin(exclude_hwnd))。引擎会:
WindowFromPoint 命中本窗或其子窗 → 返回空,不读;GetParent 链向上,任何一级是 exclude 窗口都跳过。usePicker hook 监听 picker:update / picker:ended:更新坐标、结果文本、路径标签(如 Win32 Edit · ES_PASSWORD)。不直接碰 Win32,只消费主进程推送的结构化结果。

stateDiagram-v2
[*] --> Idle
Idle --> Picking: 在拾取热区按下左键
Picking --> Picking: 移动鼠标 每30ms探测
Picking --> Idle: 松开左键
Idle --> [*]
| 状态 | 用户侧 | 系统侧 |
|---|---|---|
| 空闲 | 显示上次结果或占位 | 不探测 |
| 拾取中 | 十字光标、坐标变化、目标白框 | pickerProbeAt 循环;IE 会话可能保持 |
| 结束 | 提示已退出 | pickerEnd:擦高亮、clearIeSession |
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
| state | 含义 | 界面典型文案 |
|---|---|---|
| Success | 读到非空文本 | 显示明文 + 路径标签 |
| IeHint | 进入 IE 区域但未命中 input | 「已进入 IE 浏览器区域」 |
| Denied | UIPI 等权限拒绝 | 「无法访问目标窗口…」 |
| Unsupported | 类名黑名单或不支持 | 「当前控件不支持读取」 |
| Empty | 无窗口、自身窗口、空控件 | 空白或占位 |
每次 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]
关键设计点:
ie_root 做 accHitTest,避免 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 或 不支持]
适用:类名为 Edit,或 TEdit(Delphi)、ThunderRT6TextBox(VB6)、含 .EDIT. 的 MFC 等。
核心思路:密码样式只影响绘制;临时去掉密码模式 → WM_GETTEXT → 立即恢复。
| 步骤 | 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) // 恢复
}
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)。
RealChildWindowFromPoint:最多向下 16 层;EnumChildWindows + 最小面积 Edit:点在父窗上找真正输入区。ComboBox 的可编辑区另见 7.3。
适用:类名 Internet Explorer_Server——仍用 IE/Trident 内核嵌入页面的老式程序(部分工控、老 ERP、旧版内嵌 WebView)。
不适用:Chrome、Edge(Chromium)、Firefox 的网页——它们的渲染层不是 Win32 Edit,也没有上述类名(见 九、场景走查)。
MSAA(Microsoft Active Accessibility) 是 Windows 早期的无障碍接口。屏幕阅读器用它获取「这是什么控件、当前值是多少」。
IE 的 HTML 元素会映射到 IAccessible 树:角色(role)可以是 password、text,值(value)即输入框里的字符串。
工具走的是文档化的 COM 接口,不是私有协议。
WM_HTML_GETOBJECT;ObjectFromLresult 拿到文档相关对象;IHTMLDocument2,再拿到文档的 IAccessible 根并缓存(ie_root_)。若 COM 失败,仍可能显示 IeHint(「已进入 IE 浏览器区域」),路径标签 IE · WM_HTML_GETOBJECT。
对缓存的根对象:
accHitTest(x, y) — 坐标先试 IE 客户区(ScreenToClient),未命中再试屏幕坐标;ROLE_SYSTEM_PASSWORD / ROLE_SYSTEM_TEXT(或字符串 "password" / "text");get_accValue 读当前值;还可降级尝试 IHTMLElement 相关接口(实现中的补充路径)。
鼠标移过工具窗口时,WindowFromPoint 可能命中工具自身,但 ie_root_ 仍有效。此时走 probeIeInner,继续用 IE 根做 hitTest,使在 IE 页面内移动时光标不必一直压在 IE 控件上也能更新结果。
进入 Edit、ComboBox、Generic 分支前都会 clearIeSession()(释放 IAccessible、清空 ie_server_hwnd_),避免从 IE 切到普通控件后仍用 IE 树探测。
对实现了 WM_GETTEXT 的标准控件直接读取;失败则 Unsupported 或 Denied。
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 区域 → findComboBoxEditAtPoint → probeEdit;否则 probeComboBoxList 读当前选中项文字。
切换目标时,在目标客户区用 XOR 模式(R2_XORPEN)画 3 像素白色空心矩形:
切换目标或结束拾取时 eraseHighlight 擦除。
对已知无法通过 Win32 消息可靠读取的类名,直接 Unsupported,避免长时间 SendMessage 挂起或误导:
| 类名(部分) | 原因 |
|---|---|
Chrome_WidgetWin_1 / Chrome_RenderWidgetHostHWND |
Chromium 多进程渲染,非 Edit |
MozillaWindowClass / 含 Firefox |
Gecko 自绘 |
ApplicationFrameWindow / Windows.UI.Core.CoreWindow |
UWP |
Credential Dialog Xaml Host |
系统凭据 UI |
含 Chrome、Mozilla、Firefox 子串的类名也会拦截。
成功或部分成功时,界面右下角显示简短标签,表示本次命中的策略,便于您判断场景是否被支持,例如:
Win32 Edit · ES_PASSWORDEdit · EM_SETPASSWORDCHARComboBox · EditIE · MSAA目标控件内存 → C++ 引擎(栈上 wchar 缓冲)→ UTF-8 字符串 → 主进程 → React 状态
现象:密码框显示 *,类名常为 Edit 或类似,未必置 ES_PASSWORD。
引擎行为:
WindowFromPoint → 可能先命中对话框 → findEditAtPoint 找到子 Edit;ES_PASSWORD 绕过可能失败;EM_SETPASSWORDCHAR 清 0 → WM_GETTEXT 成功 → 恢复 *;Edit · EM_SETPASSWORDCHAR。这是路径一 fallback ② 的典型用例。
现象:窗口类名 Internet Explorer_Server,页面里有 <input type="password">。
引擎行为:
WM_HTML_GETOBJECT 建立 ie_root_;accHitTest 命中 role=password → get_accValue;clearIeSession 后改走路径一。原因(分层说明):
Edit;WM_GETTEXT;结论:不是「工具偷懒」,而是架构上就没有经典 Win32 密码缓冲区可读。应用内密码框、Win32 原生对话框才是主战场。
若目标 高完整性(管理员),工具 普通用户 运行 → SendMessage 返回 ACCESS_DENIED → 界面 Denied。
解决办法:让工具与目标同级权限(都普通或都管理员),而非绕过 UIPI——那是系统故意设计的安全边界。
项目内 ComboBoxPickerTest 提供多种 ComboBox 变体(含 ES_PASSWORD 子 Edit、ComboBoxEx32),用于验证:
ComboBox · Edit;ComboBox · CB_GETLBTEXT。便于开发回归,也帮助理解「下拉框里藏着一个 Edit」这一结构。
Edit 密码框、普通 Edit<input type="password">(MSAA)PasswordBox、Qt 自绘、纯 UWP适合在您有权操作的设备与软件上:核对已输入密码、运维排障、学习 Win32 机制。
不适合:未授权访问他人设备、绕过他人系统的访问控制。
本工具不能替代 KeePass、Bitwarden 等正规凭据管理。
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」。
| 条件 | 策略 | 成功标签 | 失败状态 |
|---|---|---|---|
| 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_PASSWORD、WM_GETTEXT、IAccessible、CB_GETEDITCONTROL、UIPI 等主题。